From cd1b1410bd10185266ae27ec2013117dd0bb9c59 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 15 Oct 2026 22:02:41 +0200 Subject: [PATCH] Tours: filter a tour list by source, routing limit and type where it varies (PR1-13) --- client/scripts/feature-pairs.json | 7 +- .../components/Tours/TourListFilterMenu.tsx | 190 ++++++++++++++++++ client/src/components/Tours/ToursSidebar.tsx | 80 ++------ .../Tours/toursPermissions.test.tsx | 10 +- .../components/Tours/useTourListFilters.ts | 117 +++++++++++ .../screens/trip/places/MPlacesBrowser.tsx | 12 +- .../trip/places/MToursSelectionList.test.tsx | 24 ++- .../trip/places/MToursSelectionList.tsx | 61 +++++- shared/src/i18n/ar/tours.ts | 8 + shared/src/i18n/az/tours.ts | 8 + shared/src/i18n/br/tours.ts | 8 + shared/src/i18n/ca/tours.ts | 8 + shared/src/i18n/cs/tours.ts | 8 + shared/src/i18n/de/tours.ts | 8 + shared/src/i18n/en/tours.ts | 8 + shared/src/i18n/es/tours.ts | 8 + shared/src/i18n/et/tours.ts | 8 + shared/src/i18n/fr/tours.ts | 8 + shared/src/i18n/gr/tours.ts | 8 + shared/src/i18n/hu/tours.ts | 8 + shared/src/i18n/id/tours.ts | 8 + shared/src/i18n/it/tours.ts | 8 + shared/src/i18n/ja/tours.ts | 8 + shared/src/i18n/ko/tours.ts | 8 + shared/src/i18n/nl/tours.ts | 8 + shared/src/i18n/pl/tours.ts | 8 + shared/src/i18n/ru/tours.ts | 8 + shared/src/i18n/sk/tours.ts | 8 + shared/src/i18n/sv/tours.ts | 8 + shared/src/i18n/th/tours.ts | 8 + shared/src/i18n/tours-locales.spec.ts | 4 +- shared/src/i18n/tr/tours.ts | 8 + shared/src/i18n/uk/tours.ts | 8 + shared/src/i18n/vi/tours.ts | 8 + shared/src/i18n/zh-TW/tours.ts | 7 + shared/src/i18n/zh/tours.ts | 7 + 36 files changed, 632 insertions(+), 87 deletions(-) create mode 100644 client/src/components/Tours/TourListFilterMenu.tsx create mode 100644 client/src/components/Tours/useTourListFilters.ts diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index d0e4480b9..ea88f99ff 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -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"], diff --git a/client/src/components/Tours/TourListFilterMenu.tsx b/client/src/components/Tours/TourListFilterMenu.tsx new file mode 100644 index 000000000..61c1ab45f --- /dev/null +++ b/client/src/components/Tours/TourListFilterMenu.tsx @@ -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; + +/** 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 ( + + ); +} + +function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+
+ + {label} + + {hint && } +
+ {children} +
+ ); +} + +/** + * 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(null); + const buttonRef = useRef(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 ( +
+ + {open && ( +
+ + {(['all', 'unplanned', 'planned'] as const).map((status) => ( + filters.setStatus(status)} + /> + ))} + + {facets.origin.length > 0 && ( + + {facets.origin.map((option) => ( + filters.select('origin', option.value)} + /> + ))} + + )} + {facets.grade.length > 0 && ( + + {facets.grade.map((option) => ( + filters.select('grade', option.value)} + /> + ))} + + )} + {facets.type.length > 0 && ( + + {facets.type.map((option) => ( + filters.select('type', option.value)} + /> + ))} + + )} +
+ )} +
+ ); +} diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx index 60bf9e779..979476763 100644 --- a/client/src/components/Tours/ToursSidebar.tsx +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -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('all') - const filterRef = useRef(null) - const filterButtonRef = useRef(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 (
@@ -110,31 +79,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
-
- - {filterOpen && ( -
- {(['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 - })} -
- )} -
+ {visibility.hiddenCount > 0 && (
@@ -150,12 +95,21 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
- {!loading && filtered.length === 0 ? ( + {!loading && filters.emptyByFilter ? ( +
+

{t('tours.filter.empty')}

+ +
+ ) : !loading && filters.filtered.length === 0 ? ( {t('tours.empty.body')}

} /> ) : (
    - {filtered.map(tour => ( + {filters.filtered.map(tour => ( , 'filters'>) { + const filters = useTourListFilters(props.planner.tours) + return +} 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( key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} filter="all" canEdit={canEdit} canAssign={canAssign} />) + rerender( 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 diff --git a/client/src/components/Tours/useTourListFilters.ts b/client/src/components/Tours/useTourListFilters.ts new file mode 100644 index 000000000..ab3e1ac0a --- /dev/null +++ b/client/src/components/Tours/useTourListFilters.ts @@ -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 { + 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( + tours: readonly TourListItem[], + facet: TourFacet, + order: (a: V, b: V) => number +): TourFacetOption[] { + const counts = new Map(); + 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('all'); + const [selection, setSelection] = useState(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( + base('origin'), + 'origin', + (a, b) => ORIGIN_ORDER.indexOf(a) - ORIGIN_ORDER.indexOf(b) + ); + const grade = optionsOf(base('grade'), 'grade', (a, b) => a - b); + const type = optionsOf(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((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; diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx index 8097f0f20..3eaeb0adf 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -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('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. */}
    - setToursFilter('all')} /> - setToursFilter('unplanned')} /> - setToursFilter('planned')} /> + tourFilters.setStatus('all')} /> + tourFilters.setStatus('unplanned')} /> + tourFilters.setStatus('planned')} />
    - + ) : ( <> diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx index aa25f195f..b02962bef 100644 --- a/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx @@ -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, 'filters'>) { + const filters = useTourListFilters(props.planner.tours) + return +} 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() + const { rerender } = render() 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() + rerender() 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() + render() 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() + const { container } = render() 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() + render() 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() + render() 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() + const { rerender } = render() - rerender() + rerender() expect(reloadTourPlaceIds).not.toHaveBeenCalled() }) diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx index 082cc4994..abb3b6a8a 100644 --- a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx @@ -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 ( + + ) } /** @@ -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 && ( +
    + {facets.origin.map(option => ( + filters.select('origin', option.value)} /> + ))} + {facets.grade.map(option => ( + filters.select('grade', option.value)} /> + ))} + {facets.type.map(option => ( + filters.select('type', option.value)} /> + ))} +
    + ) - const filtered = filterTours(tours, filter) + if (filters.emptyByFilter) { + return ( + <> + {facetRow} +
    +

    {t('tours.filter.empty')}

    + +
    + + ) + } if (filtered.length === 0) { return ( @@ -50,6 +98,7 @@ export default function MToursSelectionList({ planner, shell, filter, canAssign:

    {t('tours.planner.mobileHint')}

    + {facetRow}
      {filtered.map(tour => { const isSelected = planner.selectedPlaceId === tour.place_id diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index ad304cae9..d84b757e0 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -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; diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 239bf0db2..c18cb752c 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -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; diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index fe72fa1e5..577142b44 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -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; diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 35e125ea3..c19ce9055 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -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; diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 12b0665c3..2d568576b 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -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; diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 4400fba7f..acaaacb5b 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -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; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 5848c8dd3..c37b1a6ce 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -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; diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 2abb4f1b5..f3297f281 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -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; diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index cd09f20da..33721842f 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -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; diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index a2039ac14..1f238518d 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -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; diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index d0fd77dd9..41eb950f6 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -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; diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 031f51f9d..1ffc468d8 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -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; diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 2a48aa03c..a874d2d1d 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -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; diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index ff224e0a2..5150f0b95 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -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; diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 0dabd1a94..30a9cfca4 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -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; diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 1bf179232..1bd85aba8 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -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; diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 81fad8c69..617e6de8b 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -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; diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index e9472310f..f6b48cb53 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -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; diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index ec64e1245..973d76ba0 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -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; diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index e98611426..5146c71ce 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -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; diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index ffae5be98..3519406e8 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -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; diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 4091b0bf0..dda64f854 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -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; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index f06f553e0..a0d906af1 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -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); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 228707aa1..9b0956ff0 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -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; diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 688377606..888f5b84c 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -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; diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index dff4cdb45..60e461f95 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index e26e2e0ab..107bc5acf 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -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; diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 605e9a713..4a00d223b 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -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;