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

This commit is contained in:
Maurice
2026-10-15 22:02:41 +02:00
parent 3d9095ccde
commit cd1b1410bd
36 changed files with 632 additions and 87 deletions
+6 -1
View File
@@ -717,12 +717,17 @@
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
@@ -0,0 +1,190 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { InfoDot } from './tourParts';
import type { TourFilter } from './tourPresentation';
import type { TourListFilters } from './useTourListFilters';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** How each origin is named in a list, the words the source badge uses. */
export const ORIGIN_LABEL_KEY = {
trek: 'tours.badge.planned',
gpx: 'tours.badge.gpx',
wanderer: 'tours.badge.wanderer',
} as const;
export function statusLabel(status: TourFilter, t: Translate): string {
if (status === 'unplanned') return t('places.unplanned');
if (status === 'planned') return t('places.planned');
return t('places.all');
}
function MenuRow({
active,
label,
count,
onClick,
}: {
active: boolean;
label: string;
count: number;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{count}
</span>
<span className="grid w-3.5 flex-none place-items-center">
{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}
</span>
</button>
);
}
function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div
role="group"
aria-label={label}
className="flex flex-col gap-px border-t border-edge-faint pt-1.5 first:border-t-0 first:pt-0"
>
<div className="flex items-center gap-1 px-2 pb-0.5">
<span className="font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
{label}
</span>
{hint && <InfoDot label={label} description={hint} />}
</div>
{children}
</div>
);
}
/**
* The filter of a tour list (PR1-13): one button that says what is set, and a
* menu with the day status and, where the list varies in them, the source,
* the stored routing limit and the tour type, each with how many tours it
* keeps. Choosing a set value again clears it.
*/
export default function TourListFilterMenu({ filters }: { filters: TourListFilters }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { facets, selection, statusCounts } = filters;
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
setOpen(false);
buttonRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
return (
<div ref={rootRef} className="relative min-w-0">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}
>
<span className="min-w-0 truncate text-start">{statusLabel(filters.status, t)}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>
{filters.filtered.length}
</span>
{filters.activeFacets > 0 && (
<span
className="rounded-full bg-accent px-1.5 font-geist font-semibold leading-[16px] text-accent-text"
style={fs(10)}
>
+{filters.activeFacets}
</span>
)}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`ms-auto flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
<MenuSection label={t('tours.filter.status')}>
{(['all', 'unplanned', 'planned'] as const).map((status) => (
<MenuRow
key={status}
active={filters.status === status}
label={statusLabel(status, t)}
count={statusCounts[status]}
onClick={() => filters.setStatus(status)}
/>
))}
</MenuSection>
{facets.origin.length > 0 && (
<MenuSection label={t('tours.filter.source')}>
{facets.origin.map((option) => (
<MenuRow
key={option.value}
active={selection.origin === option.value}
label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count}
onClick={() => filters.select('origin', option.value)}
/>
))}
</MenuSection>
)}
{facets.grade.length > 0 && (
<MenuSection label={t('tours.filter.grade')} hint={t('tours.filter.gradeHint')}>
{facets.grade.map((option) => (
<MenuRow
key={option.value}
active={selection.grade === option.value}
label={`T${option.value}`}
count={option.count}
onClick={() => filters.select('grade', option.value)}
/>
))}
</MenuSection>
)}
{facets.type.length > 0 && (
<MenuSection label={t('tours.filter.type')}>
{facets.type.map((option) => (
<MenuRow
key={option.value}
active={selection.type === option.value}
label={t(`tourTypes.${option.value}`)}
count={option.count}
onClick={() => filters.select('type', option.value)}
/>
))}
</MenuSection>
)}
</div>
)}
</div>
);
}
+17 -63
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, EyeOff, FileDown } from 'lucide-react'
import React, { useEffect, useMemo, useRef } from 'react'
import { EyeOff, FileDown } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import type { Day } from '../../types'
import { useTranslation } from '../../i18n'
@@ -9,7 +9,8 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import { filterTours, type TourFilter } from './tourPresentation'
import TourListFilterMenu from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import { useTourGpxImport } from './useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
import { useTourMapVisibility } from './useTourMapVisibility'
@@ -44,10 +45,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
const { t } = useTranslation()
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
const [filter, setFilter] = useState<TourFilter>('all')
const filterRef = useRef<HTMLDivElement>(null)
const filterButtonRef = useRef<HTMLButtonElement>(null)
const [filterOpen, setFilterOpen] = useState(false)
const filters = useTourListFilters(tours)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
@@ -66,35 +64,6 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
row?.scrollIntoView?.({ block: 'nearest' })
}, [selectedPlaceId])
const counts = {
all: tours.length,
unplanned: tours.filter(tr => !tr.planned).length,
planned: tours.filter(tr => tr.planned).length,
}
const filtered = filterTours(tours, filter)
useEffect(() => {
if (!filterOpen) return
const onDown = (event: MouseEvent) => {
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.stopPropagation()
setFilterOpen(false)
filterButtonRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [filterOpen])
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
const filterCount = counts[filter]
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
<div className="flex flex-none flex-col gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: NEUTRAL_TINT }}>
@@ -110,31 +79,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
</button>
</Tooltip>
</div>
<div ref={filterRef} className="relative min-w-0">
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}>
<span className="min-w-0 truncate text-start">{filterLabel}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`ms-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
</button>
{filterOpen && (
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
{(['all', 'unplanned', 'planned'] as const).map(id => {
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
const active = filter === id
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
</button>
})}
</div>
)}
</div>
<TourListFilterMenu filters={filters} />
{visibility.hiddenCount > 0 && (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
@@ -150,12 +95,21 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
</div>
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!loading && filtered.length === 0 ? (
{!loading && filters.emptyByFilter ? (
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset}
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.filter.reset')}
</button>
</div>
) : !loading && filters.filtered.length === 0 ? (
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
) : (
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filtered.map(tour => (
{filters.filtered.map(tour => (
<TourListRow
key={tour.place_id}
tour={tour}
@@ -46,6 +46,14 @@ import type { TourPlannerController } from './planner/useTourPlanner'
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
import TourDetailDialog from './TourDetailDialog'
import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList'
import type { ComponentProps } from 'react'
import { useTourListFilters } from './useTourListFilters'
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
const filters = useTourListFilters(props.planner.tours)
return <MToursSelectionList {...props} filters={filters} />
}
const tour = { place_id: 42, name: 'Ridge', tour_type: 'hike', max_hiking_difficulty: 2, planned: false } as TourListItem
const days = [{ id: 3, title: 'Summit' }] as never
@@ -133,7 +141,7 @@ describe('Tours permissions without database setup', () => {
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
const view = vi.fn()
const openSheet = vi.fn()
rerender(<MToursSelectionList planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} filter="all" canEdit={canEdit} canAssign={canAssign} />)
rerender(<List planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
fireEvent.click(screen.getByRole('option'))
expect(view).toHaveBeenCalledWith(42)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
@@ -0,0 +1,117 @@
import type { TourListItem, TourOrigin, TourTypeKey } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import { filterTours, tourSource, type TourFilter } from './tourPresentation';
export type TourFacet = 'origin' | 'grade' | 'type';
export interface TourFacetOption<V> {
value: V;
/** How many tours of the list carry it, under the other filters as they are set. */
count: number;
}
interface TourFacetSelection {
origin: TourOrigin | null;
grade: number | null;
type: TourTypeKey | null;
}
const NO_SELECTION: TourFacetSelection = { origin: null, grade: null, type: null };
const ORIGIN_ORDER: TourOrigin[] = ['trek', 'gpx', 'wanderer'];
function facetValue(tour: TourListItem, facet: TourFacet): string | number | null {
if (facet === 'origin') return tourSource(tour);
if (facet === 'grade') return tour.max_hiking_difficulty;
return tour.tour_type;
}
function matches(tour: TourListItem, selection: TourFacetSelection, except?: TourFacet): boolean {
return (['origin', 'grade', 'type'] as const).every(
(facet) => facet === except || selection[facet] === null || facetValue(tour, facet) === selection[facet]
);
}
/** The values a facet takes in the list, each with its count; a value no tour carries is not offered. */
function optionsOf<V extends string | number>(
tours: readonly TourListItem[],
facet: TourFacet,
order: (a: V, b: V) => number
): TourFacetOption<V>[] {
const counts = new Map<V, number>();
for (const tour of tours) {
const value = facetValue(tour, facet) as V | null;
if (value !== null) counts.set(value, (counts.get(value) ?? 0) + 1);
}
return [...counts.entries()].sort(([a], [b]) => order(a, b)).map(([value, count]) => ({ value, count }));
}
/**
* PR1-13: the filters of a tour list, the same on the desktop rail and the
* phone list. Next to the day status (all, not yet on a day, on a day) it
* offers the source, the stored routing limit and the tour type, but only a
* dimension the list really varies in (two values or more), so a small list
* keeps its single filter. Filtering only narrows what the list shows; tours
* and their days are not touched.
*/
export function useTourListFilters(tours: readonly TourListItem[]) {
const [status, setStatus] = useState<TourFilter>('all');
const [selection, setSelection] = useState<TourFacetSelection>(NO_SELECTION);
const byStatus = useMemo(() => filterTours([...tours], status), [tours, status]);
const facets = useMemo(() => {
// Each dimension counts under the others as set, so its numbers say what choosing it would show.
const base = (facet: TourFacet) => byStatus.filter((tour) => matches(tour, selection, facet));
const origin = optionsOf<TourOrigin>(
base('origin'),
'origin',
(a, b) => ORIGIN_ORDER.indexOf(a) - ORIGIN_ORDER.indexOf(b)
);
const grade = optionsOf<number>(base('grade'), 'grade', (a, b) => a - b);
const type = optionsOf<TourTypeKey>(base('type'), 'type', (a, b) => a.localeCompare(b));
// A dimension the whole list does not vary in is no choice at all.
const varies = (facet: TourFacet) =>
new Set(tours.map((tour) => facetValue(tour, facet)).filter((v) => v !== null)).size >= 2;
return {
origin: varies('origin') ? origin : [],
grade: varies('grade') ? grade : [],
type: varies('type') ? type : [],
};
}, [byStatus, selection, tours]);
const filtered = useMemo(() => byStatus.filter((tour) => matches(tour, selection)), [byStatus, selection]);
const select = useCallback(<F extends TourFacet>(facet: F, value: TourFacetSelection[F]) => {
setSelection((current) => ({ ...current, [facet]: current[facet] === value ? null : value }));
}, []);
const reset = useCallback(() => {
setStatus('all');
setSelection(NO_SELECTION);
}, []);
const activeFacets = (['origin', 'grade', 'type'] as const).filter((facet) => selection[facet] !== null).length;
return {
status,
setStatus,
selection,
select,
facets,
/** The list has more to choose by than the day status. */
hasFacets: facets.origin.length > 0 || facets.grade.length > 0 || facets.type.length > 0,
filtered,
statusCounts: {
all: tours.length,
unplanned: tours.filter((tour) => !tour.planned).length,
planned: tours.filter((tour) => tour.planned).length,
},
/** How many filters beyond the status narrow the list. */
activeFacets,
/** Something is set and nothing is left: the list says so and offers to clear it. */
emptyByFilter: tours.length > 0 && filtered.length === 0 && (status !== 'all' || activeFacets > 0),
reset,
};
}
export type TourListFilters = ReturnType<typeof useTourListFilters>;
@@ -20,7 +20,7 @@ import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
import MToursSelectionList from './MToursSelectionList'
import type { TourFilter } from '../../../../components/Tours/tourPresentation'
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
@@ -47,7 +47,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
// Top level of the places browser, mirroring the desktop right add-panel's
// Places <-> Tours switch placement.
const [toursMode, setToursMode] = useState(false)
const [toursFilter, setToursFilter] = useState<TourFilter>('all')
const tourFilters = useTourListFilters(planner.tours)
const poolPlaces = useMemo(
// Places that are tours stay out of the Places pool while the addon is on.
@@ -124,11 +124,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
{/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select
buttons: tours are attached here, never created or bulk-managed. */}
<div className="flex items-center gap-[6px]">
<FilterChip active={toursFilter === 'all'} label={t('places.all')} onClick={() => setToursFilter('all')} />
<FilterChip active={toursFilter === 'unplanned'} label={t('places.unplanned')} onClick={() => setToursFilter('unplanned')} />
<FilterChip active={toursFilter === 'planned'} label={t('places.planned')} onClick={() => setToursFilter('planned')} />
<FilterChip active={tourFilters.status === 'all'} label={t('places.all')} onClick={() => tourFilters.setStatus('all')} />
<FilterChip active={tourFilters.status === 'unplanned'} label={t('places.unplanned')} onClick={() => tourFilters.setStatus('unplanned')} />
<FilterChip active={tourFilters.status === 'planned'} label={t('places.planned')} onClick={() => tourFilters.setStatus('planned')} />
</div>
<MToursSelectionList planner={planner} shell={shell} filter={toursFilter} />
<MToursSelectionList planner={planner} shell={shell} filters={tourFilters} />
</>
) : (
<>
@@ -7,6 +7,14 @@ import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
import { useSettingsStore } from '../../../../store/settingsStore'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import MToursSelectionList from './MToursSelectionList'
import type { ComponentProps } from 'react'
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
const filters = useTourListFilters(props.planner.tours)
return <MToursSelectionList {...props} filters={filters} />
}
const tour: TourListItem = {
place_id: 42,
@@ -70,19 +78,19 @@ describe('MToursSelectionList', () => {
const value = de[key] ?? en[key]
return typeof value === 'string' ? value : key
} }
const { rerender } = render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
const { rerender } = render(<List planner={germanPlanner} shell={shell()} />)
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument()
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern')
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument()
rerender(<MToursSelectionList planner={{ ...germanPlanner, tours: [] }} shell={shell()} filter="all" />)
rerender(<List planner={{ ...germanPlanner, tours: [] }} shell={shell()} />)
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument()
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/)
})
it('gives a tour name the full row and up to two lines', () => {
render(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
render(<List planner={planner()} shell={shell()} />)
const name = screen.getByText(tour.name)
expect(name).toHaveClass('line-clamp-2', 'break-words')
@@ -91,7 +99,7 @@ describe('MToursSelectionList', () => {
})
it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => {
const { container } = render(<MToursSelectionList planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} filter="all" canEdit canAssign />)
const { container } = render(<List planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} canEdit canAssign />)
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true')
const metrics = screen.getByTestId('mobile-tour-metrics')
expect(metrics).toHaveTextContent('12.5 km')
@@ -120,7 +128,7 @@ describe('MToursSelectionList', () => {
return typeof value === 'string' ? value : key
},
}
render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
render(<List planner={germanPlanner} shell={shell()} />)
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument()
expect(screen.getByText('GPX-Import')).toBeInTheDocument()
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument()
@@ -129,7 +137,7 @@ describe('MToursSelectionList', () => {
it('shows no T-badge for an imported tour that carries no difficulty', () => {
const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null }
render(<MToursSelectionList planner={{ ...planner(), tours: [imported] }} shell={shell()} filter="all" />)
render(<List planner={{ ...planner(), tours: [imported] }} shell={shell()} />)
const row = screen.getByRole('option', { name: new RegExp(tour.name) })
expect(row).not.toHaveTextContent(/T\d/)
@@ -139,9 +147,9 @@ describe('MToursSelectionList', () => {
})
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
const { rerender } = render(<MToursSelectionList planner={planner()} shell={shell({ id: 'bract' })} filter="all" />)
const { rerender } = render(<List planner={planner()} shell={shell({ id: 'bract' })} />)
rerender(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
rerender(<List planner={planner()} shell={shell()} />)
expect(reloadTourPlaceIds).not.toHaveBeenCalled()
})
@@ -2,15 +2,34 @@ import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucid
import type { TourListItem } from '@trek/shared'
import { useSettingsStore } from '../../../../store/settingsStore'
import { formatDistance, formatElevation } from '../../../../utils/units'
import { filterTours, hikeSourceBadgeLabel, type TourFilter } from '../../../../components/Tours/tourPresentation'
import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation'
import { ORIGIN_LABEL_KEY } from '../../../../components/Tours/TourListFilterMenu'
import type { TourListFilters } from '../../../../components/Tours/useTourListFilters'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
interface MToursSelectionListProps extends TourPermissionProps {
planner: TripPlanner
shell: MTripShellApi
/** All / Unplanned / Planned — same three states as the Places pool. */
filter: TourFilter
/** The list's filters (PR1-13): the day status chips sit above, the other dimensions here. */
filters: TourListFilters
}
/** One value of a dimension as a chip of the row; tapping a chosen one clears it. */
function FacetChip({ active, label, count, onClick }: { active: boolean; label: string; count: number; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[0.75rem] font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
}`}
>
{label}
<span className="font-geist text-[0.6875rem] opacity-60">{count}</span>
</button>
)
}
/**
@@ -22,13 +41,42 @@ interface MToursSelectionListProps extends TourPermissionProps {
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
* Place under the hood, so no new linking code is needed here either.
*/
export default function MToursSelectionList({ planner, shell, filter, canAssign: assignPermission }: MToursSelectionListProps) {
export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) {
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission })
const { t } = planner
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const tours: TourListItem[] = planner.tours
const filtered: TourListItem[] = filters.filtered
const { facets, selection } = filters
const facetRow = filters.hasFacets && (
<div className="-mx-4 mt-2 flex gap-[6px] overflow-x-auto px-4 pb-1">
{facets.origin.map(option => (
<FacetChip key={`o-${option.value}`} active={selection.origin === option.value} label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count} onClick={() => filters.select('origin', option.value)} />
))}
{facets.grade.map(option => (
<FacetChip key={`g-${option.value}`} active={selection.grade === option.value} label={`T${option.value}`}
count={option.count} onClick={() => filters.select('grade', option.value)} />
))}
{facets.type.map(option => (
<FacetChip key={`t-${option.value}`} active={selection.type === option.value} label={t(`tourTypes.${option.value}`)}
count={option.count} onClick={() => filters.select('type', option.value)} />
))}
</div>
)
const filtered = filterTours(tours, filter)
if (filters.emptyByFilter) {
return (
<>
{facetRow}
<div role="status" className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
<p className="font-geist text-[0.78125rem] text-m-muted">{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg">
{t('tours.filter.reset')}
</button>
</div>
</>
)
}
if (filtered.length === 0) {
return (
@@ -50,6 +98,7 @@ export default function MToursSelectionList({ planner, shell, filter, canAssign:
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
{t('tours.planner.mobileHint')}
</p>
{facetRow}
<ul className="mt-3 space-y-2">
{filtered.map(tour => {
const isSelected = planner.selectedPlaceId === tour.place_id