feat(tours): a city walk's arrivals and opening hours for the day it is on, at each stop in the planner and the detail (CW-02, CW-04)

A city walk now shows, at each stop, when the walk gets there, until when
the stay lasts, and what the stop's opening hours say about that visit. The
same lines appear under each stop row in the planner and under each stop in
the walk's detail, in the user's 12 or 24 hour format. A conflict names the
stop, the arrival, the end of the stay and the hours it was checked against,
for instance "Lenbachhaus closes at 18:00, during the stay from 17:20 to
18:20 (open 10:00 to 18:00)", in the warning colour. Nothing is changed by
these hints: no stop, order or route moves.

The times belong to one day the walk is on. Opened from a day of the plan,
the detail keeps that day, and checking stops off stays bound to it.
Opened otherwise, and in the planner, a small pill above the stops names
the day and its start, and the walk's other days can be picked from it; it
starts on the first day with a start time. The pick is only what is looked
at and is never saved, and another day never takes over the times quietly.
Without a day, a date or a start time the line says what is missing and no
arrival is shown. TREK keeps no time zone on a trip, so start, arrivals and
opening hours are all read as the local time of the plan, and an arrival
after midnight is said to be on the next day without being checked.

Opening hours come from the trip place a stop stands for, as the travellers
entered them, and otherwise from OpenStreetMap; a pill at each stop names
the source, and a notice says the hours are a hint to check. A stop without
hours stays unknown and is never shown as open, hours that are not in a
plain form are shown as written, and an arrival that is only a lower bound,
because a stay before it is not entered yet, is not judged unless no later
arrival could escape the conflict. A trip place that a stop stands for also
gives the stop its name before "Stop N".

The arrival at a stop is found where the route passes it in the walk's
order, so the end of a loop and a stop passed on the way out and back get
the right time. The rules live once in the shared package, for the server
to give the same answers. The walk's detail reads its stops again after a
save, its own or a teammate's.
This commit is contained in:
Maurice
2026-10-20 03:22:11 +00:00
parent 717915d540
commit 32dfc75756
48 changed files with 2476 additions and 397 deletions
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { buildPlace } from '../../../tests/helpers/factories'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import { paddleBench } from '../../../tests/helpers/paddleCheck'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
@@ -179,6 +179,36 @@ describe('TourDetailDialog', () => {
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
})
it('CW-04: a walk opened from a day it is on keeps that day for its times; opened otherwise, a day can be picked', async () => {
const walk = { ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 20, station_count: 1 }
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', name: 'Museum', stay_minutes: 30, opening_hours: 'Mo-Fr 10:00-18:00' },
] })
// 2026-10-19 is a Monday: the museum opens at 10:00.
const days = [buildDay({ id: 7, date: '2026-10-19' }), buildDay({ id: 8, date: '2026-10-20' })]
const assignments = {
'7': [buildAssignment({ id: 9, day_id: 7, place_id: 42, assignment_time: '09:00', place })],
'8': [buildAssignment({ id: 10, day_id: 8, place_id: 42, assignment_time: '11:00', place })],
}
const view = render(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} selectedDayId={7} />)
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, start 09:00')
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument()
expect(await screen.findByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument()
expect(screen.getByTestId('walk-station-conflict'))
.toHaveTextContent('Museum is closed at 09:00, the stay would last until 09:30; open 10:00 to 18:00')
expect(screen.getByText('Mark visited')).toBeInTheDocument()
view.rerender(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} />)
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent('Day 1, start 09:00')
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('walk-day-picker'))
fireEvent.click(screen.getByRole('button', { name: /^Day 2, .*start 11:00$/ }))
expect(await screen.findByText('Arrival about 11:00, stay until 11:30')).toBeInTheDocument()
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument()
expect(screen.getByText(/Open 10:00 to 18:00/)).toBeInTheDocument()
})
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
@@ -688,18 +688,11 @@ export default function TourDetailDialog({
<WalkStationsCard
tour={tour}
tripId={place.trip_id}
assignmentId={assignment?.id ?? null}
assignment={assignment}
canMark={canAssign}
day={
assignment
? {
date: days.find((day) => day.id === assignment.day_id)?.date ?? null,
start: assignment.assignment_time ?? place.place_time ?? null,
route: analysis?.routeCoordinates ?? [],
walkingMinutes: tour.duration ?? null,
}
: null
}
days={days}
assignments={assignments}
route={analysis?.routeCoordinates ?? null}
/>
)}
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
@@ -1,12 +1,15 @@
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-008: shaping points, the water router's answers
// and a city walk's stops in the list (P2A-03, P2A-05, PAD-11, CW-01).
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-011: shaping points, the water router's answers
// and a city walk's stops in the list, with their arrivals and opening hours on a day of the walk
// (P2A-03, P2A-05, PAD-11, CW-01, CW-02, CW-04).
import userEvent from '@testing-library/user-event';
import type { PaddleRouteLeg } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTripStore } from '../../../store/tripStore';
import type { PaddleTourRoute } from './paddleRouting';
import TourWaypointList from './TourWaypointList';
import type { TourPlannerController } from './useTourPlanner';
@@ -232,4 +235,85 @@ describe('TourWaypointList', () => {
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
});
describe('a city walk on a day of the plan (CW-02, CW-04)', () => {
const lenbachhaus = {
...START,
key: 'stop-lenbachhaus',
name: 'Lenbachhaus',
stayMinutes: 60,
openingHours: 'Mo-Fr 10:00-18:00',
};
const cafe = { ...STOP, name: 'Cafe', stayMinutes: 30 };
const ROUTE: Array<[number, number]> = [
[49.1, 12.1],
[49.08, 12.11],
[49.05, 12.12],
[49.0, 12.1],
];
/** A walk being edited, its route planned for the points as they are; on Monday 19 October from 17:20. */
function walkPlanner(overrides: Partial<TourPlannerController> = {}) {
return planner({
tourType: 'walk',
editingPlaceId: 42,
waypoints: [lenbachhaus, SHAPE, cafe, END],
route: ROUTE,
routeCurrent: true,
walkingDurationMinutes: 40,
...overrides,
});
}
beforeEach(() => {
const day = buildDay({ id: 7, date: '2026-10-19' });
const entry = buildAssignment({
day_id: 7,
place_id: 42,
assignment_time: '17:20',
place: buildPlace({ id: 42 }),
});
seedStore(useTripStore, { days: [day], assignments: { '7': [entry] } });
});
it('FE-TOUR-WAYPOINT-LIST-009: names the day above the stops and the conflict at the stop it is about', () => {
render(<TourWaypointList planner={walkPlanner()} canEdit isSaving={false} />);
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, start 17:20');
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
const first = within(at(rows, 0)).getByTestId('walk-station-schedule');
expect(within(first).getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
expect(within(first).getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
);
// Beside the row's select button, never inside it.
expect(within(at(rows, 0)).getByRole('button', { current: false, name: /Lenbachhaus/ })).not.toContainElement(
first
);
expect(within(at(rows, 1)).queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
expect(within(at(rows, 2)).getByTestId('walk-station-schedule')).not.toHaveTextContent('closes');
expect(screen.getAllByTestId('walk-station-conflict')).toHaveLength(1);
});
it('FE-TOUR-WAYPOINT-LIST-010: shows no arrival while the route is not planned for the points as they are', () => {
render(<TourWaypointList planner={walkPlanner({ routeCurrent: false })} canEdit isSaving={false} />);
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, start 17:20');
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-011: gives the way back of a loop no arrival of its own, and a hike no day line', () => {
const back = { ...START, id: 'back', role: 'end' as const };
const loop = walkPlanner({ isLoop: true, waypoints: [lenbachhaus, STOP, back], route: ROUTE.slice(0, 3) });
const { unmount } = render(<TourWaypointList planner={loop} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(within(at(rows, 0)).getByTestId('walk-station-schedule')).toBeInTheDocument();
expect(within(at(rows, 1)).getByTestId('walk-station-schedule')).toBeInTheDocument();
expect(within(at(rows, 2)).queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
unmount();
render(<TourWaypointList planner={walkPlanner({ tourType: 'hike' })} canEdit isSaving={false} />);
expect(screen.queryByTestId('walk-day-line')).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
});
});
});
@@ -37,6 +37,9 @@ import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageRouting } from '../paddle/usePortageRouting';
import { RailCard } from '../tourParts';
import { StationScheduleRow } from '../walk/StationSchedule';
import { usePlannerWalkSchedule } from '../walk/useWalkSchedule';
import { WalkDayLine } from '../walk/WalkDayLine';
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
import { StationDialog } from './StationDialog';
import { StationPills } from './stationParts';
@@ -584,6 +587,8 @@ export default function TourWaypointList({
}) {
const { t } = useTranslation();
const tripPlaces = useTripPlaceWaypoints(planner, tourPlaceIds);
// CW-02, CW-04: the arrivals and opening hours of a city walk on one of its days; hints only, nothing saved.
const walk = usePlannerWalkSchedule(planner, tripPlaces.linkedPlaceOf);
const [placesOpen, setPlacesOpen] = useState(false);
const addRows = canEdit && (
<>
@@ -654,6 +659,7 @@ export default function TourWaypointList({
return card(
<>
<WalkDayLine {...walk} className="mb-2" />
<ol data-touch-drag className="m-0 -mx-1.5 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
@@ -661,6 +667,7 @@ export default function TourWaypointList({
const loopEnd = planner.isLoop && index === lastIndex;
const linkedPlace = tripPlaces.linkedPlaceOf(point);
const placeName = linkedPlace?.name ?? placeNames[point.id];
const stop = stopNumbers.get(point.id) ?? index + 1;
return (
<Fragment key={point.id}>
<li
@@ -717,7 +724,7 @@ export default function TourWaypointList({
<GripVertical size={14} strokeWidth={1.8} />
</span>
<WaypointNumber
stop={stopNumbers.get(point.id) ?? index + 1}
stop={stop}
selected={selected}
ferrata={ferrataEnds[point.id] ?? null}
loopEnd={loopEnd}
@@ -751,6 +758,7 @@ export default function TourWaypointList({
/>
)}
</div>
<StationScheduleRow schedule={walk.schedule} point={point} placeName={placeName} number={stop} />
</li>
<WaypointLegRows
planner={planner}
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
import { useTripStore } from '../../../store/tripStore';
import type { Category, Place } from '../../../types';
import { randomId } from '../../../utils/randomId';
import { useLinkedPlaceOf } from '../walk/useWalkSchedule';
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
import { withPointAppended } from './tourRouteActions';
import type { TourPlannerController } from './useTourPlanner';
@@ -49,7 +50,7 @@ export function useTripPlaceWaypoints(planner: TourPlannerController, tourPlaceI
const categories = useTripStore((state) => state.categories) ?? NO_CATEGORIES;
const { editWaypoints, setSelectedWaypointId, waypoints, route } = planner;
const placesById = useMemo(() => new Map(places.map((place) => [place.id, place])), [places]);
const linkedPlaceOf = useLinkedPlaceOf();
const candidates = useMemo((): TripPlaceCandidate[] => {
const line = tourLine({ route, waypoints });
@@ -87,13 +88,6 @@ export function useTripPlaceWaypoints(planner: TourPlannerController, tourPlaceI
[editWaypoints]
);
/** The place a point stands for while the trip still has it. */
const linkedPlaceOf = useCallback(
(point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) =>
point.linkedPlaceId ? (placesById.get(point.linkedPlaceId) ?? null) : null,
[placesById]
);
return { candidates, add, unlink, linkedPlaceOf };
}
@@ -0,0 +1,136 @@
// CW-04: a station's arrival, end of stay and opening hours as the planner and
// the walk's detail both show them.
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { StationScheduleLine, StationScheduleRow } from './StationSchedule';
import type { StationSchedule, WalkSchedule } from './walkSchedule';
const at = (overrides: Partial<StationSchedule>): StationSchedule => ({
arrival: 1040,
atLeast: false,
leave: 1100,
fit: { kind: 'unknown' },
...overrides,
});
const line = (schedule: StationSchedule | undefined) =>
render(<StationScheduleLine schedule={schedule} name="Lenbachhaus" />);
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
});
describe('StationScheduleLine', () => {
it('names station, arrival, end of stay and window for the card case: 17:20 to 18:20 against a closing at 18:00', () => {
line(at({ fit: { kind: 'closes', source: 'osm', window: [600, 1080] } }));
expect(screen.getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
);
expect(screen.getByTestId('walk-hours-source')).toHaveTextContent('OpenStreetMap');
expect(screen.getByText('Found with the stop on OpenStreetMap; it may be out of date.')).toBeInTheDocument();
});
it('follows the 12 hour format, a window to midnight included', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
line(at({ leave: 1130, fit: { kind: 'closes', source: 'place', window: [600, 1080] } }));
expect(screen.getByText('Arrival about 5:20 PM, stay until 6:50 PM')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 6:00 PM, during the stay from 5:20 PM to 6:50 PM (open 10:00 AM to 6:00 PM)'
);
expect(screen.getByTestId('walk-hours-source')).toHaveTextContent('Trip place');
});
it('shows the window an open station is checked against; one to midnight reads 24:00', () => {
line(at({ fit: { kind: 'open', source: 'place', window: [600, 1440] } }));
expect(screen.getByText(/Open 10:00 to 24:00/)).toBeInTheDocument();
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
});
it('names a station closed at its arrival with the windows of the day, with and without a stay', () => {
const fit = {
kind: 'closed',
source: 'osm',
windows: [
[600, 720],
[840, 1080],
],
} as const;
const { unmount } = line(at({ arrival: 780, leave: 810, fit }));
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus is closed at 13:00, the stay would last until 13:30; open 10:00 to 12:00, 14:00 to 18:00'
);
unmount();
line(at({ arrival: 780, leave: null, fit }));
expect(screen.getByText('Arrival about 13:00')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus is closed at 13:00; open 10:00 to 12:00, 14:00 to 18:00'
);
});
it('names a station closed all day, and a point only passed by its arrival alone', () => {
line(at({ leave: 1040, fit: { kind: 'closedAllDay', source: 'osm' } }));
expect(screen.getByText('Arrival about 17:20')).toBeInTheDocument();
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent('Lenbachhaus is closed all day on this date');
});
it('for an arrival at the earliest, says the hours are not checked yet', () => {
line(at({ atLeast: true, fit: { kind: 'uncertain', source: 'osm' } }));
expect(screen.getByText('Arrival 17:20 at the earliest, stay until 18:20 or later')).toBeInTheDocument();
expect(
screen.getByText(/Opening hours not checked: a stay before this stop is not entered yet\./)
).toBeInTheDocument();
const { container } = line(at({ atLeast: true, leave: null }));
expect(container).toHaveTextContent('Arrival 17:20 at the earliest');
});
it('gives hours it cannot read back as written, and says nothing for hours not known', () => {
const { unmount } = line(at({ fit: { kind: 'unread', text: 'PH off' } }));
expect(screen.getByText('Opening hours not read here: PH off')).toBeInTheDocument();
unmount();
line(at({ fit: { kind: 'unknown' } }));
expect(screen.getByTestId('walk-station-hours')).toHaveTextContent('Arrival about 17:20, stay until 18:20');
expect(screen.queryByText(/Open/)).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-hours-source')).not.toBeInTheDocument();
});
it('says an arrival after midnight is on the next day and is not checked', () => {
line(at({ arrival: 1470, leave: 1500, fit: { kind: 'nextDay' } }));
expect(
screen.getByText('Arrival about 00:30 the next day; opening hours are not checked across midnight.')
).toBeInTheDocument();
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
});
it('shows nothing without a schedule', () => {
const { container } = line(undefined);
expect(container).toBeEmptyDOMElement();
});
});
describe('StationScheduleRow', () => {
const schedule: WalkSchedule = {
state: 'ready',
anyHours: true,
byStation: new Map([['a', at({ fit: { kind: 'closedAllDay', source: 'osm' } })]]),
};
it("names the station by its own name, else the place's, else its number", () => {
const { rerender } = render(
<StationScheduleRow schedule={schedule} point={{ id: 'a', name: 'Museum' }} placeName="Square" number={2} />
);
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Museum is closed all day');
rerender(<StationScheduleRow schedule={schedule} point={{ id: 'a' }} placeName="Square" number={2} />);
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Square is closed all day');
rerender(<StationScheduleRow schedule={schedule} point={{ id: 'a' }} placeName={undefined} number={2} />);
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Stop 2 is closed all day');
});
it('shows nothing for a point the schedule has no station for', () => {
render(<StationScheduleRow schedule={schedule} point={{ id: 'b' }} placeName={undefined} number={3} />);
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,177 @@
import { clockOf, type HoursSource, type HoursWindow, type StationFit } from '@trek/shared';
import { AlertTriangle, Clock } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatClockTime } from '../../../utils/formatters';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { StationSchedule, WalkSchedule } from './walkSchedule';
type Translate = ReturnType<typeof useTranslation>['t'];
/** Where a station's hours come from, named on the pill, with how far to trust them behind it. */
const SOURCES: Record<HoursSource, { labelKey: string; hintKey: string }> = {
osm: { labelKey: 'tours.walk.hours.sourceOsm', hintKey: 'tours.walk.hours.sourceOsmHint' },
place: { labelKey: 'tours.walk.hours.sourcePlace', hintKey: 'tours.walk.hours.sourcePlaceHint' },
};
interface ClockFormat {
/** A time of the day in the user's 12 or 24 hours. */
at: (minutes: number) => string;
/** The end of a window: one running to midnight reads 24:00 in 24 hours. */
end: (minutes: number) => string;
}
function useClock(): ClockFormat {
const is12h = useSettingsStore((state) => state.settings.time_format) === '12h';
const at = (minutes: number) => formatClockTime(clockOf(minutes), is12h);
return { at, end: (minutes) => (minutes >= 24 * 60 && !is12h ? '24:00' : at(minutes)) };
}
function windowsText(windows: readonly HoursWindow[], clock: ClockFormat, t: Translate): string {
return windows
.map(([from, to]) => t('tours.walk.hours.window', { from: clock.at(from), to: clock.end(to) }))
.join(', ');
}
/** The arrival, and the end of the stay where it lasts; the next day said as such. */
function arrivalText(schedule: StationSchedule, clock: ClockFormat, t: Translate): string {
const arrival = clock.at(schedule.arrival);
if (schedule.fit.kind === 'nextDay') return t('tours.walk.arrivalNextDay', { time: arrival });
const leave = schedule.leave ?? schedule.arrival;
if (leave > schedule.arrival) {
return t(schedule.atLeast ? 'tours.walk.arrivalStayAtLeast' : 'tours.walk.arrivalStay', {
arrival,
leave: clock.at(leave),
});
}
return t(schedule.atLeast ? 'tours.walk.arrivalAtLeast' : 'tours.walk.arrival', { time: arrival });
}
type Conflict = Extract<StationFit, { kind: 'closes' | 'closed' | 'closedAllDay' }>;
/** A conflict in one sentence: the station, the arrival, the end of the stay and the window it was checked against. */
function conflictText(
fit: Conflict,
schedule: StationSchedule,
name: string,
clock: ClockFormat,
t: Translate
): string {
const arrival = clock.at(schedule.arrival);
const leave = schedule.leave ?? schedule.arrival;
if (fit.kind === 'closedAllDay') return t('tours.walk.hours.closedAllDay', { name });
if (fit.kind === 'closes') {
return t('tours.walk.hours.closes', {
name,
closes: clock.end(fit.window[1]),
arrival,
leave: clock.at(leave),
window: windowsText([fit.window], clock, t),
});
}
const open = windowsText(fit.windows, clock, t);
return leave > schedule.arrival
? t('tours.walk.hours.closed', { name, arrival, leave: clock.at(leave), window: open })
: t('tours.walk.hours.closedNoStay', { name, arrival, window: open });
}
function SourcePill({ source }: { source: HoursSource }) {
const { t } = useTranslation();
const { labelKey, hintKey } = SOURCES[source];
return (
<Tooltip label={t(hintKey)}>
<span className="inline-flex" data-testid="walk-hours-source">
<SoftPill>{t(labelKey)}</SoftPill>
<span className="sr-only">{t(hintKey)}</span>
</span>
</Tooltip>
);
}
/** What the hours say: a conflict in the warning tone, otherwise a quiet line; nothing where none are known. */
function FitLine({ schedule, name, clock }: { schedule: StationSchedule; name: string; clock: ClockFormat }) {
const { t } = useTranslation();
const { fit } = schedule;
if (fit.kind === 'unknown' || fit.kind === 'nextDay') return null;
if (fit.kind === 'unread') {
return (
<span className="break-words text-content-faint" style={fs(10.5, 'body')}>
{t('tours.walk.hours.unread', { text: fit.text })}
</span>
);
}
if (fit.kind === 'open' || fit.kind === 'uncertain') {
return (
<span className="inline-flex flex-wrap items-center gap-1 text-content-faint" style={fs(10.5, 'body')}>
{fit.kind === 'open'
? t('tours.walk.hours.open', { window: windowsText([fit.window], clock, t) })
: t('tours.walk.hours.uncertain')}
<SourcePill source={fit.source} />
</span>
);
}
return (
<span
className="inline-flex flex-wrap items-start gap-1 text-warning"
style={fs(11, 'body')}
data-testid="walk-station-conflict"
>
<AlertTriangle size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
<span className="min-w-0 flex-1 break-words">{conflictText(fit, schedule, name, clock, t)}</span>
<SourcePill source={fit.source} />
</span>
);
}
/**
* CW-04: a station on the day looked at: its expected arrival, the end of its
* stay, and what its opening hours say, with where they come from. The same
* lines in the planner and in the walk's detail; nothing is changed by them.
*/
export function StationScheduleLine({ schedule, name }: { schedule: StationSchedule | undefined; name: string }) {
const { t } = useTranslation();
const clock = useClock();
if (!schedule) return null;
return (
<span className="flex flex-col gap-0.5" data-testid="walk-station-hours">
<span className="inline-flex items-start gap-1 text-content-muted" style={fs(11, 'body')}>
<Clock size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
<span className="min-w-0 break-words">{arrivalText(schedule, clock, t)}</span>
</span>
<FitLine schedule={schedule} name={name} clock={clock} />
</span>
);
}
/**
* The station's lines under its row in the planner, beside its select button
* and aligned with its name; nothing for a point that is no station.
*/
export function StationScheduleRow({
schedule,
point,
placeName,
number,
}: {
schedule: WalkSchedule;
point: Pick<TourPlannerWaypoint, 'id' | 'name'>;
/** The name the row shows for a point without its own: the trip place it stands for, else the place found there. */
placeName: string | undefined;
/** The stop's number, for a station without any name. */
number: number;
}) {
const { t } = useTranslation();
const at = schedule.byStation.get(point.id);
if (!at) return null;
return (
<div className="pb-1 pe-1.5 ps-[66px]" data-testid="walk-station-schedule">
<StationScheduleLine
schedule={at}
name={point.name ?? placeName ?? t('tours.walk.stationNumbered', { number })}
/>
</div>
);
}
@@ -0,0 +1,111 @@
// CW-02, CW-04: the day a city walk's times are for, picked where there is a
// choice, what is missing for them and where the hours come from.
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDay } from '../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { resetAllStores } from '../../../../tests/helpers/store';
import type { WalkDayChoice, WalkDayOption } from './useWalkDay';
import { WalkDayLine } from './WalkDayLine';
import type { WalkSchedule, WalkScheduleState } from './walkSchedule';
const monday: WalkDayOption = {
day: buildDay({ id: 1, date: '2026-10-19', title: 'Old town' }),
index: 0,
start: null,
};
const tuesday: WalkDayOption = { day: buildDay({ id: 2, date: '2026-10-20' }), index: 1, start: '09:30' };
const schedule = (state: WalkScheduleState, anyHours = false): WalkSchedule => ({
state,
anyHours,
byStation: new Map(),
});
const choice = (overrides: Partial<WalkDayChoice> = {}): WalkDayChoice => ({
options: [monday, tuesday],
day: tuesday,
fixed: false,
choose: vi.fn(),
...overrides,
});
const NO_DAY = 'Put the walk on a day with a start time to see arrivals and opening hours.';
beforeEach(() => resetAllStores());
describe('WalkDayLine', () => {
it("lets another of the walk's days be picked from a menu, the one shown marked", () => {
function Picker() {
const [day, setDay] = useState<WalkDayOption>(tuesday);
const pick = (id: number) => setDay(id === 1 ? monday : tuesday);
return <WalkDayLine choice={choice({ day, choose: pick })} schedule={schedule('ready')} stationCount={3} />;
}
render(<Picker />);
expect(screen.getByText('Times for')).toBeInTheDocument();
const picker = screen.getByTestId('walk-day-picker');
expect(picker).toHaveTextContent('Day 2, start 09:30');
expect(picker).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(picker);
expect(picker).toHaveAttribute('aria-expanded', 'true');
const items = [...screen.getByRole('menu').querySelectorAll('button')];
expect(items.map((item) => item.textContent)).toEqual([
expect.stringMatching(/^Day 1, .*: Old town, no start time$/),
expect.stringMatching(/^Day 2, .*, start 09:30$/),
]);
expect(items.map((item) => !!item.querySelector('svg'))).toEqual([false, true]);
fireEvent.click(screen.getByRole('button', { name: /^Day 1, / }));
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent('Day 1, no start time');
});
it('closes the menu again from its pill', () => {
render(<WalkDayLine choice={choice()} schedule={schedule('ready')} stationCount={3} />);
const picker = screen.getByTestId('walk-day-picker');
fireEvent.click(picker);
expect(screen.getByRole('menu')).toBeInTheDocument();
fireEvent.click(picker);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
it('names the day alone where it is fixed or the only one', () => {
const { rerender } = render(
<WalkDayLine choice={choice({ fixed: true })} schedule={schedule('ready')} stationCount={3} />
);
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 2, start 09:30');
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument();
rerender(
<WalkDayLine
choice={choice({ options: [monday], day: monday })}
schedule={schedule('noStart')}
stationCount={3}
/>
);
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, no start time');
expect(screen.getByText('Give the walk a time on this day to see arrivals and opening hours.')).toBeInTheDocument();
});
it('says what is missing: a day once there are stops, a date', () => {
const none = choice({ options: [], day: null });
const { rerender, container } = render(<WalkDayLine choice={none} schedule={schedule('noDay')} stationCount={2} />);
expect(screen.getByText(NO_DAY)).toBeInTheDocument();
rerender(<WalkDayLine choice={none} schedule={schedule('noDay')} stationCount={1} />);
expect(container).toBeEmptyDOMElement();
// Opened from a day the walk has left: that day is not replaced, and nothing is asked.
rerender(<WalkDayLine choice={{ ...none, fixed: true }} schedule={schedule('noDay')} stationCount={3} />);
expect(container).toBeEmptyDOMElement();
rerender(<WalkDayLine choice={choice()} schedule={schedule('noDate')} stationCount={3} />);
expect(screen.getByText('This day has no date, so arrivals and opening hours are left out.')).toBeInTheDocument();
});
it('names where the hours come from once there are any to go by, and nothing for another tour', () => {
const { rerender, container } = render(
<WalkDayLine choice={choice()} schedule={schedule('ready', true)} stationCount={3} />
);
expect(screen.getByText(/Opening hours from OpenStreetMap or your own place entries/)).toBeInTheDocument();
rerender(<WalkDayLine choice={choice()} schedule={schedule('noRoute', true)} stationCount={3} />);
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
rerender(<WalkDayLine enabled={false} choice={choice()} schedule={schedule('ready', true)} stationCount={3} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,120 @@
import { Check, ChevronDown, Clock } from 'lucide-react';
import type { MouseEvent } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatClockTime } from '../../../utils/formatters';
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell';
import { dayMenuLabel } from '../tourDays';
import { TourNotice } from '../tourParts';
import type { WalkDayChoice, WalkDayOption } from './useWalkDay';
import type { WalkSchedule, WalkScheduleState } from './walkSchedule';
type Translate = ReturnType<typeof useTranslation>['t'];
const PILL =
'inline-flex min-w-0 items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-start font-medium text-content-secondary shadow-sm';
/** "Day 2, start 09:00", or that it has no start time; `day` names the day as the place needs it. */
function optionLabel(option: WalkDayOption, day: string, t: Translate, is12h: boolean): string {
return option.start
? t('tours.walk.day.start', { day, time: formatClockTime(option.start, is12h) })
: t('tours.walk.day.noStart', { day });
}
/** What keeps the arrivals from being worked out, said once; a route still being planned says so elsewhere. */
function missingNote(state: WalkScheduleState, choice: WalkDayChoice, stationCount: number, t: Translate) {
if (state === 'noDay') return !choice.fixed && stationCount >= 2 ? t('tours.walk.hours.noDay') : null;
if (state === 'noDate') return t('tours.walk.hours.noDate');
if (state === 'noStart') return t('tours.walk.hours.noStart');
return null;
}
/** The day as a pill: a menu of the walk's days where there is a choice, its name alone where there is none. */
function DayPill({ choice, day }: { choice: WalkDayChoice; day: WalkDayOption }) {
const { t, locale } = useTranslation();
const is12h = useSettingsStore((state) => state.settings.time_format) === '12h';
const menu = useContextMenu();
const label = optionLabel(day, t('dayplan.dayN', { n: day.index + 1 }), t, is12h);
if (choice.fixed || choice.options.length < 2) {
return (
<span data-testid="walk-day" className={`${PILL} break-words`} style={fs(11, 'body')}>
{label}
</span>
);
}
const items = choice.options.map((option) => ({
label: optionLabel(option, dayMenuLabel(option.day, option.index, t, locale), t, is12h),
icon: option === day ? Check : undefined,
onClick: () => choice.choose(option.day.id),
}));
const toggle = (event: MouseEvent<HTMLButtonElement>) => {
if (menu.menu) {
event.stopPropagation();
menu.close();
} else menu.open(event, items, true);
};
return (
<>
<button
type="button"
data-testid="walk-day-picker"
aria-haspopup="menu"
aria-expanded={!!menu.menu}
onClick={toggle}
className={`${PILL} transition-colors hover:text-content`}
style={fs(11, 'body')}
>
<span className="min-w-0 break-words">{label}</span>
<ChevronDown size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
</button>
<ContextMenu menu={menu.menu} onClose={menu.close} />
</>
);
}
/**
* CW-02, CW-04: the day a city walk's arrivals and opening hours are worked
* out for, above its stations, in the planner and in the walk's detail alike;
* what is missing for them; and, once there are hours to go by, where they
* come from and that they are a hint to check.
*/
export function WalkDayLine({
enabled = true,
choice,
schedule,
stationCount,
className = '',
}: {
/** Only a city walk has the line. */
enabled?: boolean;
choice: WalkDayChoice;
schedule: WalkSchedule;
stationCount: number;
className?: string;
}) {
const { t } = useTranslation();
if (!enabled) return null;
const note = missingNote(schedule.state, choice, stationCount, t);
if (!choice.day && !note) return null;
return (
<div data-testid="walk-day-line" className={`flex flex-col gap-1.5 ${className}`}>
{choice.day && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.walk.day.label')}
</span>
<DayPill choice={choice} day={choice.day} />
</div>
)}
{note && (
<p className="m-0 text-content-faint" style={fs(11, 'body')}>
{note}
</p>
)}
{schedule.state === 'ready' && schedule.anyHours && (
<TourNotice icon={<Clock size={13} strokeWidth={2} />}>{t('tours.walk.hours.source')}</TourNotice>
)}
</div>
);
}
@@ -1,13 +1,19 @@
// CW-03, CW-04: a city walk's stops in its detail, checked off for the day it
// was opened from, and its progress as a pill of the desktop day row.
// CW-02, CW-03, CW-04: a city walk's stops in its detail, checked off for the
// day it was opened from, their arrivals and opening hours on the day looked
// at, and its progress as a pill of the desktop day row.
import type { TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { tourRepo } from '../../../repo/tourRepo';
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { useTripStore } from '../../../store/tripStore';
import type { Assignment, Day } from '../../../types';
import { ToastContainer } from '../../shared/Toast';
import { WalkProgressPill, WalkStationsCard, type WalkDay } from './WalkStations';
import { WalkProgressPill, WalkStationsCard } from './WalkStations';
const walk: TourListItem = {
place_id: 42,
@@ -44,16 +50,32 @@ const waypoints: TourWaypoint[] = [
{ lat: 48.002, lng: 11, role: 'end', sequence: 3, name: 'Cafe', stay_minutes: 15, opening_hours: 'Mo 10:00-18:00' },
];
const day: WalkDay = {
date: '2026-10-19',
start: '09:00',
route: [
[48, 11],
[48.001, 11],
[48.002, 11],
],
walkingMinutes: 20,
};
const ROUTE: Array<[number, number]> = [
[48, 11],
[48.001, 11],
[48.002, 11],
];
// 2026-10-19 is a Monday, 2026-10-20 a Tuesday.
const MONDAY = buildDay({ id: 7, trip_id: 4, date: '2026-10-19' });
const TUESDAY = buildDay({ id: 8, trip_id: 4, date: '2026-10-20' });
const ON_DAY = { id: 9, day_id: 7 };
/** The walk's entry on a day: its start that day, else the place's own time. */
function entry(dayId: number, time: string | null, placeTime: string | null = null): Assignment {
return buildAssignment({
id: dayId + 2,
day_id: dayId,
place_id: 42,
assignment_time: time,
place: buildPlace({ id: 42, place_time: placeTime }),
});
}
/** The walk on Monday from 09:00, opened from that day. */
function onMonday(time: string | null = '09:00', day: Day = MONDAY) {
return { assignment: ON_DAY, days: [day], assignments: { [String(day.id)]: [entry(day.id, time)] }, route: ROUTE };
}
function detail(points: TourWaypoint[] = waypoints): Promise<TourDetailResponse> {
return Promise.resolve({ tour: walk, waypoints: points });
@@ -63,7 +85,7 @@ function card(props: Partial<Parameters<typeof WalkStationsCard>[0]> = {}) {
return render(
<>
<ToastContainer />
<WalkStationsCard tour={walk} tripId={4} assignmentId={9} canMark {...props} />
<WalkStationsCard tour={walk} tripId={4} assignment={ON_DAY} canMark {...props} />
</>
);
}
@@ -74,6 +96,7 @@ const rowOf = async (name: string) =>
(await screen.findAllByTestId('walk-station')).find((row) => row.textContent?.includes(name)) as HTMLElement;
beforeEach(() => {
resetAllStores();
useTourVisitStore.setState({ tripId: '4', byAssignment: {} });
vi.spyOn(tourRepo, 'detail').mockImplementation(() => detail());
});
@@ -82,7 +105,8 @@ afterEach(() => vi.restoreAllMocks());
describe('WalkStationsCard', () => {
it('shows nothing for a tour that is no city walk', () => {
render(<WalkStationsCard tour={{ ...walk, tour_type: 'hike' }} tripId={4} assignmentId={9} canMark />);
render(<WalkStationsCard tour={{ ...walk, tour_type: 'hike' }} tripId={4} assignment={ON_DAY} canMark />);
expect(tourRepo.detail).not.toHaveBeenCalled();
expect(screen.queryByTestId('walk-stations')).not.toBeInTheDocument();
});
@@ -100,7 +124,7 @@ describe('WalkStationsCard', () => {
});
it('opened without a day, offers nothing to check off and says where to', async () => {
card({ assignmentId: null });
card({ assignment: null });
await screen.findAllByTestId('walk-station');
expect(
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
@@ -153,7 +177,7 @@ describe('WalkStationsCard', () => {
});
it('on a day put on the plan offline, says the stops wait for the sync and offers no buttons', async () => {
card({ assignmentId: -1700000000000 });
card({ assignment: { id: -1700000000000, day_id: 7 } });
await screen.findAllByTestId('walk-station');
expect(
screen.getByText('This walk was put on the day while offline. Its stops can be checked off once it has synced.')
@@ -180,37 +204,141 @@ describe('WalkStationsCard', () => {
expect(await screen.findByText('1 of 3 visited')).toBeInTheDocument();
});
it('on a day with a start, shows each arrival and what the opening hours say about it', async () => {
card({ day });
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00')).toBeInTheDocument();
expect(within(await rowOf('Museum')).getByTestId('walk-station-conflict')).toHaveTextContent(
'Arrival 09:00, stay until 09:30, but it closes at 09:20'
it('on the day it was opened from, shows each arrival, the end of each stay and what the opening hours say', async () => {
card(onMonday());
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, start 09:00');
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument();
const museum = await rowOf('Museum');
expect(within(museum).getByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument();
expect(within(museum).getByTestId('walk-station-conflict')).toHaveTextContent(
'Museum closes at 09:20, during the stay from 09:00 to 09:30 (open 09:00 to 09:20)'
);
expect(within(await rowOf('Stop 2')).getByText('Arrival about 09:40')).toBeInTheDocument();
expect(within(await rowOf('Stop 2')).getByText('Opening hours not read here: sunrise-sunset')).toBeInTheDocument();
expect(within(await rowOf('Cafe')).getByText('Arrival 09:50 at the earliest')).toBeInTheDocument();
expect(within(await rowOf('Cafe')).getByTestId('walk-station-conflict')).toHaveTextContent(
'Closed at 09:50 by its opening hours'
);
expect(screen.getByText(/Opening hours as OpenStreetMap has them/)).toBeInTheDocument();
expect(within(museum).getByTestId('walk-hours-source')).toHaveTextContent('OpenStreetMap');
const unnamed = await rowOf('Stop 2');
expect(within(unnamed).getByText('Arrival about 09:40')).toBeInTheDocument();
expect(within(unnamed).getByText('Opening hours not read here: sunrise-sunset')).toBeInTheDocument();
// The stay at stop 2 is not known, so the cafe's arrival is a lower bound and its hours are not judged.
const cafe = await rowOf('Cafe');
expect(within(cafe).getByText('Arrival 09:50 at the earliest, stay until 10:05 or later')).toBeInTheDocument();
expect(
within(cafe).getByText('Opening hours not checked: a stay before this stop is not entered yet.')
).toBeInTheDocument();
expect(within(cafe).queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
expect(screen.getByText(/Opening hours from OpenStreetMap or your own place entries/)).toBeInTheDocument();
});
it('on a day without a start, asks for one and leaves the hours unsaid', async () => {
card({ day: { ...day, start: null } });
it("names a stop by the trip place it stands for and checks the place's own hours: 17:20 to 18:20 against 18:00", async () => {
const week = JSON.stringify(
Array.from({ length: 7 }, (_, day) =>
day < 5 ? { closed: false, open: '10:00', close: '18:00' } : { closed: true }
)
);
seedStore(useTripStore, { places: [buildPlace({ id: 77, name: 'Lenbachhaus', opening_hours: week })] });
vi.mocked(tourRepo.detail).mockImplementation(() =>
detail([
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', linked_place_id: 77, stay_minutes: 60 },
{ lat: 48.002, lng: 11, role: 'end', sequence: 1, key: 'stop-02', name: 'Cafe', stay_minutes: 0 },
])
);
card(onMonday('17:20'));
const museum = await rowOf('Lenbachhaus');
expect(within(museum).getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
expect(within(museum).getByTestId('walk-station-conflict')).toHaveTextContent(
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
);
expect(within(museum).getByTestId('walk-hours-source')).toHaveTextContent('Trip place');
expect(within(museum).getByText('Entered for this place of the trip.')).toBeInTheDocument();
// Only passed, a stop without hours stays unknown: no window and nothing said to be open.
const cafe = await rowOf('Cafe');
expect(within(cafe).getByText('Arrival about 18:40')).toBeInTheDocument();
expect(within(cafe).queryByText(/Open/)).not.toBeInTheDocument();
});
it('shows the times in the 12 hour format where the user chose it', async () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
card(onMonday());
expect(within(await rowOf('Museum')).getByText('Arrival about 9:00 AM, stay until 9:30 AM')).toBeInTheDocument();
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, start 9:00 AM');
});
it('on a day without a start, asks for one and leaves arrivals and hours unsaid', async () => {
card(onMonday(null));
expect(
await screen.findByText('Give the walk a time on this day to see arrivals and opening hours.')
).toBeInTheDocument();
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, no start time');
await screen.findAllByTestId('walk-station');
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
});
it('on a day without a date, says so and shows no arrival', async () => {
card(onMonday('09:00', buildDay({ id: 7, trip_id: 4, date: null })));
expect(
await screen.findByText('This day has no date, so arrivals and opening hours are left out.')
).toBeInTheDocument();
await screen.findAllByTestId('walk-station');
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('without a route, shows no arrival', async () => {
card({ ...onMonday(), route: null });
await screen.findAllByTestId('walk-station');
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
expect(screen.queryByText(/Opening hours as OpenStreetMap has them/)).not.toBeInTheDocument();
});
it('names no source of hours when no stop has any', async () => {
vi.mocked(tourRepo.detail).mockImplementation(() =>
detail(waypoints.map(({ opening_hours: _hours, ...point }) => point))
);
card({ day });
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00')).toBeInTheDocument();
expect(screen.queryByText(/Opening hours as OpenStreetMap has them/)).not.toBeInTheDocument();
card(onMonday());
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument();
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
expect(screen.queryByTestId('walk-hours-source')).not.toBeInTheDocument();
});
it('opened from no day of the walk, takes the first with a start time, lets another be picked, and checks nothing off', async () => {
card({
assignment: null,
days: [MONDAY, TUESDAY],
assignments: { '7': [entry(7, null)], '8': [entry(8, null, '10:00')] },
route: ROUTE,
});
const picker = screen.getByTestId('walk-day-picker');
expect(picker).toHaveTextContent('Day 2, start 10:00');
// Tuesday from 10:00: the museum closed at 09:20 already.
expect(within(await rowOf('Museum')).getByTestId('walk-station-conflict')).toHaveTextContent(
'Museum is closed at 10:00, the stay would last until 10:30; open 09:00 to 09:20'
);
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
expect(
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
).toBeInTheDocument();
fireEvent.click(picker);
const monday = screen.getByRole('button', { name: /^Day 1, .*no start time$/ });
fireEvent.click(monday);
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent('Day 1, no start time');
expect(screen.getByText('Give the walk a time on this day to see arrivals and opening hours.')).toBeInTheDocument();
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('opened from no day while the walk is on none, asks to put it on one', async () => {
card({ assignment: null });
expect(
await screen.findByText('Put the walk on a day with a start time to see arrivals and opening hours.')
).toBeInTheDocument();
});
it('reads the stops again once the walk was saved, keeping the ones shown until then', async () => {
const view = render(<WalkStationsCard tour={{ ...walk, revision: 1 }} tripId={4} assignment={ON_DAY} canMark />);
await screen.findAllByTestId('walk-station');
vi.mocked(tourRepo.detail).mockImplementation(() => detail(waypoints.slice(0, 2)));
view.rerender(<WalkStationsCard tour={{ ...walk, revision: 2 }} tripId={4} assignment={ON_DAY} canMark />);
expect(screen.getAllByTestId('walk-station')).toHaveLength(3);
await vi.waitFor(() => expect(screen.getAllByTestId('walk-station')).toHaveLength(1));
expect(tourRepo.detail).toHaveBeenCalledTimes(2);
});
it('a walk whose stops cannot be read lists none, counts none and says so', async () => {
@@ -228,7 +356,7 @@ describe('WalkStationsCard', () => {
{ lat: 48.002, lng: 11, role: 'end', sequence: 1 },
];
vi.mocked(tourRepo.detail).mockImplementation(() => detail(ends));
card({ day });
card(onMonday());
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
@@ -239,7 +367,7 @@ describe('WalkStationsCard', () => {
it('a walk saved offline lists its stops before the sync gave them keys', async () => {
const unsaved = waypoints.map(({ key: _key, ...point }) => point);
vi.mocked(tourRepo.detail).mockImplementation(() => detail(unsaved));
card({ tour: { ...walk, place_id: -1700000000001 }, assignmentId: -1700000000000 });
card({ tour: { ...walk, place_id: -1700000000001 }, assignment: { id: -1700000000000, day_id: 7 } });
expect(await screen.findAllByTestId('walk-station')).toHaveLength(3);
expect(screen.getByText('0 of 3 visited')).toBeInTheDocument();
+53 -128
View File
@@ -1,54 +1,25 @@
import { walkStationsOf, type TourListItem, type TourVisitStatus } from '@trek/shared';
import { AlertTriangle, Check, CheckCheck, Clock, Redo2, SkipForward, Undo2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { TourListItem, TourVisitStatus } from '@trek/shared';
import { Check, CheckCheck, Redo2, SkipForward, Undo2 } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { tourRepo } from '../../../repo/tourRepo';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import type { Assignment, AssignmentsMap, Day } from '../../../types';
import { SoftPill } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { useToast } from '../../shared/Toast';
import { StationPills } from '../planner/stationParts';
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { TourDayRowPill } from '../TourDayRowFacts';
import { CardAction, TourNotice } from '../tourParts';
import { CardAction } from '../tourParts';
import { StationScheduleLine } from './StationSchedule';
import { useWalkDay } from './useWalkDay';
import { useLinkedPlaceOf, useWalkSchedule } from './useWalkSchedule';
import { useWalkStations } from './useWalkStations';
import { WalkDayLine } from './WalkDayLine';
import { useDayVisits, useWalkProgress } from './walkProgress';
import { clockOf, minutesOf, stationArrivals, stationFit, type StationFit } from './walkSchedule';
import type { StationSchedule } from './walkSchedule';
let nextId = 0;
const freshId = () => `station-${++nextId}`;
/**
* CW-03: a synced walk's stations all carry a key. Points without any are the
* route's ends, which this device reads in their place while it holds none of
* the stations, so they are not listed as the walk's. A walk saved offline has
* no keys before it synced, and its stations stay.
*/
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
return placeId < 0 || points.some((point) => point.key) ? points : [];
}
/**
* CW-03: a saved walk's stations in order, read once for the dialog; null while
* they load, none where they could not be read.
*/
function useWalkStations(tripId: number | string, placeId: number): TourPlannerWaypoint[] | null {
const [stations, setStations] = useState<{ placeId: number; points: TourPlannerWaypoint[] } | null>(null);
useEffect(() => {
const controller = new AbortController();
tourRepo
.detail(tripId, placeId, controller.signal)
.then((detail) => {
if (controller.signal.aborted) return;
const points = walkStationsOf(plannerWaypointsOf(detail.waypoints, freshId));
setStations({ placeId, points: ownStations(points, placeId) });
})
.catch(() => {
if (!controller.signal.aborted) setStations({ placeId, points: [] });
});
return () => controller.abort();
}, [placeId, tripId]);
return stations?.placeId === placeId ? stations.points : null;
}
const NO_DAYS: Day[] = [];
const NO_ASSIGNMENTS: AssignmentsMap = {};
/**
* CW-03: a walk's progress on one of its days, as a pill of the day row:
@@ -104,58 +75,28 @@ function StatusActions({
);
}
/** CW-04: what the arrival at a station meets, said only where it can be known. */
function StationHours({ fit, arrival, atLeast }: { fit: StationFit; arrival: number; atLeast: boolean }) {
const { t } = useTranslation();
const time = clockOf(arrival);
const conflict =
fit.kind === 'closed'
? t('tours.walk.hours.closed', { arrival: time })
: fit.kind === 'closes'
? t('tours.walk.hours.closes', { arrival: time, leave: clockOf(fit.leave), closes: clockOf(fit.closes) })
: null;
return (
<span className="flex flex-col gap-1">
<span className="inline-flex items-center gap-1 text-content-muted" style={fs(11, 'body')}>
<Clock size={11} strokeWidth={2.2} aria-hidden="true" />
{t(atLeast ? 'tours.walk.arrivalAtLeast' : 'tours.walk.arrival', { time })}
</span>
{conflict && (
<span
className="inline-flex items-start gap-1 text-warning"
style={fs(11, 'body')}
data-testid="walk-station-conflict"
>
<AlertTriangle size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
{conflict}
</span>
)}
{fit.kind === 'unread' && (
<span className="text-content-faint" style={fs(10.5, 'body')}>
{t('tours.walk.hours.unread', { text: fit.hours })}
</span>
)}
</span>
);
}
/** One station of the list: its number or mark, name, stay and the day's arrival, and what can be marked. */
/**
* One station of the list: its number or mark, its name (its own, else the
* trip place it stands for), stay, the day's arrival and opening hours, and
* what can be marked.
*/
function StationRow({
station,
index,
status,
arrival,
date,
placeName,
schedule,
onMark,
}: {
station: TourPlannerWaypoint;
index: number;
status: TourVisitStatus | undefined;
arrival: { arrival: number; atLeast: boolean } | null;
date: string | null;
placeName: string | undefined;
schedule: StationSchedule | undefined;
onMark: ((status: TourVisitStatus | 'planned') => void) | null;
}) {
const { t } = useTranslation();
const name = station.name ?? placeName ?? t('tours.walk.stationNumbered', { number: index + 1 });
return (
<li
data-testid="walk-station"
@@ -178,16 +119,10 @@ function StationRow({
className={`truncate font-semibold text-content ${status === 'skipped' ? 'line-through' : ''}`}
style={fs(12.5, 'body')}
>
{station.name ?? t('tours.walk.stationNumbered', { number: index + 1 })}
{name}
</span>
<StationPills point={station} />
{arrival && date && (
<StationHours
fit={stationFit(station.openingHours, date, arrival.arrival, station.stayMinutes ?? 0)}
arrival={arrival.arrival}
atLeast={arrival.atLeast}
/>
)}
<StationScheduleLine schedule={schedule} name={name} />
{status && (
<span className="text-content-faint" style={fs(11, 'body')}>
{t(status === 'visited' ? 'tours.walk.visit.isVisited' : 'tours.walk.visit.isSkipped')}
@@ -199,24 +134,17 @@ function StationRow({
);
}
/**
* The lines above the stations: loading, stations that could not be read, what
* is missing for arrivals, where the hours come from, where to check off.
*/
/** The lines above the stations: loading, stations that could not be read, where to check off. */
function WalkStationsNotes({
loading,
unread,
any,
noStart,
hoursShown,
fromDay,
notSynced,
}: {
loading: boolean;
unread: boolean;
any: boolean;
noStart: boolean;
hoursShown: boolean;
fromDay: boolean;
notSynced: boolean;
}) {
@@ -230,58 +158,56 @@ function WalkStationsNotes({
return (
<>
{unread && quiet(t('tours.walk.stationsUnread'))}
{any && noStart && quiet(t('tours.walk.hours.noStart'))}
{hoursShown && <TourNotice icon={<Clock size={13} strokeWidth={2} />}>{t('tours.walk.hours.source')}</TourNotice>}
{any && fromDay && quiet(t('tours.walk.visit.fromDay'))}
{any && notSynced && quiet(t('tours.walk.visit.notSynced'))}
</>
);
}
/** CW-04: what the stations are checked against: the day, its start and the route. */
export interface WalkDay {
/** `YYYY-MM-DD`; null on a day without a date. */
date: string | null;
/** `HH:MM` the walk starts that day; null when none is set. */
start: string | null;
route: ReadonlyArray<readonly [number, number]>;
walkingMinutes: number | null;
}
/**
* CW-03: a city walk's stations in its detail, in order, each with its stay,
* importance and protection; opened from a day, each can be marked visited or
* skipped for that day and taken back. Marking changes no route, place or order.
* CW-03, CW-04: a city walk's stations in its detail, in order, each with its
* stay, importance and protection, and its arrival and opening hours on the
* day looked at. Opened from one of the walk's days, that day holds, and each
* station can be marked visited or skipped for it and taken back; opened
* otherwise, a day of the walk can be picked for the times and nothing is
* checked off. Neither changes a route, place or order.
*/
export function WalkStationsCard({
tour,
tripId,
assignmentId,
assignment,
canMark,
day = null,
days = NO_DAYS,
assignments = NO_ASSIGNMENTS,
route = null,
}: {
tour: TourListItem;
tripId: number | string;
/** The day the dialog was opened from; without one there is nothing to check off. */
assignmentId: number | null;
/** The walk's entry on the day the dialog was opened from; without one there is nothing to check off. */
assignment: Pick<Assignment, 'id' | 'day_id'> | null;
canMark: boolean;
/** CW-04: the day it was opened from, for arrivals and opening hours. */
day?: WalkDay | null;
days?: readonly Day[];
assignments?: AssignmentsMap;
/** The walk's line, along which its walking time is shared out. */
route?: ReadonlyArray<readonly [number, number]> | null;
}) {
const { t } = useTranslation();
const toast = useToast();
const stations = useWalkStations(tripId, tour.place_id);
const isWalk = tour.tour_type === 'walk';
const stations = useWalkStations(tripId, tour.place_id, isWalk, tour.revision);
const assignmentId = assignment?.id ?? null;
const visits = useDayVisits(assignmentId);
const mark = useTourVisitStore((state) => state.mark);
if (tour.tour_type !== 'walk') return null;
const placeOf = useLinkedPlaceOf();
const choice = useWalkDay(isWalk ? tour.place_id : null, days, assignments, assignment?.day_id ?? null);
const walkingMinutes = tour.duration ?? null;
const schedule = useWalkSchedule({ stations, route, walkingMinutes, day: choice.day, placeOf });
if (!isWalk) return null;
const onMark = (key: string, status: TourVisitStatus | 'planned') => {
if (assignmentId === null) return;
mark(tripId, assignmentId, key, status).catch(() => toast.error(t('tours.walk.visit.failed')));
};
const start = minutesOf(day?.start);
const arrivals = stations && day?.date ? stationArrivals(stations, day.route, start, day.walkingMinutes) : null;
const listed = stations ?? [];
const anyHours = listed.some((station) => station.openingHours);
const visited = listed.filter((station) => station.key && visits?.[station.key] === 'visited').length;
const marking = markState(canMark, assignmentId);
return (
@@ -296,12 +222,11 @@ export function WalkStationsCard({
}
>
<div data-testid="walk-stations" className="flex flex-col gap-2">
<WalkDayLine choice={choice} schedule={schedule} stationCount={listed.length} />
<WalkStationsNotes
loading={stations === null}
unread={stations?.length === 0 && tour.station_count !== 0}
any={listed.length > 0}
noStart={!!day && start === null}
hoursShown={!!arrivals && anyHours}
fromDay={assignmentId === null}
notSynced={marking === 'notSynced'}
/>
@@ -312,8 +237,8 @@ export function WalkStationsCard({
station={station}
index={index}
status={station.key ? visits?.[station.key] : undefined}
arrival={arrivals?.[index] ?? null}
date={day?.date ?? null}
placeName={placeOf(station)?.name}
schedule={schedule.byStation.get(station.id)}
onMark={marking === 'ready' && station.key ? (next) => onMark(station.key ?? '', next) : null}
/>
))}
@@ -0,0 +1,80 @@
// CW-02, CW-04: the day a city walk's times are shown for. Opened from one of
// its days that day holds; otherwise the first with a start time, or the one
// picked, and another walk starts over.
import { describe, expect, it } from 'vitest';
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { act, renderHook } from '../../../../tests/helpers/render';
import type { AssignmentsMap, Day } from '../../../types';
import { useWalkDay } from './useWalkDay';
const one = buildDay({ id: 1, date: '2026-10-19' });
const two = buildDay({ id: 2, date: '2026-10-20' });
const three = buildDay({ id: 3, date: '2026-10-21' });
const days: Day[] = [one, two, three];
const entry = (dayId: number, placeId: number, time: string | null, placeTime: string | null = null) =>
buildAssignment({
day_id: dayId,
place_id: placeId,
assignment_time: time,
place: buildPlace({ id: placeId, place_time: placeTime }),
});
// Walk 42 is on day 1 without a time, on day 2 at the place's own 10:00 (overridden to 9:30 PM there)
// and on day 3 at its place time; another place is on day 1 too.
const assignments: AssignmentsMap = {
'1': [entry(1, 7, '08:00'), entry(1, 42, null)],
'2': [entry(2, 42, '9:30 PM', '10:00')],
'3': [entry(3, 42, null, '11:15')],
};
function walkDay(placeId: number | null, fixedDayId: number | null = null, map: AssignmentsMap = assignments) {
return renderHook(({ id, fixed, entries }) => useWalkDay(id, days, entries, fixed), {
initialProps: { id: placeId, fixed: fixedDayId, entries: map },
});
}
describe('useWalkDay', () => {
it("lists the walk's days in the trip's order with their start, the day's own time before the place's", () => {
const { result } = walkDay(42);
expect(result.current.options.map((option) => [option.day.id, option.index, option.start])).toEqual([
[1, 0, null],
[2, 1, '21:30'],
[3, 2, '11:15'],
]);
});
it('takes the first day with a start time, and the first day where none has one', () => {
expect(walkDay(42).result.current).toMatchObject({ fixed: false, day: { day: { id: 2 } } });
const untimed = { '3': [entry(3, 42, null)], '1': [entry(1, 42, 'later')] };
expect(walkDay(42, null, untimed).result.current.day?.day.id).toBe(1);
});
it('keeps the day picked, falls back once the walk left it, and starts over for another walk', () => {
const view = walkDay(42);
act(() => view.result.current.choose(3));
expect(view.result.current.day?.day.id).toBe(3);
view.rerender({ id: 42, fixed: null, entries: { ...assignments, '3': [] } });
expect(view.result.current.day?.day.id).toBe(2);
const other = { '2': [entry(2, 43, '07:00')], '3': [entry(3, 43, '08:00')] };
view.rerender({ id: 43, fixed: null, entries: other });
expect(view.result.current.day?.day.id).toBe(2);
});
it('holds the day it was opened from, with or without a time, and never another once the walk left it', () => {
expect(walkDay(42, 1).result.current).toMatchObject({ fixed: true, day: { day: { id: 1 }, start: null } });
const gone = walkDay(42, 1, { '2': assignments['2'] ?? [] });
expect(gone.result.current).toMatchObject({ fixed: true, day: null });
act(() => gone.result.current.choose(2));
expect(gone.result.current.day).toBeNull();
});
it('has no day for no walk, and nothing to pick', () => {
const { result } = walkDay(null);
expect(result.current).toMatchObject({ options: [], day: null, fixed: false });
act(() => result.current.choose(2));
expect(result.current.day).toBeNull();
});
});
@@ -0,0 +1,65 @@
import { clockOf, minutesOf } from '@trek/shared';
import { useMemo, useState } from 'react';
import type { AssignmentsMap, Day } from '../../../types';
/** A day the walk is on, with its place in the trip and the walk's start that day. */
export interface WalkDayOption {
day: Day;
/** The day's position in the trip, so `index + 1` is the number the plan shows. */
index: number;
/** `HH:MM` the walk starts that day; null when none is set or it cannot be read. */
start: string | null;
}
/**
* CW-02: the days a walk is on, in the order of the trip, each with its start:
* the time set on that day's entry, else the place's own time, as the plan
* shows it. A time that cannot be read is no start.
*/
function walkDayOptions(placeId: number, days: readonly Day[], assignments: AssignmentsMap): WalkDayOption[] {
return days.flatMap((day, index) => {
const assignment = (assignments[String(day.id)] ?? []).find((entry) => entry.place_id === placeId);
if (!assignment) return [];
const minutes = minutesOf(assignment.assignment_time ?? assignment.place?.place_time);
return [{ day, index, start: minutes === null ? null : clockOf(minutes) }];
});
}
/** The day the times are worked out for, and how it is picked. */
export interface WalkDayChoice {
options: WalkDayOption[];
day: WalkDayOption | null;
/** Opened from one of the walk's days: that day holds and cannot be switched. */
fixed: boolean;
choose: (dayId: number) => void;
}
/**
* CW-02, CW-04: the day a walk's arrivals and opening hours are shown for.
* Opened from one of its days, that day holds, with or without a time, and is
* gone rather than replaced once the walk leaves it: another day never takes
* over its time quietly. Otherwise the day picked here, or the first with a
* start time, else the first. The choice is only what is looked at, never
* saved, and starts over for another walk.
*/
export function useWalkDay(
placeId: number | null,
days: readonly Day[],
assignments: AssignmentsMap,
fixedDayId: number | null
): WalkDayChoice {
const [chosen, setChosen] = useState<{ placeId: number; dayId: number } | null>(null);
const options = useMemo(
() => (placeId === null ? [] : walkDayOptions(placeId, days, assignments)),
[assignments, days, placeId]
);
const choose = (dayId: number) => {
if (placeId !== null) setChosen({ placeId, dayId });
};
if (fixedDayId !== null) {
return { options, day: options.find((option) => option.day.id === fixedDayId) ?? null, fixed: true, choose };
}
const picked = chosen?.placeId === placeId ? options.find((option) => option.day.id === chosen.dayId) : undefined;
const day = picked ?? options.find((option) => option.start !== null) ?? options[0] ?? null;
return { options, day, fixed: false, choose };
}
@@ -0,0 +1,69 @@
import { walkStationsOf } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useTripStore } from '../../../store/tripStore';
import type { AssignmentsMap, Day, Place } from '../../../types';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWalkDay, type WalkDayChoice } from './useWalkDay';
import { walkScheduleOf, type LinkedPlaceOf, type WalkSchedule, type WalkScheduleInput } from './walkSchedule';
const NO_PLACES: Place[] = [];
const NO_DAYS: Day[] = [];
const NO_ASSIGNMENTS: AssignmentsMap = {};
const NO_STATIONS: TourPlannerWaypoint[] = [];
/** PR1-10: the trip place a point stands for, while the trip still has it. */
export function useLinkedPlaceOf(): (point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) => Place | null {
const places = useTripStore((state) => state.places) ?? NO_PLACES;
const byId = useMemo(() => new Map(places.map((place) => [place.id, place])), [places]);
return useCallback(
(point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) =>
point.linkedPlaceId ? (byId.get(point.linkedPlaceId) ?? null) : null,
[byId]
);
}
/** CW-04: `walkScheduleOf`, worked out again only when one of its parts changes. */
export function useWalkSchedule({ stations, route, walkingMinutes, day, placeOf }: WalkScheduleInput): WalkSchedule {
return useMemo(
() => walkScheduleOf({ stations, route, walkingMinutes, day, placeOf }),
[day, placeOf, route, stations, walkingMinutes]
);
}
/** A walk's times in the planner: whether it is one, the day they are for, the schedule and how many stations. */
export interface PlannerWalkSchedule {
enabled: boolean;
choice: WalkDayChoice;
schedule: WalkSchedule;
stationCount: number;
}
/**
* CW-02, CW-04: the planner's walk against the days it is on. The day is the
* one picked here, else the first with a start time; nothing of it is saved.
* Only a route that belongs to the stations as they are now counts, so a stop
* moved since the last routing shows no arrival until the route is planned again.
*/
export function usePlannerWalkSchedule(
planner: Pick<
TourPlannerController,
'tourType' | 'editingPlaceId' | 'waypoints' | 'route' | 'routeCurrent' | 'walkingDurationMinutes'
>,
placeOf: LinkedPlaceOf
): PlannerWalkSchedule {
const days = useTripStore((state) => state.days) ?? NO_DAYS;
const assignments = useTripStore((state) => state.assignments) ?? NO_ASSIGNMENTS;
const { tourType, editingPlaceId, waypoints, route, routeCurrent, walkingDurationMinutes } = planner;
const enabled = tourType === 'walk';
const choice = useWalkDay(enabled ? (editingPlaceId ?? null) : null, days, assignments, null);
const stations = useMemo(() => (enabled ? walkStationsOf(waypoints) : NO_STATIONS), [enabled, waypoints]);
const schedule = useWalkSchedule({
stations,
route: routeCurrent ? (route ?? null) : null,
walkingMinutes: walkingDurationMinutes ?? null,
day: choice.day,
placeOf,
});
return { enabled, choice, schedule, stationCount: stations.length };
}
@@ -0,0 +1,46 @@
import { walkStationsOf } from '@trek/shared';
import { useEffect, useState } from 'react';
import { tourRepo } from '../../../repo/tourRepo';
import { randomId } from '../../../utils/randomId';
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
/**
* CW-03: a synced walk's stations all carry a key. Points without any are the
* route's ends, which this device reads in their place while it holds none of
* the stations, so they are not listed as the walk's. A walk saved offline has
* no keys before it synced, and its stations stay.
*/
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
return placeId < 0 || points.some((point) => point.key) ? points : [];
}
/**
* CW-03: a saved walk's stations in order: null while they load, none where
* they could not be read. Read again when the walk is saved (its revision
* moves on), here or by someone else, so the detail never shows the stations
* of before; the ones shown stay until the new ones are in.
*/
export function useWalkStations(
tripId: number | string,
placeId: number,
enabled: boolean,
revision?: number
): TourPlannerWaypoint[] | null {
const [stations, setStations] = useState<{ placeId: number; points: TourPlannerWaypoint[] } | null>(null);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
tourRepo
.detail(tripId, placeId, controller.signal)
.then((detail) => {
if (controller.signal.aborted) return;
const points = walkStationsOf(plannerWaypointsOf(detail.waypoints, randomId));
setStations({ placeId, points: ownStations(points, placeId) });
})
.catch(() => {
if (!controller.signal.aborted) setStations({ placeId, points: [] });
});
return () => controller.abort();
}, [enabled, placeId, revision, tripId]);
return enabled && stations?.placeId === placeId ? stations.points : null;
}
@@ -0,0 +1,88 @@
// CW-02, CW-04: a city walk's stations on one day: what is missing for their
// arrivals, and their arrivals, stays and opening hours once nothing is.
import { describe, expect, it } from 'vitest';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { walkScheduleOf, type LinkedPlaceOf, type WalkScheduleInput } from './walkSchedule';
const ROUTE: Array<[number, number]> = [
[48, 11],
[48.001, 11],
[48.002, 11],
];
const museum: TourPlannerWaypoint = {
id: 'museum',
lat: 48,
lng: 11,
role: 'start',
name: 'Museum',
stayMinutes: 90,
openingHours: 'Mo-Fr 10:00-18:00',
};
const square: TourPlannerWaypoint = { id: 'square', lat: 48.001, lng: 11, role: 'via', linkedPlaceId: 5 };
const cafe: TourPlannerWaypoint = { id: 'cafe', lat: 48.002, lng: 11, role: 'end', name: 'Cafe', stayMinutes: 30 };
// 2026-10-19 is a Monday.
const MONDAY = { date: '2026-10-19' };
const noPlace: LinkedPlaceOf = () => null;
function input(overrides: Partial<WalkScheduleInput> = {}): WalkScheduleInput {
return {
stations: [museum, square, cafe],
route: ROUTE,
walkingMinutes: 40,
day: { day: MONDAY, start: '09:30' },
placeOf: noPlace,
...overrides,
};
}
describe('walkScheduleOf', () => {
it('says what is missing, in order: a day, its date, a start time, a route with its walking time', () => {
expect(walkScheduleOf(input({ day: null })).state).toBe('noDay');
expect(walkScheduleOf(input({ day: { day: { date: null }, start: '09:30' } })).state).toBe('noDate');
expect(walkScheduleOf(input({ day: { day: MONDAY, start: null } })).state).toBe('noStart');
expect(walkScheduleOf(input({ day: { day: MONDAY, start: 'soon' } })).state).toBe('noStart');
expect(walkScheduleOf(input({ stations: null })).state).toBe('noRoute');
expect(walkScheduleOf(input({ route: null })).state).toBe('noRoute');
expect(walkScheduleOf(input({ route: ROUTE.slice(0, 1) })).state).toBe('noRoute');
const unknown = walkScheduleOf(input({ walkingMinutes: null }));
expect(unknown).toEqual({ state: 'noRoute', byStation: new Map(), anyHours: false });
});
it('gives each station its arrival, the end of its stay and what its hours say', () => {
const schedule = walkScheduleOf(input());
expect(schedule.state).toBe('ready');
expect(schedule.anyHours).toBe(true);
expect(schedule.byStation.get('museum')).toEqual({
arrival: 570,
atLeast: false,
leave: 660,
fit: { kind: 'closed', source: 'osm', windows: [[600, 1080]] },
});
// No stay entered at the square: its end is not known, and the cafe after it comes at the earliest.
expect(schedule.byStation.get('square')).toEqual({
arrival: 680,
atLeast: false,
leave: null,
fit: { kind: 'unknown' },
});
expect(schedule.byStation.get('cafe')).toMatchObject({ arrival: 700, atLeast: true, leave: 730 });
});
it("reads a start typed in twelve hours, and a trip place's own hours before the station's", () => {
const week = JSON.stringify(Array.from({ length: 7 }, () => ({ closed: false, open: '09:00', close: '23:30' })));
const placeOf: LinkedPlaceOf = (point) =>
point.linkedPlaceId === 5 ? { name: 'Square', opening_hours: week } : null;
const stations = [museum, { ...square, openingHours: 'Mo off' }, cafe];
const schedule = walkScheduleOf(input({ stations, day: { day: MONDAY, start: '9:30 PM' }, placeOf }));
expect(schedule.byStation.get('museum')?.arrival).toBe(1290);
expect(schedule.byStation.get('square')?.fit).toEqual({ kind: 'open', source: 'place', window: [540, 1410] });
});
it('knows no hours where no station has any', () => {
const schedule = walkScheduleOf(input({ stations: [{ ...museum, openingHours: null }, square, cafe] }));
expect(schedule.state).toBe('ready');
expect(schedule.anyHours).toBe(false);
});
});
+62 -127
View File
@@ -1,146 +1,81 @@
import { minutesOf, stationArrivals, stationFit, stationHoursOn, type StationFit } from '@trek/shared';
import type { Day, Place } from '../../../types';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
/**
* CW-04: a city walk's stations against their opening hours on one day. The
* arrival is an estimate: the start of that day's walk, the walking time
* shared out along the route by distance, and the stays before. Hours are
* read only in OpenStreetMap's plain forms ("Mo-Fr 09:00-18:00; Sa 10:00-14:00;
* Su off", "24/7"); anything else stays unread rather than guessed, and a
* station without hours is unknown, never open.
* CW-02, CW-04: a city walk's stations on the day it is looked at: the arrival
* at each, the end of its stay and what its opening hours say, worked out by
* the rules in `@trek/shared` (`walk-hours.ts`). Nothing is said without a
* day, a date, a start time and a route with its walking time, so no estimate
* looks more exact than what it is made of.
*/
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] as const;
/** What a schedule still misses, or `ready`. */
export type WalkScheduleState = 'noDay' | 'noDate' | 'noStart' | 'noRoute' | 'ready';
/** Minutes after midnight of a `HH:MM` time; null for anything else. */
export function minutesOf(time: string | null | undefined): number | null {
const match = /^(\d{1,2}):(\d{2})/.exec(time ?? '');
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
/** One station on that day: arrival and end of stay in minutes after midnight, and what its hours say. */
export interface StationSchedule {
arrival: number;
/** A stay before it is not known, so the arrival may come later. */
atLeast: boolean;
/** The end of the stay; null while the stay is not known. */
leave: number | null;
fit: StationFit;
}
export function clockOf(minutes: number): string {
const day = ((Math.round(minutes) % 1440) + 1440) % 1440;
return `${String(Math.floor(day / 60)).padStart(2, '0')}:${String(day % 60).padStart(2, '0')}`;
export interface WalkSchedule {
state: WalkScheduleState;
/** Keyed by the waypoint's id; empty unless `ready`. */
byStation: ReadonlyMap<string, StationSchedule>;
/** Some station has opening hours to go by. */
anyHours: boolean;
}
/** Monday 0 to Sunday 6, of a `YYYY-MM-DD` date read as a calendar day. */
function weekdayOf(date: string): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) return null;
const day = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))).getUTCDay();
return (day + 6) % 7;
/** The trip place a waypoint stands for, as far as the hours and the name need it. */
export type LinkedPlaceOf = (
point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>
) => Pick<Place, 'name' | 'opening_hours'> | null;
export interface WalkScheduleInput {
/** The walk's stations in order: no shaping point, no way back of a loop. Null while they load. */
stations: readonly TourPlannerWaypoint[] | null;
route: ReadonlyArray<readonly [number, number]> | null;
walkingMinutes: number | null;
/** The day it is looked at, with the walk's start that day; null when it is on none. */
day: { day: Pick<Day, 'date'>; start: string | null } | null;
placeOf: LinkedPlaceOf;
}
function daysOf(spec: string): Set<number> | null {
const days = new Set<number>();
for (const part of spec.split(',')) {
const [from, to] = part.split('-');
const start = DAYS.indexOf(from as (typeof DAYS)[number]);
const end = to === undefined ? start : DAYS.indexOf(to as (typeof DAYS)[number]);
if (start < 0 || end < 0) return null;
for (let day = start; ; day = (day + 1) % 7) {
days.add(day);
if (day === end) break;
}
}
return days;
function emptySchedule(state: WalkScheduleState): WalkSchedule {
return { state, byStation: new Map(), anyHours: false };
}
/** The windows a place is open on a date, in minutes; null where the hours cannot be read. */
function openWindows(hours: string, date: string): Array<[number, number]> | null {
const weekday = weekdayOf(date);
if (weekday === null) return null;
const text = hours.trim();
if (text === '24/7') return [[0, 1440]];
let windows: Array<[number, number]> = [];
for (const raw of text.split(';')) {
const rule = raw.trim();
if (!rule) continue;
const match = /^([A-Za-z,-]+)\s+(.+)$/.exec(rule);
if (!match) return null;
const days = daysOf(match[1]);
if (!days) return null;
if (!days.has(weekday)) continue;
// A later rule for the day replaces an earlier one, as OpenStreetMap reads them.
if (match[2].trim() === 'off' || match[2].trim() === 'closed') {
windows = [];
continue;
}
const ranges: Array<[number, number]> = [];
for (const span of match[2].split(',')) {
const [open, close] = span.trim().split('-');
const from = minutesOf(open);
const to = close === '24:00' ? 1440 : minutesOf(close);
if (from === null || to === null || to <= from) return null;
ranges.push([from, to]);
}
windows = ranges;
}
return windows;
/** The date and the start in minutes the arrivals count from, or what is missing for them. */
function startOf(day: WalkScheduleInput['day']): { date: string; start: number } | WalkScheduleState {
if (!day) return 'noDay';
if (!day.day.date) return 'noDate';
const start = minutesOf(day.start);
return start === null ? 'noStart' : { date: day.day.date, start };
}
export type StationFit =
| { kind: 'unknown' }
| { kind: 'unread'; hours: string }
| { kind: 'ok' }
| { kind: 'closed'; arrival: number }
| { kind: 'closes'; arrival: number; leave: number; closes: number };
/** Whether a station is open from its arrival to the end of its stay on that date. */
export function stationFit(hours: string | null | undefined, date: string, arrival: number, stay: number): StationFit {
if (!hours) return { kind: 'unknown' };
const windows = openWindows(hours, date);
if (windows === null) return { kind: 'unread', hours };
const leave = arrival + stay;
const open = windows.find(([from, to]) => arrival >= from && arrival < to);
if (!open) return { kind: 'closed', arrival };
if (leave > open[1]) return { kind: 'closes', arrival, leave, closes: open[1] };
return { kind: 'ok' };
}
/** Metres between two points, close enough along a city walk. */
function metres(a: readonly [number, number], b: readonly [number, number]): number {
const rad = Math.PI / 180;
const x = (b[1] - a[1]) * rad * Math.cos(((a[0] + b[0]) / 2) * rad);
const y = (b[0] - a[0]) * rad;
return Math.hypot(x, y) * 6_371_000;
}
/**
* The estimated arrival at each station, in minutes after midnight: the start,
* the walking time shared out by how far along the route the station lies,
* and the stays before it; a stay not known counts as nothing before it and
* makes every later arrival a lower bound. Null when the start, the walking
* time or the route is not known.
*/
export function stationArrivals(
stations: readonly TourPlannerWaypoint[],
route: ReadonlyArray<readonly [number, number]>,
start: number | null,
walkingMinutes: number | null
): Array<{ arrival: number; atLeast: boolean }> | null {
if (start === null || walkingMinutes === null || route.length < 2) return null;
const along: number[] = [0];
for (let i = 1; i < route.length; i += 1) along.push(along[i - 1] + metres(route[i - 1], route[i]));
const total = along[along.length - 1] || 1;
let stays = 0;
let unknown = false;
return stations.map((station) => {
let nearest = 0;
let best = Infinity;
route.forEach((point, index) => {
const gap = metres(point, [station.lat, station.lng]);
if (gap < best) {
best = gap;
nearest = index;
}
export function walkScheduleOf({ stations, route, walkingMinutes, day, placeOf }: WalkScheduleInput): WalkSchedule {
const from = startOf(day);
if (typeof from === 'string') return emptySchedule(from);
if (!stations || !route) return emptySchedule('noRoute');
const arrivals = stationArrivals(stations, route, from.start, walkingMinutes);
if (!arrivals) return emptySchedule('noRoute');
const byStation = new Map<string, StationSchedule>();
let anyHours = false;
stations.forEach((station, index) => {
const at = arrivals[index];
const hours = stationHoursOn({ place: placeOf(station)?.opening_hours, osm: station.openingHours }, from.date);
if (hours) anyHours = true;
const stay = station.stayMinutes ?? null;
byStation.set(station.id, {
...at,
leave: stay === null ? null : at.arrival + stay,
fit: stationFit(hours, at, stay),
});
const arrival = start + walkingMinutes * (along[nearest] / total) + stays;
const result = { arrival, atLeast: unknown };
if (station.stayMinutes == null) unknown = true;
else stays += station.stayMinutes;
return result;
});
return { state: 'ready', byStation, anyHours };
}