Tours: filter a tour list by source, routing limit and type where it varies (PR1-13)

This commit is contained in:
Maurice
2026-10-15 22:02:41 +02:00
parent 3d9095ccde
commit cd1b1410bd
36 changed files with 632 additions and 87 deletions
+6 -1
View File
@@ -717,12 +717,17 @@
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
@@ -0,0 +1,190 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { InfoDot } from './tourParts';
import type { TourFilter } from './tourPresentation';
import type { TourListFilters } from './useTourListFilters';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** How each origin is named in a list, the words the source badge uses. */
export const ORIGIN_LABEL_KEY = {
trek: 'tours.badge.planned',
gpx: 'tours.badge.gpx',
wanderer: 'tours.badge.wanderer',
} as const;
export function statusLabel(status: TourFilter, t: Translate): string {
if (status === 'unplanned') return t('places.unplanned');
if (status === 'planned') return t('places.planned');
return t('places.all');
}
function MenuRow({
active,
label,
count,
onClick,
}: {
active: boolean;
label: string;
count: number;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{count}
</span>
<span className="grid w-3.5 flex-none place-items-center">
{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}
</span>
</button>
);
}
function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div
role="group"
aria-label={label}
className="flex flex-col gap-px border-t border-edge-faint pt-1.5 first:border-t-0 first:pt-0"
>
<div className="flex items-center gap-1 px-2 pb-0.5">
<span className="font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
{label}
</span>
{hint && <InfoDot label={label} description={hint} />}
</div>
{children}
</div>
);
}
/**
* The filter of a tour list (PR1-13): one button that says what is set, and a
* menu with the day status and, where the list varies in them, the source,
* the stored routing limit and the tour type, each with how many tours it
* keeps. Choosing a set value again clears it.
*/
export default function TourListFilterMenu({ filters }: { filters: TourListFilters }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { facets, selection, statusCounts } = filters;
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
setOpen(false);
buttonRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
return (
<div ref={rootRef} className="relative min-w-0">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}
>
<span className="min-w-0 truncate text-start">{statusLabel(filters.status, t)}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>
{filters.filtered.length}
</span>
{filters.activeFacets > 0 && (
<span
className="rounded-full bg-accent px-1.5 font-geist font-semibold leading-[16px] text-accent-text"
style={fs(10)}
>
+{filters.activeFacets}
</span>
)}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`ms-auto flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
<MenuSection label={t('tours.filter.status')}>
{(['all', 'unplanned', 'planned'] as const).map((status) => (
<MenuRow
key={status}
active={filters.status === status}
label={statusLabel(status, t)}
count={statusCounts[status]}
onClick={() => filters.setStatus(status)}
/>
))}
</MenuSection>
{facets.origin.length > 0 && (
<MenuSection label={t('tours.filter.source')}>
{facets.origin.map((option) => (
<MenuRow
key={option.value}
active={selection.origin === option.value}
label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count}
onClick={() => filters.select('origin', option.value)}
/>
))}
</MenuSection>
)}
{facets.grade.length > 0 && (
<MenuSection label={t('tours.filter.grade')} hint={t('tours.filter.gradeHint')}>
{facets.grade.map((option) => (
<MenuRow
key={option.value}
active={selection.grade === option.value}
label={`T${option.value}`}
count={option.count}
onClick={() => filters.select('grade', option.value)}
/>
))}
</MenuSection>
)}
{facets.type.length > 0 && (
<MenuSection label={t('tours.filter.type')}>
{facets.type.map((option) => (
<MenuRow
key={option.value}
active={selection.type === option.value}
label={t(`tourTypes.${option.value}`)}
count={option.count}
onClick={() => filters.select('type', option.value)}
/>
))}
</MenuSection>
)}
</div>
)}
</div>
);
}
+17 -63
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, EyeOff, FileDown } from 'lucide-react'
import React, { useEffect, useMemo, useRef } from 'react'
import { EyeOff, FileDown } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import type { Day } from '../../types'
import { useTranslation } from '../../i18n'
@@ -9,7 +9,8 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import { filterTours, type TourFilter } from './tourPresentation'
import TourListFilterMenu from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import { useTourGpxImport } from './useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
import { useTourMapVisibility } from './useTourMapVisibility'
@@ -44,10 +45,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
const { t } = useTranslation()
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
const [filter, setFilter] = useState<TourFilter>('all')
const filterRef = useRef<HTMLDivElement>(null)
const filterButtonRef = useRef<HTMLButtonElement>(null)
const [filterOpen, setFilterOpen] = useState(false)
const filters = useTourListFilters(tours)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
@@ -66,35 +64,6 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
row?.scrollIntoView?.({ block: 'nearest' })
}, [selectedPlaceId])
const counts = {
all: tours.length,
unplanned: tours.filter(tr => !tr.planned).length,
planned: tours.filter(tr => tr.planned).length,
}
const filtered = filterTours(tours, filter)
useEffect(() => {
if (!filterOpen) return
const onDown = (event: MouseEvent) => {
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.stopPropagation()
setFilterOpen(false)
filterButtonRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [filterOpen])
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
const filterCount = counts[filter]
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
<div className="flex flex-none flex-col gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: NEUTRAL_TINT }}>
@@ -110,31 +79,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
</button>
</Tooltip>
</div>
<div ref={filterRef} className="relative min-w-0">
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}>
<span className="min-w-0 truncate text-start">{filterLabel}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`ms-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
</button>
{filterOpen && (
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
{(['all', 'unplanned', 'planned'] as const).map(id => {
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
const active = filter === id
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
</button>
})}
</div>
)}
</div>
<TourListFilterMenu filters={filters} />
{visibility.hiddenCount > 0 && (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
@@ -150,12 +95,21 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
</div>
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!loading && filtered.length === 0 ? (
{!loading && filters.emptyByFilter ? (
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset}
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.filter.reset')}
</button>
</div>
) : !loading && filters.filtered.length === 0 ? (
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
) : (
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filtered.map(tour => (
{filters.filtered.map(tour => (
<TourListRow
key={tour.place_id}
tour={tour}
@@ -46,6 +46,14 @@ import type { TourPlannerController } from './planner/useTourPlanner'
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
import TourDetailDialog from './TourDetailDialog'
import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList'
import type { ComponentProps } from 'react'
import { useTourListFilters } from './useTourListFilters'
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
const filters = useTourListFilters(props.planner.tours)
return <MToursSelectionList {...props} filters={filters} />
}
const tour = { place_id: 42, name: 'Ridge', tour_type: 'hike', max_hiking_difficulty: 2, planned: false } as TourListItem
const days = [{ id: 3, title: 'Summit' }] as never
@@ -133,7 +141,7 @@ describe('Tours permissions without database setup', () => {
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
const view = vi.fn()
const openSheet = vi.fn()
rerender(<MToursSelectionList planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} filter="all" canEdit={canEdit} canAssign={canAssign} />)
rerender(<List planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
fireEvent.click(screen.getByRole('option'))
expect(view).toHaveBeenCalledWith(42)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
@@ -0,0 +1,117 @@
import type { TourListItem, TourOrigin, TourTypeKey } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import { filterTours, tourSource, type TourFilter } from './tourPresentation';
export type TourFacet = 'origin' | 'grade' | 'type';
export interface TourFacetOption<V> {
value: V;
/** How many tours of the list carry it, under the other filters as they are set. */
count: number;
}
interface TourFacetSelection {
origin: TourOrigin | null;
grade: number | null;
type: TourTypeKey | null;
}
const NO_SELECTION: TourFacetSelection = { origin: null, grade: null, type: null };
const ORIGIN_ORDER: TourOrigin[] = ['trek', 'gpx', 'wanderer'];
function facetValue(tour: TourListItem, facet: TourFacet): string | number | null {
if (facet === 'origin') return tourSource(tour);
if (facet === 'grade') return tour.max_hiking_difficulty;
return tour.tour_type;
}
function matches(tour: TourListItem, selection: TourFacetSelection, except?: TourFacet): boolean {
return (['origin', 'grade', 'type'] as const).every(
(facet) => facet === except || selection[facet] === null || facetValue(tour, facet) === selection[facet]
);
}
/** The values a facet takes in the list, each with its count; a value no tour carries is not offered. */
function optionsOf<V extends string | number>(
tours: readonly TourListItem[],
facet: TourFacet,
order: (a: V, b: V) => number
): TourFacetOption<V>[] {
const counts = new Map<V, number>();
for (const tour of tours) {
const value = facetValue(tour, facet) as V | null;
if (value !== null) counts.set(value, (counts.get(value) ?? 0) + 1);
}
return [...counts.entries()].sort(([a], [b]) => order(a, b)).map(([value, count]) => ({ value, count }));
}
/**
* PR1-13: the filters of a tour list, the same on the desktop rail and the
* phone list. Next to the day status (all, not yet on a day, on a day) it
* offers the source, the stored routing limit and the tour type, but only a
* dimension the list really varies in (two values or more), so a small list
* keeps its single filter. Filtering only narrows what the list shows; tours
* and their days are not touched.
*/
export function useTourListFilters(tours: readonly TourListItem[]) {
const [status, setStatus] = useState<TourFilter>('all');
const [selection, setSelection] = useState<TourFacetSelection>(NO_SELECTION);
const byStatus = useMemo(() => filterTours([...tours], status), [tours, status]);
const facets = useMemo(() => {
// Each dimension counts under the others as set, so its numbers say what choosing it would show.
const base = (facet: TourFacet) => byStatus.filter((tour) => matches(tour, selection, facet));
const origin = optionsOf<TourOrigin>(
base('origin'),
'origin',
(a, b) => ORIGIN_ORDER.indexOf(a) - ORIGIN_ORDER.indexOf(b)
);
const grade = optionsOf<number>(base('grade'), 'grade', (a, b) => a - b);
const type = optionsOf<TourTypeKey>(base('type'), 'type', (a, b) => a.localeCompare(b));
// A dimension the whole list does not vary in is no choice at all.
const varies = (facet: TourFacet) =>
new Set(tours.map((tour) => facetValue(tour, facet)).filter((v) => v !== null)).size >= 2;
return {
origin: varies('origin') ? origin : [],
grade: varies('grade') ? grade : [],
type: varies('type') ? type : [],
};
}, [byStatus, selection, tours]);
const filtered = useMemo(() => byStatus.filter((tour) => matches(tour, selection)), [byStatus, selection]);
const select = useCallback(<F extends TourFacet>(facet: F, value: TourFacetSelection[F]) => {
setSelection((current) => ({ ...current, [facet]: current[facet] === value ? null : value }));
}, []);
const reset = useCallback(() => {
setStatus('all');
setSelection(NO_SELECTION);
}, []);
const activeFacets = (['origin', 'grade', 'type'] as const).filter((facet) => selection[facet] !== null).length;
return {
status,
setStatus,
selection,
select,
facets,
/** The list has more to choose by than the day status. */
hasFacets: facets.origin.length > 0 || facets.grade.length > 0 || facets.type.length > 0,
filtered,
statusCounts: {
all: tours.length,
unplanned: tours.filter((tour) => !tour.planned).length,
planned: tours.filter((tour) => tour.planned).length,
},
/** How many filters beyond the status narrow the list. */
activeFacets,
/** Something is set and nothing is left: the list says so and offers to clear it. */
emptyByFilter: tours.length > 0 && filtered.length === 0 && (status !== 'all' || activeFacets > 0),
reset,
};
}
export type TourListFilters = ReturnType<typeof useTourListFilters>;
@@ -20,7 +20,7 @@ import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
import MToursSelectionList from './MToursSelectionList'
import type { TourFilter } from '../../../../components/Tours/tourPresentation'
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
@@ -47,7 +47,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
// Top level of the places browser, mirroring the desktop right add-panel's
// Places <-> Tours switch placement.
const [toursMode, setToursMode] = useState(false)
const [toursFilter, setToursFilter] = useState<TourFilter>('all')
const tourFilters = useTourListFilters(planner.tours)
const poolPlaces = useMemo(
// Places that are tours stay out of the Places pool while the addon is on.
@@ -124,11 +124,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
{/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select
buttons: tours are attached here, never created or bulk-managed. */}
<div className="flex items-center gap-[6px]">
<FilterChip active={toursFilter === 'all'} label={t('places.all')} onClick={() => setToursFilter('all')} />
<FilterChip active={toursFilter === 'unplanned'} label={t('places.unplanned')} onClick={() => setToursFilter('unplanned')} />
<FilterChip active={toursFilter === 'planned'} label={t('places.planned')} onClick={() => setToursFilter('planned')} />
<FilterChip active={tourFilters.status === 'all'} label={t('places.all')} onClick={() => tourFilters.setStatus('all')} />
<FilterChip active={tourFilters.status === 'unplanned'} label={t('places.unplanned')} onClick={() => tourFilters.setStatus('unplanned')} />
<FilterChip active={tourFilters.status === 'planned'} label={t('places.planned')} onClick={() => tourFilters.setStatus('planned')} />
</div>
<MToursSelectionList planner={planner} shell={shell} filter={toursFilter} />
<MToursSelectionList planner={planner} shell={shell} filters={tourFilters} />
</>
) : (
<>
@@ -7,6 +7,14 @@ import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
import { useSettingsStore } from '../../../../store/settingsStore'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import MToursSelectionList from './MToursSelectionList'
import type { ComponentProps } from 'react'
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
const filters = useTourListFilters(props.planner.tours)
return <MToursSelectionList {...props} filters={filters} />
}
const tour: TourListItem = {
place_id: 42,
@@ -70,19 +78,19 @@ describe('MToursSelectionList', () => {
const value = de[key] ?? en[key]
return typeof value === 'string' ? value : key
} }
const { rerender } = render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
const { rerender } = render(<List planner={germanPlanner} shell={shell()} />)
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument()
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern')
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument()
rerender(<MToursSelectionList planner={{ ...germanPlanner, tours: [] }} shell={shell()} filter="all" />)
rerender(<List planner={{ ...germanPlanner, tours: [] }} shell={shell()} />)
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument()
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/)
})
it('gives a tour name the full row and up to two lines', () => {
render(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
render(<List planner={planner()} shell={shell()} />)
const name = screen.getByText(tour.name)
expect(name).toHaveClass('line-clamp-2', 'break-words')
@@ -91,7 +99,7 @@ describe('MToursSelectionList', () => {
})
it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => {
const { container } = render(<MToursSelectionList planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} filter="all" canEdit canAssign />)
const { container } = render(<List planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} canEdit canAssign />)
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true')
const metrics = screen.getByTestId('mobile-tour-metrics')
expect(metrics).toHaveTextContent('12.5 km')
@@ -120,7 +128,7 @@ describe('MToursSelectionList', () => {
return typeof value === 'string' ? value : key
},
}
render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
render(<List planner={germanPlanner} shell={shell()} />)
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument()
expect(screen.getByText('GPX-Import')).toBeInTheDocument()
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument()
@@ -129,7 +137,7 @@ describe('MToursSelectionList', () => {
it('shows no T-badge for an imported tour that carries no difficulty', () => {
const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null }
render(<MToursSelectionList planner={{ ...planner(), tours: [imported] }} shell={shell()} filter="all" />)
render(<List planner={{ ...planner(), tours: [imported] }} shell={shell()} />)
const row = screen.getByRole('option', { name: new RegExp(tour.name) })
expect(row).not.toHaveTextContent(/T\d/)
@@ -139,9 +147,9 @@ describe('MToursSelectionList', () => {
})
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
const { rerender } = render(<MToursSelectionList planner={planner()} shell={shell({ id: 'bract' })} filter="all" />)
const { rerender } = render(<List planner={planner()} shell={shell({ id: 'bract' })} />)
rerender(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
rerender(<List planner={planner()} shell={shell()} />)
expect(reloadTourPlaceIds).not.toHaveBeenCalled()
})
@@ -2,15 +2,34 @@ import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucid
import type { TourListItem } from '@trek/shared'
import { useSettingsStore } from '../../../../store/settingsStore'
import { formatDistance, formatElevation } from '../../../../utils/units'
import { filterTours, hikeSourceBadgeLabel, type TourFilter } from '../../../../components/Tours/tourPresentation'
import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation'
import { ORIGIN_LABEL_KEY } from '../../../../components/Tours/TourListFilterMenu'
import type { TourListFilters } from '../../../../components/Tours/useTourListFilters'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
interface MToursSelectionListProps extends TourPermissionProps {
planner: TripPlanner
shell: MTripShellApi
/** All / Unplanned / Planned — same three states as the Places pool. */
filter: TourFilter
/** The list's filters (PR1-13): the day status chips sit above, the other dimensions here. */
filters: TourListFilters
}
/** One value of a dimension as a chip of the row; tapping a chosen one clears it. */
function FacetChip({ active, label, count, onClick }: { active: boolean; label: string; count: number; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[0.75rem] font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
}`}
>
{label}
<span className="font-geist text-[0.6875rem] opacity-60">{count}</span>
</button>
)
}
/**
@@ -22,13 +41,42 @@ interface MToursSelectionListProps extends TourPermissionProps {
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
* Place under the hood, so no new linking code is needed here either.
*/
export default function MToursSelectionList({ planner, shell, filter, canAssign: assignPermission }: MToursSelectionListProps) {
export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) {
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission })
const { t } = planner
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const tours: TourListItem[] = planner.tours
const filtered: TourListItem[] = filters.filtered
const { facets, selection } = filters
const facetRow = filters.hasFacets && (
<div className="-mx-4 mt-2 flex gap-[6px] overflow-x-auto px-4 pb-1">
{facets.origin.map(option => (
<FacetChip key={`o-${option.value}`} active={selection.origin === option.value} label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count} onClick={() => filters.select('origin', option.value)} />
))}
{facets.grade.map(option => (
<FacetChip key={`g-${option.value}`} active={selection.grade === option.value} label={`T${option.value}`}
count={option.count} onClick={() => filters.select('grade', option.value)} />
))}
{facets.type.map(option => (
<FacetChip key={`t-${option.value}`} active={selection.type === option.value} label={t(`tourTypes.${option.value}`)}
count={option.count} onClick={() => filters.select('type', option.value)} />
))}
</div>
)
const filtered = filterTours(tours, filter)
if (filters.emptyByFilter) {
return (
<>
{facetRow}
<div role="status" className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
<p className="font-geist text-[0.78125rem] text-m-muted">{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg">
{t('tours.filter.reset')}
</button>
</div>
</>
)
}
if (filtered.length === 0) {
return (
@@ -50,6 +98,7 @@ export default function MToursSelectionList({ planner, shell, filter, canAssign:
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
{t('tours.planner.mobileHint')}
</p>
{facetRow}
<ul className="mt-3 space-y-2">
{filtered.map(tour => {
const isSelected = planner.selectedPlaceId === tour.place_id
+8
View File
@@ -269,5 +269,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'إظهار الكل',
'tours.visibility.show': 'إظهار على الخريطة',
'tours.visibility.hide': 'إخفاء على الخريطة',
'tours.filter.status': 'الحالة',
'tours.filter.source': 'المصدر',
'tours.filter.grade': 'حد المسار',
'tours.filter.gradeHint':
'أعلى صعوبة سُمح للمسار باستخدامها عند التخطيط، كما حُفظت مع الجولة. وليست الصعوبة المقاسة على الدرب.',
'tours.filter.type': 'النوع',
'tours.filter.empty': 'لا توجد جولة تطابق هذه المرشحات.',
'tours.filter.reset': 'إعادة ضبط المرشحات',
};
export default tours;
+8
View File
@@ -258,5 +258,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Hamısını göstər',
'tours.visibility.show': 'Xəritədə göstər',
'tours.visibility.hide': 'Xəritədə gizlət',
'tours.filter.status': 'Vəziyyət',
'tours.filter.source': 'Mənbə',
'tours.filter.grade': 'Marşrut həddi',
'tours.filter.gradeHint':
'Marşrutun planlaşdırılarkən istifadə edə biləcəyi ən yüksək çətinlik, turla birlikdə saxlandığı kimi. Cığırda ölçülən çətinlik deyil.',
'tours.filter.type': 'Növ',
'tours.filter.empty': 'Bu filtrlərə uyğun tur yoxdur.',
'tours.filter.reset': 'Filtrləri sıfırla',
};
export default tours;
+8
View File
@@ -263,5 +263,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Mostrar todos',
'tours.visibility.show': 'Mostrar no mapa',
'tours.visibility.hide': 'Ocultar no mapa',
'tours.filter.status': 'Situação',
'tours.filter.source': 'Fonte',
'tours.filter.grade': 'Limite de rota',
'tours.filter.gradeHint':
'A dificuldade máxima que a rota podia usar no planejamento, como salva no passeio. Não é a dificuldade medida na trilha.',
'tours.filter.type': 'Tipo',
'tours.filter.empty': 'Nenhum passeio corresponde a esses filtros.',
'tours.filter.reset': 'Limpar filtros',
};
export default tours;
+8
View File
@@ -264,5 +264,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Mostra-les totes',
'tours.visibility.show': 'Mostra al mapa',
'tours.visibility.hide': 'Amaga al mapa',
'tours.filter.status': 'Estat',
'tours.filter.source': 'Font',
'tours.filter.grade': 'Límit de ruta',
'tours.filter.gradeHint':
'La dificultat màxima que la ruta podia fer servir en planificar-la, tal com es va desar amb l’excursió. No la dificultat mesurada al camí.',
'tours.filter.type': 'Tipus',
'tours.filter.empty': 'Cap excursió no coincideix amb aquests filtres.',
'tours.filter.reset': 'Restableix els filtres',
};
export default tours;
+8
View File
@@ -258,5 +258,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Zobrazit vše',
'tours.visibility.show': 'Zobrazit na mapě',
'tours.visibility.hide': 'Skrýt na mapě',
'tours.filter.status': 'Stav',
'tours.filter.source': 'Zdroj',
'tours.filter.grade': 'Limit trasy',
'tours.filter.gradeHint':
'Nejvyšší obtížnost, kterou trasa směla při plánování použít, jak je uložena u túry. Ne naměřená obtížnost cesty.',
'tours.filter.type': 'Druh',
'tours.filter.empty': 'Těmto filtrům neodpovídá žádná túra.',
'tours.filter.reset': 'Zrušit filtry',
};
export default tours;
+8
View File
@@ -265,6 +265,14 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Alle einblenden',
'tours.visibility.show': 'Auf der Karte einblenden',
'tours.visibility.hide': 'Auf der Karte ausblenden',
'tours.filter.status': 'Status', // same-as-en
'tours.filter.source': 'Quelle',
'tours.filter.grade': 'Routing-Grenze',
'tours.filter.gradeHint':
'Die höchste Schwierigkeit, die die Route beim Planen nutzen durfte, wie sie an der Tour gespeichert ist. Nicht die gemessene Schwierigkeit des Weges.',
'tours.filter.type': 'Art',
'tours.filter.empty': 'Keine Tour passt zu diesen Filtern.',
'tours.filter.reset': 'Filter zurücksetzen',
};
export default tours;
+8
View File
@@ -257,5 +257,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Show all',
'tours.visibility.show': 'Show on the map',
'tours.visibility.hide': 'Hide on the map',
'tours.filter.status': 'Status',
'tours.filter.source': 'Source',
'tours.filter.grade': 'Routing limit',
'tours.filter.gradeHint':
'The highest difficulty the route was planned to allow, as stored with the tour. Not the difficulty measured on the trail.',
'tours.filter.type': 'Type',
'tours.filter.empty': 'No tour matches these filters.',
'tours.filter.reset': 'Reset filters',
};
export default tours;
+8
View File
@@ -262,5 +262,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Mostrar todas',
'tours.visibility.show': 'Mostrar en el mapa',
'tours.visibility.hide': 'Ocultar en el mapa',
'tours.filter.status': 'Estado',
'tours.filter.source': 'Fuente',
'tours.filter.grade': 'Límite de ruta',
'tours.filter.gradeHint':
'La dificultad máxima que la ruta podía usar al planificarse, tal como se guardó con la excursión. No la dificultad medida en el sendero.',
'tours.filter.type': 'Tipo',
'tours.filter.empty': 'Ninguna excursión coincide con estos filtros.',
'tours.filter.reset': 'Restablecer filtros',
};
export default tours;
+8
View File
@@ -260,5 +260,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Näita kõiki',
'tours.visibility.show': 'Näita kaardil',
'tours.visibility.hide': 'Peida kaardil',
'tours.filter.status': 'Olek',
'tours.filter.source': 'Allikas',
'tours.filter.grade': 'Marsruudi piir',
'tours.filter.gradeHint':
'Suurim raskusaste, mida marsruut planeerimisel kasutada tohtis, nagu see on matkaga salvestatud. Mitte rajal mõõdetud raskus.',
'tours.filter.type': 'Liik',
'tours.filter.empty': 'Ükski matk ei vasta neile filtritele.',
'tours.filter.reset': 'Lähtesta filtrid',
};
export default tours;
+8
View File
@@ -267,5 +267,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Tout afficher',
'tours.visibility.show': 'Afficher sur la carte',
'tours.visibility.hide': 'Masquer sur la carte',
'tours.filter.status': 'Statut',
'tours.filter.source': 'Source', // same-as-en
'tours.filter.grade': 'Limite d’itinéraire',
'tours.filter.gradeHint':
'La difficulté maximale que l’itinéraire pouvait emprunter à la planification, telle qu’enregistrée avec la sortie. Pas la difficulté mesurée sur le sentier.',
'tours.filter.type': 'Type', // same-as-en
'tours.filter.empty': 'Aucune sortie ne correspond à ces filtres.',
'tours.filter.reset': 'Réinitialiser les filtres',
};
export default tours;
+8
View File
@@ -269,5 +269,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Εμφάνιση όλων',
'tours.visibility.show': 'Εμφάνιση στον χάρτη',
'tours.visibility.hide': 'Απόκρυψη στον χάρτη',
'tours.filter.status': 'Κατάσταση',
'tours.filter.source': 'Πηγή',
'tours.filter.grade': 'Όριο διαδρομής',
'tours.filter.gradeHint':
'Η μέγιστη δυσκολία που επιτρεπόταν στη διαδρομή κατά τον σχεδιασμό, όπως αποθηκεύτηκε με την εκδρομή. Όχι η μετρημένη δυσκολία του μονοπατιού.',
'tours.filter.type': 'Είδος',
'tours.filter.empty': 'Καμία εκδρομή δεν ταιριάζει με αυτά τα φίλτρα.',
'tours.filter.reset': 'Επαναφορά φίλτρων',
};
export default tours;
+8
View File
@@ -264,5 +264,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Összes megjelenítése',
'tours.visibility.show': 'Megjelenítés a térképen',
'tours.visibility.hide': 'Elrejtés a térképen',
'tours.filter.status': 'Állapot',
'tours.filter.source': 'Forrás',
'tours.filter.grade': 'Útvonalkorlát',
'tours.filter.gradeHint':
'A legnagyobb nehézség, amelyet az útvonal tervezéskor használhatott, a túrával mentve. Nem az ösvényen mért nehézség.',
'tours.filter.type': 'Fajta',
'tours.filter.empty': 'Egy túra sem felel meg ezeknek a szűrőknek.',
'tours.filter.reset': 'Szűrők törlése',
};
export default tours;
+8
View File
@@ -257,5 +257,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Tampilkan semua',
'tours.visibility.show': 'Tampilkan di peta',
'tours.visibility.hide': 'Sembunyikan di peta',
'tours.filter.status': 'Status', // same-as-en
'tours.filter.source': 'Sumber',
'tours.filter.grade': 'Batas rute',
'tours.filter.gradeHint':
'Tingkat kesulitan tertinggi yang boleh dipakai rute saat direncanakan, seperti tersimpan pada tur. Bukan kesulitan yang diukur di jalur.',
'tours.filter.type': 'Jenis',
'tours.filter.empty': 'Tidak ada tur yang cocok dengan filter ini.',
'tours.filter.reset': 'Atur ulang filter',
};
export default tours;
+8
View File
@@ -264,5 +264,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Mostra tutti',
'tours.visibility.show': 'Mostra sulla mappa',
'tours.visibility.hide': 'Nascondi sulla mappa',
'tours.filter.status': 'Stato',
'tours.filter.source': 'Fonte',
'tours.filter.grade': 'Limite di percorso',
'tours.filter.gradeHint':
'La difficoltà massima che il percorso poteva usare in fase di pianificazione, come salvata con il tour. Non la difficoltà misurata sul sentiero.',
'tours.filter.type': 'Tipo',
'tours.filter.empty': 'Nessun tour corrisponde a questi filtri.',
'tours.filter.reset': 'Reimposta filtri',
};
export default tours;
+8
View File
@@ -251,5 +251,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'すべて表示',
'tours.visibility.show': '地図に表示',
'tours.visibility.hide': '地図で非表示にする',
'tours.filter.status': '状態',
'tours.filter.source': 'ソース',
'tours.filter.grade': 'ルートの上限',
'tours.filter.gradeHint':
'計画時にルートが使ってよい最大の難易度で、ツアーに保存されている値です。道で測った難易度ではありません。',
'tours.filter.type': '種類',
'tours.filter.empty': 'この条件に合うツアーはありません。',
'tours.filter.reset': 'フィルターをリセット',
};
export default tours;
+8
View File
@@ -249,5 +249,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': '모두 표시',
'tours.visibility.show': '지도에 표시',
'tours.visibility.hide': '지도에서 숨기기',
'tours.filter.status': '상태',
'tours.filter.source': '소스',
'tours.filter.grade': '경로 한도',
'tours.filter.gradeHint':
'계획할 때 경로가 쓸 수 있었던 최고 난이도로, 투어에 저장된 값입니다. 길에서 측정한 난이도가 아닙니다.',
'tours.filter.type': '종류',
'tours.filter.empty': '이 필터에 맞는 투어가 없습니다.',
'tours.filter.reset': '필터 초기화',
};
export default tours;
+8
View File
@@ -263,5 +263,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Alles tonen',
'tours.visibility.show': 'Tonen op de kaart',
'tours.visibility.hide': 'Verbergen op de kaart',
'tours.filter.status': 'Status', // same-as-en
'tours.filter.source': 'Bron',
'tours.filter.grade': 'Routelimiet',
'tours.filter.gradeHint':
'De hoogste moeilijkheid die de route bij het plannen mocht gebruiken, zoals opgeslagen bij de tocht. Niet de gemeten moeilijkheid van het pad.',
'tours.filter.type': 'Soort',
'tours.filter.empty': 'Geen tocht past bij deze filters.',
'tours.filter.reset': 'Filters wissen',
};
export default tours;
+8
View File
@@ -271,5 +271,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Pokaż wszystkie',
'tours.visibility.show': 'Pokaż na mapie',
'tours.visibility.hide': 'Ukryj na mapie',
'tours.filter.status': 'Stan',
'tours.filter.source': 'Źródło',
'tours.filter.grade': 'Limit trasy',
'tours.filter.gradeHint':
'Najwyższa trudność, jaką trasa mogła wykorzystać przy planowaniu, zapisana przy trasie. To nie zmierzona trudność szlaku.',
'tours.filter.type': 'Rodzaj',
'tours.filter.empty': 'Żadna trasa nie pasuje do tych filtrów.',
'tours.filter.reset': 'Wyczyść filtry',
};
export default tours;
+8
View File
@@ -274,5 +274,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Показать все',
'tours.visibility.show': 'Показать на карте',
'tours.visibility.hide': 'Скрыть на карте',
'tours.filter.status': 'Статус',
'tours.filter.source': 'Источник',
'tours.filter.grade': 'Предел маршрута',
'tours.filter.gradeHint':
'Наибольшая сложность, которую маршрут мог использовать при планировании, как она сохранена с походом. Это не измеренная сложность тропы.',
'tours.filter.type': 'Тип',
'tours.filter.empty': 'Ни один поход не подходит под эти фильтры.',
'tours.filter.reset': 'Сбросить фильтры',
};
export default tours;
+8
View File
@@ -259,5 +259,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Zobraziť všetky',
'tours.visibility.show': 'Zobraziť na mape',
'tours.visibility.hide': 'Skryť na mape',
'tours.filter.status': 'Stav',
'tours.filter.source': 'Zdroj',
'tours.filter.grade': 'Limit trasy',
'tours.filter.gradeHint':
'Najvyššia náročnosť, ktorú trasa smela pri plánovaní použiť, ako je uložená pri túre. Nie nameraná náročnosť chodníka.',
'tours.filter.type': 'Druh',
'tours.filter.empty': 'Týmto filtrom nezodpovedá žiadna túra.',
'tours.filter.reset': 'Zrušiť filtre',
};
export default tours;
+8
View File
@@ -256,5 +256,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Visa alla',
'tours.visibility.show': 'Visa på kartan',
'tours.visibility.hide': 'Dölj på kartan',
'tours.filter.status': 'Status', // same-as-en
'tours.filter.source': 'Källa',
'tours.filter.grade': 'Ruttgräns',
'tours.filter.gradeHint':
'Den högsta svårighet som rutten fick använda vid planeringen, sparad med turen. Inte den uppmätta svårigheten på leden.',
'tours.filter.type': 'Typ',
'tours.filter.empty': 'Ingen tur matchar de här filtren.',
'tours.filter.reset': 'Återställ filter',
};
export default tours;
+8
View File
@@ -245,5 +245,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'แสดงทั้งหมด',
'tours.visibility.show': 'แสดงบนแผนที่',
'tours.visibility.hide': 'ซ่อนบนแผนที่',
'tours.filter.status': 'สถานะ',
'tours.filter.source': 'แหล่งข้อมูล',
'tours.filter.grade': 'ขีดจำกัดเส้นทาง',
'tours.filter.gradeHint':
'ความยากสูงสุดที่เส้นทางใช้ได้ตอนวางแผน ตามที่บันทึกไว้กับทัวร์ ไม่ใช่ความยากที่วัดได้บนเส้นทางจริง',
'tours.filter.type': 'ประเภท',
'tours.filter.empty': 'ไม่มีทัวร์ที่ตรงกับตัวกรองเหล่านี้',
'tours.filter.reset': 'รีเซ็ตตัวกรอง',
};
export default tours;
+2 -2
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 231;
const GENERAL_KEY_COUNT = 238;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
@@ -34,7 +34,7 @@ function literalKeys(source: string): string[] {
}
describe('Tours locale contracts', () => {
it('covers all 27 supported locales with 231 general source keys', () => {
it('covers all 27 supported locales with 238 general source keys', () => {
expect(Object.keys(domains)).toHaveLength(27);
expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort());
expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT);
+8
View File
@@ -258,5 +258,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Tümünü göster',
'tours.visibility.show': 'Haritada göster',
'tours.visibility.hide': 'Haritada gizle',
'tours.filter.status': 'Durum',
'tours.filter.source': 'Kaynak',
'tours.filter.grade': 'Rota sınırı',
'tours.filter.gradeHint':
'Rotanın planlanırken kullanabileceği en yüksek zorluk, turla birlikte saklandığı gibi. Parkurda ölçülen zorluk değildir.',
'tours.filter.type': 'Tür',
'tours.filter.empty': 'Bu filtrelere uyan tur yok.',
'tours.filter.reset': 'Filtreleri sıfırla',
};
export default tours;
+8
View File
@@ -271,5 +271,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Показати всі',
'tours.visibility.show': 'Показати на мапі',
'tours.visibility.hide': 'Приховати на мапі',
'tours.filter.status': 'Стан',
'tours.filter.source': 'Джерело',
'tours.filter.grade': 'Межа маршруту',
'tours.filter.gradeHint':
'Найвища складність, яку маршрут міг використати під час планування, як її збережено з походом. Це не виміряна складність стежки.',
'tours.filter.type': 'Тип',
'tours.filter.empty': 'Жоден похід не відповідає цим фільтрам.',
'tours.filter.reset': 'Скинути фільтри',
};
export default tours;
+8
View File
@@ -257,5 +257,13 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': 'Hiện tất cả',
'tours.visibility.show': 'Hiện trên bản đồ',
'tours.visibility.hide': 'Ẩn trên bản đồ',
'tours.filter.status': 'Trạng thái',
'tours.filter.source': 'Nguồn',
'tours.filter.grade': 'Giới hạn lộ trình',
'tours.filter.gradeHint':
'Độ khó cao nhất mà lộ trình được phép dùng khi lên kế hoạch, như đã lưu cùng chuyến đi bộ. Không phải độ khó đo được trên đường mòn.',
'tours.filter.type': 'Loại',
'tours.filter.empty': 'Không có chuyến đi bộ nào khớp với bộ lọc này.',
'tours.filter.reset': 'Đặt lại bộ lọc',
};
export default tours;
+7
View File
@@ -236,5 +236,12 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': '全部顯示',
'tours.visibility.show': '在地圖上顯示',
'tours.visibility.hide': '在地圖上隱藏',
'tours.filter.status': '狀態',
'tours.filter.source': '出處',
'tours.filter.grade': '路線上限',
'tours.filter.gradeHint': '規劃時允許路線使用的最高難度,隨遊程一起儲存。不是在步道上量測到的難度。',
'tours.filter.type': '類型',
'tours.filter.empty': '沒有符合這些篩選條件的遊程。',
'tours.filter.reset': '重設篩選',
};
export default tours;
+7
View File
@@ -237,5 +237,12 @@ const tours: TranslationStrings = {
'tours.visibility.showAll': '全部显示',
'tours.visibility.show': '在地图上显示',
'tours.visibility.hide': '在地图上隐藏',
'tours.filter.status': '状态',
'tours.filter.source': '来源',
'tours.filter.grade': '路线上限',
'tours.filter.gradeHint': '规划时允许路线使用的最高难度,随路线一起保存。不是在步道上测得的难度。',
'tours.filter.type': '类型',
'tours.filter.empty': '没有符合这些筛选条件的路线。',
'tours.filter.reset': '重置筛选',
};
export default tours;