mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Tours: filter a tour list by source, routing limit and type where it varies (PR1-13)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user