diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index 7c063ca99..c737c97d5 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -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() + + 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() + 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() diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index ce5063e50..85480eb13 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -688,18 +688,11 @@ export default function TourDetailDialog({ 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' && } diff --git a/client/src/components/Tours/planner/TourWaypointList.test.tsx b/client/src/components/Tours/planner/TourWaypointList.test.tsx index 01f076779..5d4c7bc85 100644 --- a/client/src/components/Tours/planner/TourWaypointList.test.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.test.tsx @@ -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 = {}) { + 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(); + 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(); + 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(); + 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(); + expect(screen.queryByTestId('walk-day-line')).not.toBeInTheDocument(); + expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index b54ccb8e2..da3e18aa0 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -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( <> +
    {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 (
  1. )} +
  2. 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) => - point.linkedPlaceId ? (placesById.get(point.linkedPlaceId) ?? null) : null, - [placesById] - ); - return { candidates, add, unlink, linkedPlaceOf }; } diff --git a/client/src/components/Tours/walk/StationSchedule.test.tsx b/client/src/components/Tours/walk/StationSchedule.test.tsx new file mode 100644 index 000000000..d095fdf8c --- /dev/null +++ b/client/src/components/Tours/walk/StationSchedule.test.tsx @@ -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 => ({ + arrival: 1040, + atLeast: false, + leave: 1100, + fit: { kind: 'unknown' }, + ...overrides, +}); + +const line = (schedule: StationSchedule | undefined) => + render(); + +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( + + ); + expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Museum is closed all day'); + rerender(); + expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Square is closed all day'); + rerender(); + 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(); + expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Tours/walk/StationSchedule.tsx b/client/src/components/Tours/walk/StationSchedule.tsx new file mode 100644 index 000000000..94d736d8a --- /dev/null +++ b/client/src/components/Tours/walk/StationSchedule.tsx @@ -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['t']; + +/** Where a station's hours come from, named on the pill, with how far to trust them behind it. */ +const SOURCES: Record = { + 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; + +/** 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 ( + + + {t(labelKey)} + {t(hintKey)} + + + ); +} + +/** 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 ( + + {t('tours.walk.hours.unread', { text: fit.text })} + + ); + } + if (fit.kind === 'open' || fit.kind === 'uncertain') { + return ( + + {fit.kind === 'open' + ? t('tours.walk.hours.open', { window: windowsText([fit.window], clock, t) }) + : t('tours.walk.hours.uncertain')} + + + ); + } + return ( + + + ); +} + +/** + * 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 ( + + + + + + ); +} + +/** + * 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; + /** 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 ( +
    + +
    + ); +} diff --git a/client/src/components/Tours/walk/WalkDayLine.test.tsx b/client/src/components/Tours/walk/WalkDayLine.test.tsx new file mode 100644 index 000000000..d7a93ec7a --- /dev/null +++ b/client/src/components/Tours/walk/WalkDayLine.test.tsx @@ -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 => ({ + 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(tuesday); + const pick = (id: number) => setDay(id === 1 ? monday : tuesday); + return ; + } + render(); + 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(); + 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( + + ); + expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 2, start 09:30'); + expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument(); + rerender( + + ); + 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(); + expect(screen.getByText(NO_DAY)).toBeInTheDocument(); + rerender(); + expect(container).toBeEmptyDOMElement(); + // Opened from a day the walk has left: that day is not replaced, and nothing is asked. + rerender(); + expect(container).toBeEmptyDOMElement(); + rerender(); + 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( + + ); + expect(screen.getByText(/Opening hours from OpenStreetMap or your own place entries/)).toBeInTheDocument(); + rerender(); + expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument(); + rerender(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/client/src/components/Tours/walk/WalkDayLine.tsx b/client/src/components/Tours/walk/WalkDayLine.tsx new file mode 100644 index 000000000..b031eb5bb --- /dev/null +++ b/client/src/components/Tours/walk/WalkDayLine.tsx @@ -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['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 ( + + {label} + + ); + } + 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) => { + if (menu.menu) { + event.stopPropagation(); + menu.close(); + } else menu.open(event, items, true); + }; + return ( + <> + + + + ); +} + +/** + * 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 ( +
    + {choice.day && ( +
    + + {t('tours.walk.day.label')} + + +
    + )} + {note && ( +

    + {note} +

    + )} + {schedule.state === 'ready' && schedule.anyHours && ( + }>{t('tours.walk.hours.source')} + )} +
    + ); +} diff --git a/client/src/components/Tours/walk/WalkStations.test.tsx b/client/src/components/Tours/walk/WalkStations.test.tsx index 5bd839631..9e34c1f03 100644 --- a/client/src/components/Tours/walk/WalkStations.test.tsx +++ b/client/src/components/Tours/walk/WalkStations.test.tsx @@ -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 { return Promise.resolve({ tour: walk, waypoints: points }); @@ -63,7 +85,7 @@ function card(props: Partial[0]> = {}) { return render( <> - + ); } @@ -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(); + render(); + 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(); + await screen.findAllByTestId('walk-station'); + vi.mocked(tourRepo.detail).mockImplementation(() => detail(waypoints.slice(0, 2))); + view.rerender(); + 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(); diff --git a/client/src/components/Tours/walk/WalkStations.tsx b/client/src/components/Tours/walk/WalkStations.tsx index d780b9576..2d216babd 100644 --- a/client/src/components/Tours/walk/WalkStations.tsx +++ b/client/src/components/Tours/walk/WalkStations.tsx @@ -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 ( - - - - {conflict && ( - - - )} - {fit.kind === 'unread' && ( - - {t('tours.walk.hours.unread', { text: fit.hours })} - - )} - - ); -} - -/** 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 (
  3. - {station.name ?? t('tours.walk.stationNumbered', { number: index + 1 })} + {name} - {arrival && date && ( - - )} + {status && ( {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 && }>{t('tours.walk.hours.source')}} {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; - 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 | 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 | 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({ } >
    + 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} /> ))} diff --git a/client/src/components/Tours/walk/useWalkDay.test.ts b/client/src/components/Tours/walk/useWalkDay.test.ts new file mode 100644 index 000000000..1d5ec56d1 --- /dev/null +++ b/client/src/components/Tours/walk/useWalkDay.test.ts @@ -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(); + }); +}); diff --git a/client/src/components/Tours/walk/useWalkDay.ts b/client/src/components/Tours/walk/useWalkDay.ts new file mode 100644 index 000000000..138c88ce3 --- /dev/null +++ b/client/src/components/Tours/walk/useWalkDay.ts @@ -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 }; +} diff --git a/client/src/components/Tours/walk/useWalkSchedule.ts b/client/src/components/Tours/walk/useWalkSchedule.ts new file mode 100644 index 000000000..871a48274 --- /dev/null +++ b/client/src/components/Tours/walk/useWalkSchedule.ts @@ -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) => 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) => + 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 }; +} diff --git a/client/src/components/Tours/walk/useWalkStations.ts b/client/src/components/Tours/walk/useWalkStations.ts new file mode 100644 index 000000000..0939aa259 --- /dev/null +++ b/client/src/components/Tours/walk/useWalkStations.ts @@ -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; +} diff --git a/client/src/components/Tours/walk/walkSchedule.test.ts b/client/src/components/Tours/walk/walkSchedule.test.ts new file mode 100644 index 000000000..d2a55ea83 --- /dev/null +++ b/client/src/components/Tours/walk/walkSchedule.test.ts @@ -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 { + 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); + }); +}); diff --git a/client/src/components/Tours/walk/walkSchedule.ts b/client/src/components/Tours/walk/walkSchedule.ts index 00f56b1b7..7318f5cd7 100644 --- a/client/src/components/Tours/walk/walkSchedule.ts +++ b/client/src/components/Tours/walk/walkSchedule.ts @@ -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; + /** 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 +) => Pick | 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 | 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; start: string | null } | null; + placeOf: LinkedPlaceOf; } -function daysOf(spec: string): Set | null { - const days = new Set(); - 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, - 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(); + 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 }; } diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index c7b751023..95b0aedf7 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -528,12 +528,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'مقترح لموضوع {theme}', 'tours.walk.arrival': 'الوصول نحو {time}', 'tours.walk.arrivalAtLeast': 'الوصول في {time} على أقرب تقدير', - 'tours.walk.hours.closed': 'مغلق في {arrival} حسب ساعات العمل', - 'tours.walk.hours.closes': 'الوصول {arrival}، البقاء حتى {leave}، لكنه يغلق في {closes}', + 'tours.walk.hours.closed': '{name} مغلق في {arrival}، وكان البقاء سيستمر حتى {leave}؛ مفتوح {window}', + 'tours.walk.hours.closes': '{name} يغلق في {closes}، أثناء البقاء من {arrival} إلى {leave} (مفتوح {window})', 'tours.walk.hours.unread': 'لم تُقرأ ساعات العمل هنا: {text}', 'tours.walk.hours.source': - 'ساعات العمل كما في OpenStreetMap مقابل وقت الوصول التقديري؛ تحقق قبل الذهاب. المحطة بلا ساعات غير معروفة، لا مفتوحة.', + 'ساعات العمل من OpenStreetMap أو من أماكنك التي أدخلتها بنفسك، مقارنةً بوقت الوصول المقدّر بالتوقيت المحلي للخطة؛ تحقق منها قبل الذهاب. المحطة بلا ساعات عمل غير معروفة، وليست مفتوحة.', 'tours.walk.hours.noStart': 'حدد وقتًا للجولة في هذا اليوم لرؤية أوقات الوصول وساعات العمل.', + 'tours.walk.day.label': 'الأوقات حسب', + 'tours.walk.day.start': '{day}، البدء {time}', + 'tours.walk.day.noStart': '{day}، بلا وقت بدء', + 'tours.walk.hours.noDate': 'لا تاريخ لهذا اليوم، لذلك لا تظهر أوقات الوصول وساعات العمل.', + 'tours.walk.hours.noDay': 'ضع الجولة في يوم له وقت بدء لترى أوقات الوصول وساعات العمل.', + 'tours.walk.hours.closedNoStay': '{name} مغلق في {arrival}؛ مفتوح {window}', + 'tours.walk.hours.closedAllDay': '{name} مغلق طوال اليوم في هذا التاريخ', + 'tours.walk.hours.open': 'مفتوح {window}', + 'tours.walk.hours.window': 'من {from} إلى {to}', + 'tours.walk.hours.uncertain': 'لم تُفحص ساعات العمل: مدة البقاء في محطة سابقة لم تُدخل بعد.', + 'tours.walk.arrivalNextDay': 'الوصول نحو {time} في اليوم التالي؛ لا تُفحص ساعات العمل بعد منتصف الليل.', + 'tours.walk.arrivalStay': 'الوصول نحو {arrival}، البقاء حتى {leave}', + 'tours.walk.arrivalStayAtLeast': 'الوصول في {arrival} على أقرب تقدير، البقاء حتى {leave} أو أكثر', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'مكان في الرحلة', + 'tours.walk.hours.sourceOsmHint': 'وُجدت مع المحطة على OpenStreetMap؛ قد تكون قديمة.', + 'tours.walk.hours.sourcePlaceHint': 'أُدخلت لهذا المكان من الرحلة.', 'tours.poi.categoriesNone': 'لم تُختر أي فئة', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'نوع الجولة', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index cf0b91ff1..982b08327 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -526,12 +526,32 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': '{theme} üçün təklif olunur', 'tours.walk.arrival': 'Təxmini çatma {time}', 'tours.walk.arrivalAtLeast': 'Ən tez {time} çatma', - 'tours.walk.hours.closed': 'Açılış saatlarına görə {arrival} vaxtı bağlıdır', - 'tours.walk.hours.closes': 'Çatma {arrival}, qalma {leave} qədər, amma {closes} vaxtı bağlanır', + 'tours.walk.hours.closed': '{name} {arrival} vaxtı bağlıdır, qalma {leave} qədər davam edərdi; açıqdır {window}', + 'tours.walk.hours.closes': + '{name} {closes} vaxtı bağlanır, {arrival} ilə {leave} arası qalma zamanı (açıqdır {window})', 'tours.walk.hours.unread': 'Açılış saatları burada oxunmadı: {text}', 'tours.walk.hours.source': - 'Açılış saatları OpenStreetMap-dakı kimi, təxmini çatma vaxtı ilə; getməzdən əvvəl yoxla. Saatı olmayan dayanacaq məlum deyil, açıq deyil.', + 'Açılış saatları OpenStreetMap-dən və ya öz yerlərindən götürülür, planın yerli vaxtı ilə təxmini çatma vaxtına qarşı müqayisə olunur; getməzdən əvvəl yoxla. Saatları olmayan dayanacaq naməlumdur, açıq deyil.', 'tours.walk.hours.noStart': 'Çatma vaxtlarını və açılış saatlarını görmək üçün gəzintiyə bu gün vaxt ver.', + 'tours.walk.day.label': 'Vaxtlar üçün gün', + 'tours.walk.day.start': '{day}, başlanğıc {time}', + 'tours.walk.day.noStart': '{day}, başlanğıc vaxtı yoxdur', + 'tours.walk.hours.noDate': 'Bu günün tarixi yoxdur, ona görə çatma vaxtları və açılış saatları göstərilmir.', + 'tours.walk.hours.noDay': + 'Çatma vaxtlarını və açılış saatlarını görmək üçün gəzintini başlanğıc vaxtı olan bir günə qoy.', + 'tours.walk.hours.closedNoStay': '{name} {arrival} vaxtı bağlıdır; açıqdır {window}', + 'tours.walk.hours.closedAllDay': '{name} bu tarixdə bütün gün bağlıdır', + 'tours.walk.hours.open': 'Açıqdır {window}', + 'tours.walk.hours.window': '{from} ilə {to} arası', + 'tours.walk.hours.uncertain': + 'Açılış saatları yoxlanmayıb: əvvəlki dayanacaqlardan birinin qalma müddəti hələ daxil edilməyib.', + 'tours.walk.arrivalNextDay': 'Növbəti gün təxminən {time} çatma; gecə yarısından sonra açılış saatları yoxlanmır.', + 'tours.walk.arrivalStay': 'Təxmini çatma {arrival}, qalma {leave} qədər', + 'tours.walk.arrivalStayAtLeast': 'Ən tez {arrival} çatma, qalma {leave} qədər və ya daha uzun', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Səyahət yeri', + 'tours.walk.hours.sourceOsmHint': 'Dayanacaqla birlikdə OpenStreetMap-dən tapılıb; köhnəlmiş ola bilər.', + 'tours.walk.hours.sourcePlaceHint': 'Səyahətin bu yeri üçün daxil edilib.', 'tours.poi.categoriesNone': 'Kateqoriya seçilməyib', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tur növü', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 64f74a8ad..03f686aff 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -531,12 +531,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Sugerido para {theme}', 'tours.walk.arrival': 'Chegada por volta de {time}', 'tours.walk.arrivalAtLeast': 'Chegada a partir de {time}', - 'tours.walk.hours.closed': 'Fechado às {arrival} segundo o horário', - 'tours.walk.hours.closes': 'Chegada {arrival}, permanência até {leave}, mas fecha às {closes}', + 'tours.walk.hours.closed': '{name} está fechado às {arrival}, a permanência iria até {leave}; abre {window}', + 'tours.walk.hours.closes': '{name} fecha às {closes}, durante a permanência das {arrival} às {leave} (abre {window})', 'tours.walk.hours.unread': 'Horário não lido aqui: {text}', 'tours.walk.hours.source': - 'Horário como está no OpenStreetMap, comparado à chegada estimada; confira antes de ir. Uma parada sem horário é desconhecida, não aberta.', + 'Horários do OpenStreetMap ou dos seus próprios lugares, comparados com a chegada estimada no horário local do plano; confira antes de ir. Uma parada sem horário é desconhecida, não aberta.', 'tours.walk.hours.noStart': 'Dê ao passeio um horário neste dia para ver chegadas e horários.', + 'tours.walk.day.label': 'Horários para', + 'tours.walk.day.start': '{day}, saída às {time}', + 'tours.walk.day.noStart': '{day}, sem horário de saída', + 'tours.walk.hours.noDate': 'Este dia não tem data, então chegadas e horários de funcionamento ficam de fora.', + 'tours.walk.hours.noDay': + 'Coloque o passeio em um dia com horário de saída para ver chegadas e horários de funcionamento.', + 'tours.walk.hours.closedNoStay': '{name} está fechado às {arrival}; abre {window}', + 'tours.walk.hours.closedAllDay': '{name} fica fechado o dia todo nesta data', + 'tours.walk.hours.open': 'Aberto {window}', + 'tours.walk.hours.window': 'das {from} às {to}', + 'tours.walk.hours.uncertain': 'Horário não verificado: falta a permanência de uma parada anterior.', + 'tours.walk.arrivalNextDay': + 'Chegada por volta de {time} no dia seguinte; horários não são verificados depois da meia-noite.', + 'tours.walk.arrivalStay': 'Chegada por volta de {arrival}, permanência até {leave}', + 'tours.walk.arrivalStayAtLeast': 'Chegada a partir de {arrival}, permanência até {leave} ou mais', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Lugar da viagem', + 'tours.walk.hours.sourceOsmHint': 'Encontrado com a parada no OpenStreetMap; pode estar desatualizado.', + 'tours.walk.hours.sourcePlaceHint': 'Informado para este lugar da viagem.', 'tours.poi.categoriesNone': 'Nenhuma categoria escolhida', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tipo de passeio', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 8689c7c4b..45b89d64a 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -532,12 +532,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Suggerit per a {theme}', 'tours.walk.arrival': 'Arribada cap a les {time}', 'tours.walk.arrivalAtLeast': 'Arribada com a aviat a les {time}', - 'tours.walk.hours.closed': "Tancat a les {arrival} segons l'horari", - 'tours.walk.hours.closes': 'Arribada {arrival}, estada fins a les {leave}, però tanca a les {closes}', + 'tours.walk.hours.closed': "{name} és tancat a les {arrival}, l'estada duraria fins a les {leave}; obre {window}", + 'tours.walk.hours.closes': "{name} tanca a les {closes}, durant l'estada de {arrival} a {leave} (obre {window})", 'tours.walk.hours.unread': 'Horari no llegit aquí: {text}', 'tours.walk.hours.source': - "Horari tal com el té OpenStreetMap, contra l'arribada estimada; comprova-ho abans d'anar-hi. Una parada sense horari és desconeguda, no oberta.", + "Horaris d'OpenStreetMap o dels teus propis llocs, comparats amb l'arribada estimada en l'hora local del pla; comprova'ls abans d'anar-hi. Una parada sense horari és desconeguda, no oberta.", 'tours.walk.hours.noStart': 'Posa una hora a la ruta aquest dia per veure arribades i horaris.', + 'tours.walk.day.label': 'Horaris per a', + 'tours.walk.day.start': '{day}, sortida a les {time}', + 'tours.walk.day.noStart': '{day}, sense hora de sortida', + 'tours.walk.hours.noDate': "Aquest dia no té data, així que s'ometen les arribades i els horaris d'obertura.", + 'tours.walk.hours.noDay': + "Posa la ruta en un dia amb hora de sortida per veure les arribades i els horaris d'obertura.", + 'tours.walk.hours.closedNoStay': '{name} és tancat a les {arrival}; obre {window}', + 'tours.walk.hours.closedAllDay': '{name} és tancat tot el dia en aquesta data', + 'tours.walk.hours.open': 'Obert {window}', + 'tours.walk.hours.window': 'de {from} a {to}', + 'tours.walk.hours.uncertain': "Horari no comprovat: falta l'estada d'una parada anterior.", + 'tours.walk.arrivalNextDay': + "Arribada cap a les {time} de l'endemà; els horaris no es comproven passada la mitjanit.", + 'tours.walk.arrivalStay': 'Arribada cap a les {arrival}, estada fins a les {leave}', + 'tours.walk.arrivalStayAtLeast': 'Arribada com a aviat a les {arrival}, estada fins a les {leave} o més', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Lloc del viatge', + 'tours.walk.hours.sourceOsmHint': 'Trobat amb la parada a OpenStreetMap; pot estar desactualitzat.', + 'tours.walk.hours.sourcePlaceHint': 'Introduït per a aquest lloc del viatge.', 'tours.poi.categoriesNone': 'Cap categoria triada', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tipus de ruta', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 264d0064a..ce8029ddb 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -522,12 +522,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Navrženo pro {theme}', 'tours.walk.arrival': 'Příchod kolem {time}', 'tours.walk.arrivalAtLeast': 'Příchod nejdříve {time}', - 'tours.walk.hours.closed': 'V {arrival} podle otevírací doby zavřeno', - 'tours.walk.hours.closes': 'Příchod {arrival}, pobyt do {leave}, ale zavírá v {closes}', + 'tours.walk.hours.closed': '{name}: v {arrival} zavřeno, pobyt by trval do {leave}; otevřeno {window}', + 'tours.walk.hours.closes': '{name}: zavírá v {closes}, během pobytu od {arrival} do {leave} (otevřeno {window})', 'tours.walk.hours.unread': 'Otevírací dobu zde nelze vyhodnotit: {text}', 'tours.walk.hours.source': - 'Otevírací doba podle OpenStreetMap proti odhadovanému příchodu; před cestou si ji ověř. Zastávka bez časů je neznámá, ne otevřená.', + 'Otevírací doby z OpenStreetMap nebo z tvých vlastních míst, porovnané s odhadovaným příchodem v místním čase plánu; před cestou si je ověř. Zastávka bez časů je neznámá, ne otevřená.', 'tours.walk.hours.noStart': 'Dej procházce v tento den čas, abys viděl příchody a otevírací doby.', + 'tours.walk.day.label': 'Časy pro', + 'tours.walk.day.start': '{day}, začátek {time}', + 'tours.walk.day.noStart': '{day}, bez času začátku', + 'tours.walk.hours.noDate': 'Tento den nemá datum, proto se příchody a otevírací doby nezobrazují.', + 'tours.walk.hours.noDay': 'Dej procházku na den s časem začátku, abys viděl příchody a otevírací doby.', + 'tours.walk.hours.closedNoStay': '{name}: v {arrival} zavřeno; otevřeno {window}', + 'tours.walk.hours.closedAllDay': '{name}: v tento den celý den zavřeno', + 'tours.walk.hours.open': 'Otevřeno {window}', + 'tours.walk.hours.window': 'od {from} do {to}', + 'tours.walk.hours.uncertain': 'Otevírací doba neověřena: u některé dřívější zastávky ještě chybí pobyt.', + 'tours.walk.arrivalNextDay': 'Příchod kolem {time} následující den; otevírací doba se po půlnoci neověřuje.', + 'tours.walk.arrivalStay': 'Příchod kolem {arrival}, pobyt do {leave}', + 'tours.walk.arrivalStayAtLeast': 'Příchod nejdříve {arrival}, pobyt do {leave} nebo déle', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Místo výletu', + 'tours.walk.hours.sourceOsmHint': 'Nalezeno se zastávkou v OpenStreetMap; může být zastaralé.', + 'tours.walk.hours.sourcePlaceHint': 'Zadáno u tohoto místa výletu.', 'tours.poi.categoriesNone': 'Žádná kategorie', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Typ túry', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 0a0578f65..cda6b1b05 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -537,12 +537,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Vorgeschlagen für {theme}', 'tours.walk.arrival': 'Ankunft ca. {time}', 'tours.walk.arrivalAtLeast': 'Ankunft frühestens {time}', - 'tours.walk.hours.closed': 'Um {arrival} laut Öffnungszeiten geschlossen', - 'tours.walk.hours.closes': 'Ankunft {arrival}, Aufenthalt bis {leave}, aber Schließung um {closes}', + 'tours.walk.hours.closed': '{name} ist um {arrival} geschlossen, der Aufenthalt ginge bis {leave}; geöffnet {window}', + 'tours.walk.hours.closes': + '{name} schließt um {closes}, während des Aufenthalts von {arrival} bis {leave} (geöffnet {window})', 'tours.walk.hours.unread': 'Öffnungszeiten hier nicht auswertbar: {text}', 'tours.walk.hours.source': - 'Öffnungszeiten wie bei OpenStreetMap eingetragen, gegen die geschätzte Ankunft; vor Ort lieber prüfen. Eine Station ohne Zeiten ist unbekannt, nicht geöffnet.', + 'Öffnungszeiten aus OpenStreetMap oder aus deinen eigenen Ortsangaben, verglichen mit der geschätzten Ankunft in der Ortszeit des Plans; vor Ort lieber prüfen. Eine Station ohne Zeiten ist unbekannt, nicht geöffnet.', 'tours.walk.hours.noStart': 'Gib dem Walk an diesem Tag eine Uhrzeit, um Ankünfte und Öffnungszeiten zu sehen.', + 'tours.walk.day.label': 'Zeiten für', + 'tours.walk.day.start': '{day}, Beginn {time}', + 'tours.walk.day.noStart': '{day}, ohne Startzeit', + 'tours.walk.hours.noDate': 'Dieser Tag hat kein Datum, deshalb fehlen Ankünfte und Öffnungszeiten.', + 'tours.walk.hours.noDay': 'Lege den Walk auf einen Tag mit Startzeit, um Ankünfte und Öffnungszeiten zu sehen.', + 'tours.walk.hours.closedNoStay': '{name} ist um {arrival} geschlossen; geöffnet {window}', + 'tours.walk.hours.closedAllDay': '{name} ist an diesem Datum den ganzen Tag geschlossen', + 'tours.walk.hours.open': 'Geöffnet {window}', + 'tours.walk.hours.window': '{from} bis {to}', + 'tours.walk.hours.uncertain': 'Öffnungszeiten nicht geprüft: Bei einer Station davor fehlt noch der Aufenthalt.', + 'tours.walk.arrivalNextDay': + 'Ankunft ca. {time} am nächsten Tag; Öffnungszeiten werden über Mitternacht hinaus nicht geprüft.', + 'tours.walk.arrivalStay': 'Ankunft ca. {arrival}, Aufenthalt bis {leave}', + 'tours.walk.arrivalStayAtLeast': 'Ankunft frühestens {arrival}, Aufenthalt bis {leave} oder länger', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Ort der Reise', + 'tours.walk.hours.sourceOsmHint': 'Mit der Station aus OpenStreetMap übernommen; kann veraltet sein.', + 'tours.walk.hours.sourcePlaceHint': 'Für diesen Ort der Reise eingetragen.', 'tours.poi.categoriesNone': 'Keine Kategorie gewählt', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tourtyp', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index d34055012..c685e4c19 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -523,12 +523,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Suggested for {theme}', 'tours.walk.arrival': 'Arrival about {time}', 'tours.walk.arrivalAtLeast': 'Arrival {time} at the earliest', - 'tours.walk.hours.closed': 'Closed at {arrival} by its opening hours', - 'tours.walk.hours.closes': 'Arrival {arrival}, stay until {leave}, but it closes at {closes}', + 'tours.walk.hours.closed': '{name} is closed at {arrival}, the stay would last until {leave}; open {window}', + 'tours.walk.hours.closes': '{name} closes at {closes}, during the stay from {arrival} to {leave} (open {window})', 'tours.walk.hours.unread': 'Opening hours not read here: {text}', 'tours.walk.hours.source': - 'Opening hours as OpenStreetMap has them, against the estimated arrival; check before you go. A stop without hours is unknown, not open.', + 'Opening hours from OpenStreetMap or your own place entries, compared with the estimated arrival in the local time of the plan; check before you go. A stop without hours is unknown, not open.', 'tours.walk.hours.noStart': 'Give the walk a time on this day to see arrivals and opening hours.', + 'tours.walk.day.label': 'Times for', + 'tours.walk.day.start': '{day}, start {time}', + 'tours.walk.day.noStart': '{day}, no start time', + 'tours.walk.hours.noDate': 'This day has no date, so arrivals and opening hours are left out.', + 'tours.walk.hours.noDay': 'Put the walk on a day with a start time to see arrivals and opening hours.', + 'tours.walk.hours.closedNoStay': '{name} is closed at {arrival}; open {window}', + 'tours.walk.hours.closedAllDay': '{name} is closed all day on this date', + 'tours.walk.hours.open': 'Open {window}', + 'tours.walk.hours.window': '{from} to {to}', + 'tours.walk.hours.uncertain': 'Opening hours not checked: a stay before this stop is not entered yet.', + 'tours.walk.arrivalNextDay': 'Arrival about {time} the next day; opening hours are not checked across midnight.', + 'tours.walk.arrivalStay': 'Arrival about {arrival}, stay until {leave}', + 'tours.walk.arrivalStayAtLeast': 'Arrival {arrival} at the earliest, stay until {leave} or later', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Trip place', + 'tours.walk.hours.sourceOsmHint': 'Found with the stop on OpenStreetMap; it may be out of date.', + 'tours.walk.hours.sourcePlaceHint': 'Entered for this place of the trip.', 'tours.poi.categoriesNone': 'No category chosen', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tour type', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 8da751ab4..1a7f45a50 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -530,12 +530,32 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Sugerido para {theme}', 'tours.walk.arrival': 'Llegada hacia las {time}', 'tours.walk.arrivalAtLeast': 'Llegada no antes de las {time}', - 'tours.walk.hours.closed': 'Cerrado a las {arrival} según su horario', - 'tours.walk.hours.closes': 'Llegada {arrival}, estancia hasta las {leave}, pero cierra a las {closes}', + 'tours.walk.hours.closed': + '{name} está cerrado a las {arrival} y la estancia duraría hasta las {leave}; abre {window}', + 'tours.walk.hours.closes': '{name} cierra a las {closes}, durante la estancia de {arrival} a {leave} (abre {window})', 'tours.walk.hours.unread': 'Horario no interpretado aquí: {text}', 'tours.walk.hours.source': - 'Horario tal como figura en OpenStreetMap, frente a la llegada estimada; compruébalo antes de ir. Una parada sin horario es desconocida, no abierta.', + 'Horarios de OpenStreetMap o de tus propios lugares, comparados con la llegada estimada en la hora local del plan; compruébalos antes de ir. Una parada sin horario es desconocida, no abierta.', 'tours.walk.hours.noStart': 'Pon al paseo una hora en este día para ver llegadas y horarios.', + 'tours.walk.day.label': 'Horarios para', + 'tours.walk.day.start': '{day}, salida a las {time}', + 'tours.walk.day.noStart': '{day}, sin hora de salida', + 'tours.walk.hours.noDate': 'Este día no tiene fecha, así que se omiten las llegadas y los horarios de apertura.', + 'tours.walk.hours.noDay': + 'Pon el paseo en un día con hora de salida para ver las llegadas y los horarios de apertura.', + 'tours.walk.hours.closedNoStay': '{name} está cerrado a las {arrival}; abre {window}', + 'tours.walk.hours.closedAllDay': '{name} está cerrado todo el día en esta fecha', + 'tours.walk.hours.open': 'Abierto {window}', + 'tours.walk.hours.window': 'de {from} a {to}', + 'tours.walk.hours.uncertain': 'Horario sin comprobar: falta la estancia de una parada anterior.', + 'tours.walk.arrivalNextDay': + 'Llegada hacia las {time} del día siguiente; los horarios no se comprueban pasada la medianoche.', + 'tours.walk.arrivalStay': 'Llegada hacia las {arrival}, estancia hasta las {leave}', + 'tours.walk.arrivalStayAtLeast': 'Llegada no antes de las {arrival}, estancia hasta las {leave} o más', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Lugar del viaje', + 'tours.walk.hours.sourceOsmHint': 'Encontrado con la parada en OpenStreetMap; puede estar desactualizado.', + 'tours.walk.hours.sourcePlaceHint': 'Introducido para este lugar del viaje.', 'tours.poi.categoriesNone': 'Ninguna categoría elegida', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tipo de ruta', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index ddd519aa9..c37c179b5 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -525,12 +525,30 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Soovitatud teemale {theme}', 'tours.walk.arrival': 'Saabumine umbes {time}', 'tours.walk.arrivalAtLeast': 'Saabumine kõige varem {time}', - 'tours.walk.hours.closed': 'Lahtiolekuaegade järgi kell {arrival} suletud', - 'tours.walk.hours.closes': 'Saabumine {arrival}, peatus kuni {leave}, aga suletakse kell {closes}', + 'tours.walk.hours.closed': '{name} on kell {arrival} suletud, viibimine kestaks kuni {leave}; avatud {window}', + 'tours.walk.hours.closes': '{name} suletakse kell {closes}, viibimise ajal {arrival} kuni {leave} (avatud {window})', 'tours.walk.hours.unread': 'Lahtiolekuaegu siin ei loetud: {text}', 'tours.walk.hours.source': - 'Lahtiolekuajad nagu OpenStreetMapis, võrreldes hinnangulise saabumisega; kontrolli enne minekut. Aegadeta peatus on teadmata, mitte avatud.', + 'Lahtiolekuajad OpenStreetMapist või sinu enda kohtadest, võrreldud hinnangulise saabumisega plaani kohaliku aja järgi; kontrolli enne minekut. Ajata peatus on teadmata, mitte avatud.', 'tours.walk.hours.noStart': 'Anna jalutusele sel päeval kellaaeg, et näha saabumisi ja lahtiolekuaegu.', + 'tours.walk.day.label': 'Ajad päeval', + 'tours.walk.day.start': '{day}, algus {time}', + 'tours.walk.day.noStart': '{day}, algusaeg puudub', + 'tours.walk.hours.noDate': 'Sellel päeval pole kuupäeva, seega saabumisi ja lahtiolekuaegu ei näidata.', + 'tours.walk.hours.noDay': 'Pane jalutus päevale, millel on algusaeg, et näha saabumisi ja lahtiolekuaegu.', + 'tours.walk.hours.closedNoStay': '{name} on kell {arrival} suletud; avatud {window}', + 'tours.walk.hours.closedAllDay': '{name} on sel kuupäeval terve päeva suletud', + 'tours.walk.hours.open': 'Avatud {window}', + 'tours.walk.hours.window': '{from} kuni {to}', + 'tours.walk.hours.uncertain': + 'Lahtiolekuaegu ei kontrollitud: mõne varasema peatuse viibimisaeg on veel sisestamata.', + 'tours.walk.arrivalNextDay': 'Saabumine umbes {time} järgmisel päeval; lahtiolekuaegu üle kesköö ei kontrollita.', + 'tours.walk.arrivalStay': 'Saabumine umbes {arrival}, viibimine kuni {leave}', + 'tours.walk.arrivalStayAtLeast': 'Saabumine kõige varem {arrival}, viibimine kuni {leave} või kauem', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Reisi koht', + 'tours.walk.hours.sourceOsmHint': 'Leitud koos peatusega OpenStreetMapist; võib olla aegunud.', + 'tours.walk.hours.sourcePlaceHint': 'Sisestatud selle reisi koha juurde.', 'tours.poi.categoriesNone': 'Kategooriat pole valitud', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Matka tüüp', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 6a1dc6353..9d1fd74d7 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -540,12 +540,30 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Suggéré pour {theme}', 'tours.walk.arrival': 'Arrivée vers {time}', 'tours.walk.arrivalAtLeast': 'Arrivée au plus tôt à {time}', - 'tours.walk.hours.closed': 'Fermé à {arrival} selon ses horaires', - 'tours.walk.hours.closes': "Arrivée {arrival}, arrêt jusqu'à {leave}, mais fermeture à {closes}", + 'tours.walk.hours.closed': "{name} est fermé à {arrival}, l'arrêt durerait jusqu'à {leave} ; ouvert {window}", + 'tours.walk.hours.closes': "{name} ferme à {closes}, pendant l'arrêt de {arrival} à {leave} (ouvert {window})", 'tours.walk.hours.unread': 'Horaires non lus ici : {text}', 'tours.walk.hours.source': - "Horaires tels qu'indiqués sur OpenStreetMap, face à l'arrivée estimée ; vérifie avant d'y aller. Une étape sans horaires est inconnue, pas ouverte.", + "Horaires d'OpenStreetMap ou de tes propres lieux, comparés à l'arrivée estimée à l'heure locale du plan ; vérifie avant d'y aller. Une étape sans horaires est inconnue, pas ouverte.", 'tours.walk.hours.noStart': 'Donne une heure à la balade ce jour-là pour voir les arrivées et les horaires.', + 'tours.walk.day.label': 'Horaires pour', + 'tours.walk.day.start': '{day}, départ à {time}', + 'tours.walk.day.noStart': '{day}, sans heure de départ', + 'tours.walk.hours.noDate': "Ce jour n'a pas de date : les arrivées et les horaires d'ouverture sont laissés de côté.", + 'tours.walk.hours.noDay': + "Place la balade sur un jour avec une heure de départ pour voir les arrivées et les horaires d'ouverture.", + 'tours.walk.hours.closedNoStay': '{name} est fermé à {arrival} ; ouvert {window}', + 'tours.walk.hours.closedAllDay': '{name} est fermé toute la journée à cette date', + 'tours.walk.hours.open': 'Ouvert {window}', + 'tours.walk.hours.window': 'de {from} à {to}', + 'tours.walk.hours.uncertain': "Horaires non vérifiés : la durée d'un arrêt précédent n'est pas encore saisie.", + 'tours.walk.arrivalNextDay': 'Arrivée vers {time} le lendemain ; les horaires ne sont pas vérifiés après minuit.', + 'tours.walk.arrivalStay': "Arrivée vers {arrival}, arrêt jusqu'à {leave}", + 'tours.walk.arrivalStayAtLeast': "Arrivée au plus tôt à {arrival}, arrêt jusqu'à {leave} ou plus tard", + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Lieu du voyage', + 'tours.walk.hours.sourceOsmHint': "Trouvé avec l'étape sur OpenStreetMap ; peut ne plus être à jour.", + 'tours.walk.hours.sourcePlaceHint': 'Saisi pour ce lieu du voyage.', 'tours.poi.categoriesNone': 'Aucune catégorie choisie', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Type de sortie', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 23882a636..66f2c05f7 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -540,12 +540,30 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Προτείνεται για {theme}', 'tours.walk.arrival': 'Άφιξη περίπου {time}', 'tours.walk.arrivalAtLeast': 'Άφιξη το νωρίτερο {time}', - 'tours.walk.hours.closed': 'Κλειστό στις {arrival} σύμφωνα με το ωράριο', - 'tours.walk.hours.closes': 'Άφιξη {arrival}, παραμονή έως {leave}, αλλά κλείνει στις {closes}', + 'tours.walk.hours.closed': '{name}: κλειστό στις {arrival}, η παραμονή θα κρατούσε έως {leave}, ανοιχτό {window}', + 'tours.walk.hours.closes': + '{name}: κλείνει στις {closes}, κατά την παραμονή από {arrival} έως {leave} (ανοιχτό {window})', 'tours.walk.hours.unread': 'Το ωράριο δεν διαβάστηκε εδώ: {text}', 'tours.walk.hours.source': - 'Ωράριο όπως στο OpenStreetMap, σε σχέση με την εκτιμώμενη άφιξη· έλεγξε πριν πας. Στάση χωρίς ωράριο είναι άγνωστη, όχι ανοιχτή.', + 'Ωράρια από το OpenStreetMap ή από τα δικά σου μέρη, σε σύγκριση με την εκτιμώμενη άφιξη στην τοπική ώρα του πλάνου. Έλεγξε πριν πας. Μια στάση χωρίς ωράριο είναι άγνωστη, όχι ανοιχτή.', 'tours.walk.hours.noStart': 'Δώσε ώρα στη βόλτα αυτή τη μέρα για να δεις αφίξεις και ωράρια.', + 'tours.walk.day.label': 'Ώρες για', + 'tours.walk.day.start': '{day}, έναρξη {time}', + 'tours.walk.day.noStart': '{day}, χωρίς ώρα έναρξης', + 'tours.walk.hours.noDate': 'Αυτή η μέρα δεν έχει ημερομηνία, οπότε οι αφίξεις και τα ωράρια παραλείπονται.', + 'tours.walk.hours.noDay': 'Βάλε τη βόλτα σε μια μέρα με ώρα έναρξης για να δεις αφίξεις και ωράρια.', + 'tours.walk.hours.closedNoStay': '{name}: κλειστό στις {arrival}, ανοιχτό {window}', + 'tours.walk.hours.closedAllDay': '{name}: κλειστό όλη τη μέρα σε αυτή την ημερομηνία', + 'tours.walk.hours.open': 'Ανοιχτό {window}', + 'tours.walk.hours.window': '{from} έως {to}', + 'tours.walk.hours.uncertain': 'Το ωράριο δεν ελέγχθηκε: λείπει ακόμη η παραμονή μιας προηγούμενης στάσης.', + 'tours.walk.arrivalNextDay': 'Άφιξη περίπου {time} την επόμενη μέρα. Τα ωράρια δεν ελέγχονται μετά τα μεσάνυχτα.', + 'tours.walk.arrivalStay': 'Άφιξη περίπου {arrival}, παραμονή έως {leave}', + 'tours.walk.arrivalStayAtLeast': 'Άφιξη το νωρίτερο {arrival}, παραμονή έως {leave} ή αργότερα', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Μέρος του ταξιδιού', + 'tours.walk.hours.sourceOsmHint': 'Βρέθηκε μαζί με τη στάση στο OpenStreetMap. Μπορεί να μην είναι ενημερωμένο.', + 'tours.walk.hours.sourcePlaceHint': 'Καταχωρίστηκε για αυτό το μέρος του ταξιδιού.', 'tours.poi.categoriesNone': 'Δεν επιλέχθηκε κατηγορία', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Τύπος διαδρομής', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 2e2c7519a..ed23c9105 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -532,12 +532,32 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Javasolva: {theme}', 'tours.walk.arrival': 'Érkezés kb. {time}', 'tours.walk.arrivalAtLeast': 'Érkezés legkorábban {time}', - 'tours.walk.hours.closed': '{arrival} órakor a nyitvatartás szerint zárva', - 'tours.walk.hours.closes': 'Érkezés {arrival}, tartózkodás {leave}-ig, de {closes}-kor zár', + 'tours.walk.hours.closed': '{name} {arrival} órakor zárva van, a tartózkodás eddig tartana: {leave}; nyitva {window}', + 'tours.walk.hours.closes': + '{name} {closes} órakor zár, a {arrival} és {leave} közötti tartózkodás alatt (nyitva {window})', 'tours.walk.hours.unread': 'A nyitvatartás itt nem értelmezhető: {text}', 'tours.walk.hours.source': - 'Nyitvatartás az OpenStreetMap szerint, a becsült érkezéshez mérve; indulás előtt ellenőrizd. Nyitvatartás nélküli megálló ismeretlen, nem nyitott.', + 'Nyitvatartás az OpenStreetMapből vagy a saját helyeidből, a becsült érkezéshez mérve a terv helyi ideje szerint; indulás előtt ellenőrizd. Az időpontok nélküli megálló ismeretlen, nem nyitva.', 'tours.walk.hours.noStart': 'Adj időpontot a sétának ezen a napon, hogy lásd az érkezéseket és a nyitvatartást.', + 'tours.walk.day.label': 'Időpontok:', + 'tours.walk.day.start': '{day}, kezdés {time}', + 'tours.walk.day.noStart': '{day}, kezdési idő nélkül', + 'tours.walk.hours.noDate': 'Ennek a napnak nincs dátuma, ezért az érkezések és a nyitvatartás kimaradnak.', + 'tours.walk.hours.noDay': + 'Tedd a sétát egy kezdési időponttal rendelkező napra, hogy lásd az érkezéseket és a nyitvatartást.', + 'tours.walk.hours.closedNoStay': '{name} {arrival} órakor zárva van; nyitva {window}', + 'tours.walk.hours.closedAllDay': '{name} ezen a napon egész nap zárva van', + 'tours.walk.hours.open': 'Nyitva {window}', + 'tours.walk.hours.window': '{from} és {to} között', + 'tours.walk.hours.uncertain': + 'A nyitvatartás nincs ellenőrizve: egy korábbi megálló tartózkodási ideje még hiányzik.', + 'tours.walk.arrivalNextDay': 'Érkezés kb. {time} a következő napon; éjfél után a nyitvatartást nem ellenőrizzük.', + 'tours.walk.arrivalStay': 'Érkezés kb. {arrival}, tartózkodás eddig: {leave}', + 'tours.walk.arrivalStayAtLeast': 'Érkezés legkorábban {arrival}, tartózkodás eddig: {leave} vagy tovább', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Az utazás helye', + 'tours.walk.hours.sourceOsmHint': 'A megállóval együtt az OpenStreetMapből; elavult lehet.', + 'tours.walk.hours.sourcePlaceHint': 'Az utazás ezen helyéhez rögzítve.', 'tours.poi.categoriesNone': 'Nincs kategória kiválasztva', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Túratípus', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 2eddfc9ee..6b85c6b58 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -525,12 +525,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Disarankan untuk {theme}', 'tours.walk.arrival': 'Tiba sekitar {time}', 'tours.walk.arrivalAtLeast': 'Tiba paling cepat {time}', - 'tours.walk.hours.closed': 'Tutup pada {arrival} menurut jam bukanya', - 'tours.walk.hours.closes': 'Tiba {arrival}, singgah sampai {leave}, tetapi tutup pukul {closes}', + 'tours.walk.hours.closed': '{name} tutup pada {arrival}, singgah akan berlangsung sampai {leave}; buka {window}', + 'tours.walk.hours.closes': '{name} tutup pukul {closes}, saat singgah dari {arrival} sampai {leave} (buka {window})', 'tours.walk.hours.unread': 'Jam buka tidak terbaca di sini: {text}', 'tours.walk.hours.source': - 'Jam buka sesuai OpenStreetMap, dibandingkan dengan perkiraan tiba; periksa sebelum berangkat. Titik tanpa jam buka tidak diketahui, bukan buka.', + 'Jam buka dari OpenStreetMap atau dari tempat yang kamu isi sendiri, dibandingkan dengan perkiraan waktu tiba menurut waktu setempat rencana; periksa sebelum berangkat. Titik tanpa jam buka berarti tidak diketahui, bukan buka.', 'tours.walk.hours.noStart': 'Beri rute waktu mulai di hari ini untuk melihat waktu tiba dan jam buka.', + 'tours.walk.day.label': 'Waktu untuk', + 'tours.walk.day.start': '{day}, mulai {time}', + 'tours.walk.day.noStart': '{day}, tanpa waktu mulai', + 'tours.walk.hours.noDate': 'Hari ini tidak punya tanggal, jadi waktu tiba dan jam buka tidak ditampilkan.', + 'tours.walk.hours.noDay': 'Taruh rute pada hari dengan waktu mulai untuk melihat waktu tiba dan jam buka.', + 'tours.walk.hours.closedNoStay': '{name} tutup pada {arrival}; buka {window}', + 'tours.walk.hours.closedAllDay': '{name} tutup seharian pada tanggal ini', + 'tours.walk.hours.open': 'Buka {window}', + 'tours.walk.hours.window': '{from} sampai {to}', + 'tours.walk.hours.uncertain': 'Jam buka tidak diperiksa: lama singgah di titik sebelumnya belum diisi.', + 'tours.walk.arrivalNextDay': 'Tiba sekitar {time} keesokan harinya; jam buka tidak diperiksa lewat tengah malam.', + 'tours.walk.arrivalStay': 'Tiba sekitar {arrival}, singgah sampai {leave}', + 'tours.walk.arrivalStayAtLeast': 'Tiba paling cepat {arrival}, singgah sampai {leave} atau lebih lama', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Tempat perjalanan', + 'tours.walk.hours.sourceOsmHint': 'Ditemukan bersama titik ini di OpenStreetMap; mungkin sudah usang.', + 'tours.walk.hours.sourcePlaceHint': 'Diisi untuk tempat perjalanan ini.', 'tours.poi.categoriesNone': 'Belum ada kategori', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Jenis tur', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index bae4a9e18..1c3e64507 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -531,12 +531,32 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Suggerito per {theme}', 'tours.walk.arrival': 'Arrivo verso le {time}', 'tours.walk.arrivalAtLeast': 'Arrivo non prima delle {time}', - 'tours.walk.hours.closed': 'Chiuso alle {arrival} secondo gli orari', - 'tours.walk.hours.closes': 'Arrivo {arrival}, sosta fino alle {leave}, ma chiude alle {closes}', + 'tours.walk.hours.closed': '{name} è chiuso alle {arrival}, la sosta durerebbe fino alle {leave}; aperto {window}', + 'tours.walk.hours.closes': + '{name} chiude alle {closes}, durante la sosta dalle {arrival} alle {leave} (aperto {window})', 'tours.walk.hours.unread': 'Orari non interpretati qui: {text}', 'tours.walk.hours.source': - "Orari come riportati su OpenStreetMap, rispetto all'arrivo stimato; verifica prima di andare. Una tappa senza orari è sconosciuta, non aperta.", + "Orari da OpenStreetMap o dai tuoi luoghi, confrontati con l'arrivo stimato nell'ora locale del piano; verifica prima di andare. Una tappa senza orari è sconosciuta, non aperta.", 'tours.walk.hours.noStart': 'Dai un orario al percorso in questo giorno per vedere arrivi e orari.', + 'tours.walk.day.label': 'Orari per', + 'tours.walk.day.start': '{day}, partenza alle {time}', + 'tours.walk.day.noStart': '{day}, senza orario di partenza', + 'tours.walk.hours.noDate': 'Questo giorno non ha una data, quindi arrivi e orari di apertura sono omessi.', + 'tours.walk.hours.noDay': + 'Metti il percorso in un giorno con un orario di partenza per vedere arrivi e orari di apertura.', + 'tours.walk.hours.closedNoStay': '{name} è chiuso alle {arrival}; aperto {window}', + 'tours.walk.hours.closedAllDay': '{name} è chiuso tutto il giorno in questa data', + 'tours.walk.hours.open': 'Aperto {window}', + 'tours.walk.hours.window': 'dalle {from} alle {to}', + 'tours.walk.hours.uncertain': 'Orari non verificati: manca ancora la sosta di una tappa precedente.', + 'tours.walk.arrivalNextDay': + 'Arrivo verso le {time} del giorno dopo; gli orari non vengono verificati oltre la mezzanotte.', + 'tours.walk.arrivalStay': 'Arrivo verso le {arrival}, sosta fino alle {leave}', + 'tours.walk.arrivalStayAtLeast': 'Arrivo non prima delle {arrival}, sosta fino alle {leave} o oltre', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Luogo del viaggio', + 'tours.walk.hours.sourceOsmHint': 'Trovato con la tappa su OpenStreetMap; potrebbe non essere aggiornato.', + 'tours.walk.hours.sourcePlaceHint': 'Inserito per questo luogo del viaggio.', 'tours.poi.categoriesNone': 'Nessuna categoria scelta', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tipo di tour', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 74d285e1a..97bfde194 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -516,12 +516,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': '{theme} のおすすめ', 'tours.walk.arrival': '到着 {time} ごろ', 'tours.walk.arrivalAtLeast': '到着は早くても {time}', - 'tours.walk.hours.closed': '営業時間では {arrival} は閉まっています', - 'tours.walk.hours.closes': '{arrival} 到着、{leave} まで滞在予定ですが {closes} に閉まります', + 'tours.walk.hours.closed': '{name} は {arrival} に閉まっています(滞在は {leave} まで)。営業 {window}', + 'tours.walk.hours.closes': '{name} は {closes} に閉まります。{arrival} から {leave} の滞在中です(営業 {window})', 'tours.walk.hours.unread': '営業時間はここでは読み取れません:{text}', 'tours.walk.hours.source': - 'OpenStreetMap の営業時間を推定到着時刻と照合しています。出かける前に確認してください。営業時間のない立ち寄り先は「不明」で、営業中とはみなしません。', + 'OpenStreetMap または自分で入力した場所の営業時間を、計画の現地時刻での推定到着時刻と照合しています。出かける前に確認してください。営業時間のない立ち寄り先は「不明」で、営業中とはみなしません。', 'tours.walk.hours.noStart': 'この日の開始時刻を設定すると、到着時刻と営業時間が表示されます。', + 'tours.walk.day.label': '時刻の基準日', + 'tours.walk.day.start': '{day}、開始 {time}', + 'tours.walk.day.noStart': '{day}、開始時刻なし', + 'tours.walk.hours.noDate': 'この日には日付がないため、到着時刻と営業時間は表示されません。', + 'tours.walk.hours.noDay': '到着時刻と営業時間を見るには、開始時刻のある日に街歩きを入れてください。', + 'tours.walk.hours.closedNoStay': '{name} は {arrival} に閉まっています。営業 {window}', + 'tours.walk.hours.closedAllDay': '{name} はこの日、終日休業です', + 'tours.walk.hours.open': '営業 {window}', + 'tours.walk.hours.window': '{from}~{to}', + 'tours.walk.hours.uncertain': '営業時間は未確認です:前の立ち寄り先の滞在時間がまだ入力されていません。', + 'tours.walk.arrivalNextDay': '翌日の {time} ごろ到着。日付をまたぐ営業時間は確認しません。', + 'tours.walk.arrivalStay': '到着 {arrival} ごろ、{leave} まで滞在', + 'tours.walk.arrivalStayAtLeast': '到着は早くても {arrival}、滞在は {leave} 以降まで', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': '旅行の場所', + 'tours.walk.hours.sourceOsmHint': '立ち寄り先と一緒に OpenStreetMap から取得。古い可能性があります。', + 'tours.walk.hours.sourcePlaceHint': 'この旅行の場所に入力されたものです。', 'tours.poi.categoriesNone': 'カテゴリー未選択', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'ツアーの種類', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 8ec3799a7..45eb58bb6 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -512,12 +512,30 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': '{theme} 추천', 'tours.walk.arrival': '도착 약 {time}', 'tours.walk.arrivalAtLeast': '도착 빨라야 {time}', - 'tours.walk.hours.closed': '영업시간상 {arrival}에는 문을 닫습니다', - 'tours.walk.hours.closes': '{arrival} 도착, {leave}까지 머물 예정이지만 {closes}에 문을 닫습니다', + 'tours.walk.hours.closed': '{name}은(는) {arrival}에 닫혀 있습니다({leave}까지 머물 예정). 영업 {window}', + 'tours.walk.hours.closes': + '{name}은(는) {closes}에 문을 닫습니다. {arrival}부터 {leave}까지 머무는 중입니다(영업 {window})', 'tours.walk.hours.unread': '여기서는 영업시간을 읽지 못했습니다: {text}', 'tours.walk.hours.source': - 'OpenStreetMap의 영업시간을 예상 도착 시간과 비교합니다. 가기 전에 확인하세요. 영업시간이 없는 정류지는 영업 중이 아니라 미상입니다.', + 'OpenStreetMap 또는 직접 입력한 장소의 영업시간을 계획 현지 시간 기준의 예상 도착 시간과 비교합니다. 가기 전에 확인하세요. 영업시간이 없는 정류지는 영업 중이 아니라 미상입니다.', 'tours.walk.hours.noStart': '이날 산책에 시간을 지정하면 도착 시간과 영업시간을 볼 수 있습니다.', + 'tours.walk.day.label': '시간 기준', + 'tours.walk.day.start': '{day}, 시작 {time}', + 'tours.walk.day.noStart': '{day}, 시작 시간 없음', + 'tours.walk.hours.noDate': '이날에는 날짜가 없어 도착 시간과 영업시간을 표시하지 않습니다.', + 'tours.walk.hours.noDay': '도착 시간과 영업시간을 보려면 시작 시간이 있는 날에 산책을 넣으세요.', + 'tours.walk.hours.closedNoStay': '{name}은(는) {arrival}에 닫혀 있습니다. 영업 {window}', + 'tours.walk.hours.closedAllDay': '{name}은(는) 이 날짜에 종일 휴무입니다', + 'tours.walk.hours.open': '영업 {window}', + 'tours.walk.hours.window': '{from}부터 {to}까지', + 'tours.walk.hours.uncertain': '영업시간 미확인: 앞선 정류지의 머무는 시간이 아직 입력되지 않았습니다.', + 'tours.walk.arrivalNextDay': '다음 날 약 {time} 도착. 자정을 넘긴 영업시간은 확인하지 않습니다.', + 'tours.walk.arrivalStay': '도착 약 {arrival}, {leave}까지 머묾', + 'tours.walk.arrivalStayAtLeast': '도착 빨라야 {arrival}, {leave} 이후까지 머묾', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': '여행 장소', + 'tours.walk.hours.sourceOsmHint': '정류지와 함께 OpenStreetMap에서 가져왔습니다. 오래된 정보일 수 있습니다.', + 'tours.walk.hours.sourcePlaceHint': '이 여행 장소에 입력된 시간입니다.', 'tours.poi.categoriesNone': '선택한 카테고리 없음', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': '투어 유형', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index b329bfb88..e0bfd0e99 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -534,12 +534,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Voorgesteld voor {theme}', 'tours.walk.arrival': 'Aankomst rond {time}', 'tours.walk.arrivalAtLeast': 'Aankomst op zijn vroegst {time}', - 'tours.walk.hours.closed': 'Om {arrival} gesloten volgens de openingstijden', - 'tours.walk.hours.closes': 'Aankomst {arrival}, verblijf tot {leave}, maar sluit om {closes}', + 'tours.walk.hours.closed': '{name} is om {arrival} gesloten, het verblijf zou duren tot {leave}; geopend {window}', + 'tours.walk.hours.closes': + '{name} sluit om {closes}, tijdens het verblijf van {arrival} tot {leave} (geopend {window})', 'tours.walk.hours.unread': 'Openingstijden hier niet gelezen: {text}', 'tours.walk.hours.source': - 'Openingstijden zoals in OpenStreetMap, tegen de geschatte aankomst; controleer ze voor je gaat. Een halte zonder tijden is onbekend, niet open.', + 'Openingstijden uit OpenStreetMap of je eigen plekken, vergeleken met de geschatte aankomst in de lokale tijd van het plan; controleer ze voor je gaat. Een halte zonder tijden is onbekend, niet open.', 'tours.walk.hours.noStart': 'Geef de wandeling op deze dag een tijd om aankomsten en openingstijden te zien.', + 'tours.walk.day.label': 'Tijden voor', + 'tours.walk.day.start': '{day}, vertrek {time}', + 'tours.walk.day.noStart': '{day}, geen starttijd', + 'tours.walk.hours.noDate': 'Deze dag heeft geen datum, dus aankomsten en openingstijden blijven weg.', + 'tours.walk.hours.noDay': 'Zet de wandeling op een dag met een starttijd om aankomsten en openingstijden te zien.', + 'tours.walk.hours.closedNoStay': '{name} is om {arrival} gesloten; geopend {window}', + 'tours.walk.hours.closedAllDay': '{name} is op deze datum de hele dag gesloten', + 'tours.walk.hours.open': 'Geopend {window}', + 'tours.walk.hours.window': '{from} tot {to}', + 'tours.walk.hours.uncertain': 'Openingstijden niet gecontroleerd: bij een eerdere halte ontbreekt nog het verblijf.', + 'tours.walk.arrivalNextDay': + 'Aankomst rond {time} de volgende dag; openingstijden worden na middernacht niet gecontroleerd.', + 'tours.walk.arrivalStay': 'Aankomst rond {arrival}, verblijf tot {leave}', + 'tours.walk.arrivalStayAtLeast': 'Aankomst op zijn vroegst {arrival}, verblijf tot {leave} of later', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Plek van de reis', + 'tours.walk.hours.sourceOsmHint': 'Gevonden met de halte op OpenStreetMap; kan verouderd zijn.', + 'tours.walk.hours.sourcePlaceHint': 'Ingevuld voor deze plek van de reis.', 'tours.poi.categoriesNone': 'Geen categorie gekozen', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Soort tocht', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 07d06727f..3aac20801 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -539,12 +539,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Proponowane dla: {theme}', 'tours.walk.arrival': 'Przyjście około {time}', 'tours.walk.arrivalAtLeast': 'Przyjście najwcześniej {time}', - 'tours.walk.hours.closed': 'Według godzin otwarcia o {arrival} zamknięte', - 'tours.walk.hours.closes': 'Przyjście {arrival}, pobyt do {leave}, ale zamknięcie o {closes}', + 'tours.walk.hours.closed': '{name}: o {arrival} zamknięte, pobyt trwałby do {leave}; otwarte {window}', + 'tours.walk.hours.closes': + '{name}: zamknięcie o {closes}, w trakcie pobytu od {arrival} do {leave} (otwarte {window})', 'tours.walk.hours.unread': 'Godziny otwarcia nieodczytane: {text}', 'tours.walk.hours.source': - 'Godziny otwarcia z OpenStreetMap wobec szacowanego przyjścia; sprawdź przed wyjściem. Przystanek bez godzin jest nieznany, nie otwarty.', + 'Godziny otwarcia z OpenStreetMap lub z twoich własnych miejsc, porównane z szacowanym przyjściem w czasie lokalnym planu; sprawdź przed wyjściem. Przystanek bez godzin jest nieznany, nie otwarty.', 'tours.walk.hours.noStart': 'Ustaw godzinę trasy tego dnia, aby zobaczyć przyjścia i godziny otwarcia.', + 'tours.walk.day.label': 'Godziny dla', + 'tours.walk.day.start': '{day}, początek {time}', + 'tours.walk.day.noStart': '{day}, bez godziny rozpoczęcia', + 'tours.walk.hours.noDate': 'Ten dzień nie ma daty, więc przyjścia i godziny otwarcia są pominięte.', + 'tours.walk.hours.noDay': 'Umieść trasę w dniu z godziną rozpoczęcia, aby zobaczyć przyjścia i godziny otwarcia.', + 'tours.walk.hours.closedNoStay': '{name}: o {arrival} zamknięte; otwarte {window}', + 'tours.walk.hours.closedAllDay': '{name}: w tym dniu zamknięte przez cały dzień', + 'tours.walk.hours.open': 'Otwarte {window}', + 'tours.walk.hours.window': 'od {from} do {to}', + 'tours.walk.hours.uncertain': + 'Godzin otwarcia nie sprawdzono: przy jednym z wcześniejszych przystanków brakuje jeszcze czasu pobytu.', + 'tours.walk.arrivalNextDay': 'Przyjście około {time} następnego dnia; godzin otwarcia po północy się nie sprawdza.', + 'tours.walk.arrivalStay': 'Przyjście około {arrival}, pobyt do {leave}', + 'tours.walk.arrivalStayAtLeast': 'Przyjście najwcześniej {arrival}, pobyt do {leave} lub dłużej', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Miejsce podróży', + 'tours.walk.hours.sourceOsmHint': 'Znalezione z przystankiem w OpenStreetMap; może być nieaktualne.', + 'tours.walk.hours.sourcePlaceHint': 'Wpisane dla tego miejsca podróży.', 'tours.poi.categoriesNone': 'Nie wybrano kategorii', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Rodzaj wycieczki', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 9589226e1..74024c86e 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -542,12 +542,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Предложено для темы {theme}', 'tours.walk.arrival': 'Прибытие около {time}', 'tours.walk.arrivalAtLeast': 'Прибытие не раньше {time}', - 'tours.walk.hours.closed': 'По часам работы в {arrival} закрыто', - 'tours.walk.hours.closes': 'Прибытие {arrival}, остановка до {leave}, но закрывается в {closes}', + 'tours.walk.hours.closed': '{name}: в {arrival} закрыто, пребывание длилось бы до {leave}; открыто {window}', + 'tours.walk.hours.closes': + '{name}: закрывается в {closes}, во время пребывания с {arrival} до {leave} (открыто {window})', 'tours.walk.hours.unread': 'Часы работы здесь не разобраны: {text}', 'tours.walk.hours.source': - 'Часы работы как в OpenStreetMap, относительно расчётного прибытия; проверь перед выходом. Остановка без часов неизвестна, а не открыта.', + 'Часы работы из OpenStreetMap или из твоих собственных мест, относительно расчётного прибытия по местному времени плана; проверь перед выходом. Остановка без часов неизвестна, а не открыта.', 'tours.walk.hours.noStart': 'Задай прогулке время в этот день, чтобы видеть прибытия и часы работы.', + 'tours.walk.day.label': 'Время для', + 'tours.walk.day.start': '{day}, начало в {time}', + 'tours.walk.day.noStart': '{day}, без времени начала', + 'tours.walk.hours.noDate': 'У этого дня нет даты, поэтому прибытия и часы работы не показаны.', + 'tours.walk.hours.noDay': 'Поставь прогулку на день со временем начала, чтобы видеть прибытия и часы работы.', + 'tours.walk.hours.closedNoStay': '{name}: в {arrival} закрыто; открыто {window}', + 'tours.walk.hours.closedAllDay': '{name}: в эту дату закрыто весь день', + 'tours.walk.hours.open': 'Открыто {window}', + 'tours.walk.hours.window': 'с {from} до {to}', + 'tours.walk.hours.uncertain': + 'Часы работы не проверены: у одной из предыдущих остановок ещё не указано время пребывания.', + 'tours.walk.arrivalNextDay': 'Прибытие около {time} на следующий день; часы работы после полуночи не проверяются.', + 'tours.walk.arrivalStay': 'Прибытие около {arrival}, пребывание до {leave}', + 'tours.walk.arrivalStayAtLeast': 'Прибытие не раньше {arrival}, пребывание до {leave} или дольше', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Место поездки', + 'tours.walk.hours.sourceOsmHint': 'Найдено вместе с остановкой в OpenStreetMap; может быть устаревшим.', + 'tours.walk.hours.sourcePlaceHint': 'Указано для этого места поездки.', 'tours.poi.categoriesNone': 'Категория не выбрана', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Тип тура', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index b908ec33b..e5ff56a83 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -524,12 +524,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Navrhnuté pre {theme}', 'tours.walk.arrival': 'Príchod okolo {time}', 'tours.walk.arrivalAtLeast': 'Príchod najskôr {time}', - 'tours.walk.hours.closed': 'Podľa otváracích hodín o {arrival} zatvorené', - 'tours.walk.hours.closes': 'Príchod {arrival}, pobyt do {leave}, ale zatvára o {closes}', + 'tours.walk.hours.closed': '{name}: o {arrival} zatvorené, pobyt by trval do {leave}; otvorené {window}', + 'tours.walk.hours.closes': '{name}: zatvára o {closes}, počas pobytu od {arrival} do {leave} (otvorené {window})', 'tours.walk.hours.unread': 'Otváracie hodiny tu nemožno vyhodnotiť: {text}', 'tours.walk.hours.source': - 'Otváracie hodiny podľa OpenStreetMap oproti odhadovanému príchodu; pred cestou si ich over. Zastávka bez časov je neznáma, nie otvorená.', + 'Otváracie hodiny z OpenStreetMap alebo z tvojich vlastných miest, porovnané s odhadovaným príchodom v miestnom čase plánu; pred cestou si ich over. Zastávka bez časov je neznáma, nie otvorená.', 'tours.walk.hours.noStart': 'Daj prechádzke v tento deň čas, aby si videl príchody a otváracie hodiny.', + 'tours.walk.day.label': 'Časy pre', + 'tours.walk.day.start': '{day}, začiatok {time}', + 'tours.walk.day.noStart': '{day}, bez času začiatku', + 'tours.walk.hours.noDate': 'Tento deň nemá dátum, preto sa príchody a otváracie hodiny nezobrazujú.', + 'tours.walk.hours.noDay': 'Daj prechádzku na deň s časom začiatku, aby si videl príchody a otváracie hodiny.', + 'tours.walk.hours.closedNoStay': '{name}: o {arrival} zatvorené; otvorené {window}', + 'tours.walk.hours.closedAllDay': '{name}: v tento deň zatvorené celý deň', + 'tours.walk.hours.open': 'Otvorené {window}', + 'tours.walk.hours.window': 'od {from} do {to}', + 'tours.walk.hours.uncertain': 'Otváracie hodiny neoverené: pri niektorej skoršej zastávke ešte chýba pobyt.', + 'tours.walk.arrivalNextDay': 'Príchod okolo {time} nasledujúci deň; otváracie hodiny sa po polnoci neoverujú.', + 'tours.walk.arrivalStay': 'Príchod okolo {arrival}, pobyt do {leave}', + 'tours.walk.arrivalStayAtLeast': 'Príchod najskôr {arrival}, pobyt do {leave} alebo dlhšie', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Miesto výletu', + 'tours.walk.hours.sourceOsmHint': 'Nájdené so zastávkou v OpenStreetMap; môže byť zastarané.', + 'tours.walk.hours.sourcePlaceHint': 'Zadané pri tomto mieste výletu.', 'tours.poi.categoriesNone': 'Žiadna kategória', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Typ túry', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index a3a610c60..e5bcb0d85 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -523,12 +523,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Föreslaget för {theme}', 'tours.walk.arrival': 'Ankomst runt {time}', 'tours.walk.arrivalAtLeast': 'Ankomst tidigast {time}', - 'tours.walk.hours.closed': 'Stängt kl. {arrival} enligt öppettiderna', - 'tours.walk.hours.closes': 'Ankomst {arrival}, vistelse till {leave}, men stänger kl. {closes}', + 'tours.walk.hours.closed': '{name} är stängt kl. {arrival}, vistelsen skulle vara till {leave}; öppet {window}', + 'tours.walk.hours.closes': '{name} stänger kl. {closes}, under vistelsen {arrival} till {leave} (öppet {window})', 'tours.walk.hours.unread': 'Öppettiderna kunde inte läsas här: {text}', 'tours.walk.hours.source': - 'Öppettider enligt OpenStreetMap, mot beräknad ankomst; kontrollera innan du går. Ett stopp utan tider är okänt, inte öppet.', + 'Öppettider från OpenStreetMap eller dina egna platser, jämförda med beräknad ankomst i planens lokala tid; kontrollera innan du går. Ett stopp utan tider är okänt, inte öppet.', 'tours.walk.hours.noStart': 'Ge promenaden en tid den här dagen för att se ankomster och öppettider.', + 'tours.walk.day.label': 'Tider för', + 'tours.walk.day.start': '{day}, start kl. {time}', + 'tours.walk.day.noStart': '{day}, ingen starttid', + 'tours.walk.hours.noDate': 'Den här dagen har inget datum, så ankomster och öppettider utelämnas.', + 'tours.walk.hours.noDay': 'Lägg promenaden på en dag med starttid för att se ankomster och öppettider.', + 'tours.walk.hours.closedNoStay': '{name} är stängt kl. {arrival}; öppet {window}', + 'tours.walk.hours.closedAllDay': '{name} är stängt hela dagen detta datum', + 'tours.walk.hours.open': 'Öppet {window}', + 'tours.walk.hours.window': '{from} till {to}', + 'tours.walk.hours.uncertain': 'Öppettiderna är inte kontrollerade: vistelsen vid ett tidigare stopp saknas ännu.', + 'tours.walk.arrivalNextDay': 'Ankomst runt {time} nästa dag; öppettider kontrolleras inte efter midnatt.', + 'tours.walk.arrivalStay': 'Ankomst runt {arrival}, vistelse till {leave}', + 'tours.walk.arrivalStayAtLeast': 'Ankomst tidigast {arrival}, vistelse till {leave} eller längre', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Plats på resan', + 'tours.walk.hours.sourceOsmHint': 'Hittad med stoppet på OpenStreetMap; kan vara inaktuell.', + 'tours.walk.hours.sourcePlaceHint': 'Angiven för den här platsen på resan.', 'tours.poi.categoriesNone': 'Ingen kategori vald', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Turtyp', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 72b847118..53f93a0b5 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -504,12 +504,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'แนะนำสำหรับ {theme}', 'tours.walk.arrival': 'ถึงประมาณ {time}', 'tours.walk.arrivalAtLeast': 'ถึงเร็วที่สุด {time}', - 'tours.walk.hours.closed': 'ตามเวลาทำการ ปิดเวลา {arrival}', - 'tours.walk.hours.closes': 'ถึง {arrival} แวะถึง {leave} แต่ปิด {closes}', + 'tours.walk.hours.closed': '{name} ปิดเวลา {arrival} โดยจะแวะถึง {leave} เปิด {window}', + 'tours.walk.hours.closes': '{name} ปิด {closes} ระหว่างแวะตั้งแต่ {arrival} ถึง {leave} (เปิด {window})', 'tours.walk.hours.unread': 'อ่านเวลาทำการไม่ได้: {text}', 'tours.walk.hours.source': - 'เวลาทำการตาม OpenStreetMap เทียบกับเวลาถึงโดยประมาณ โปรดตรวจสอบก่อนไป จุดแวะที่ไม่มีเวลาทำการถือว่าไม่ทราบ ไม่ใช่เปิดอยู่', + 'เวลาทำการจาก OpenStreetMap หรือจากสถานที่ที่คุณใส่เอง เทียบกับเวลาถึงโดยประมาณตามเวลาท้องถิ่นของแผน โปรดตรวจสอบก่อนไป จุดแวะที่ไม่มีเวลาทำการถือว่าไม่ทราบ ไม่ใช่เปิดอยู่', 'tours.walk.hours.noStart': 'กำหนดเวลาให้ทริปในวันนี้เพื่อดูเวลาถึงและเวลาทำการ', + 'tours.walk.day.label': 'เวลาสำหรับ', + 'tours.walk.day.start': '{day} เริ่ม {time}', + 'tours.walk.day.noStart': '{day} ไม่มีเวลาเริ่ม', + 'tours.walk.hours.noDate': 'วันนี้ไม่มีวันที่ จึงไม่แสดงเวลาถึงและเวลาทำการ', + 'tours.walk.hours.noDay': 'ใส่ทริปไว้ในวันที่มีเวลาเริ่มเพื่อดูเวลาถึงและเวลาทำการ', + 'tours.walk.hours.closedNoStay': '{name} ปิดเวลา {arrival} เปิด {window}', + 'tours.walk.hours.closedAllDay': '{name} ปิดทั้งวันในวันที่นี้', + 'tours.walk.hours.open': 'เปิด {window}', + 'tours.walk.hours.window': '{from} ถึง {to}', + 'tours.walk.hours.uncertain': 'ยังไม่ได้ตรวจเวลาทำการ: จุดแวะก่อนหน้ายังไม่ได้ใส่เวลาแวะพัก', + 'tours.walk.arrivalNextDay': 'ถึงประมาณ {time} ของวันถัดไป ไม่ตรวจเวลาทำการข้ามเที่ยงคืน', + 'tours.walk.arrivalStay': 'ถึงประมาณ {arrival} แวะถึง {leave}', + 'tours.walk.arrivalStayAtLeast': 'ถึงเร็วที่สุด {arrival} แวะถึง {leave} หรือนานกว่า', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'สถานที่ในทริป', + 'tours.walk.hours.sourceOsmHint': 'พบพร้อมจุดแวะใน OpenStreetMap ข้อมูลอาจไม่เป็นปัจจุบัน', + 'tours.walk.hours.sourcePlaceHint': 'ใส่ไว้สำหรับสถานที่นี้ในทริป', 'tours.poi.categoriesNone': 'ยังไม่ได้เลือกหมวดหมู่', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'ประเภททริป', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 6f620e710..28d5668ef 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) // placeholder spec; here only the general keys are. const groups = pluralGroups(Object.keys(english)); const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups)); -const GENERAL_KEY_COUNT = 771; +const GENERAL_KEY_COUNT = 788; function literalKeys(source: string): string[] { const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index ca7842a8a..8e9a6161f 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -527,12 +527,31 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': '{theme} için önerildi', 'tours.walk.arrival': 'Varış yaklaşık {time}', 'tours.walk.arrivalAtLeast': 'En erken varış {time}', - 'tours.walk.hours.closed': 'Açılış saatlerine göre {arrival} saatinde kapalı', - 'tours.walk.hours.closes': 'Varış {arrival}, kalış {leave} saatine kadar, ama {closes} saatinde kapanıyor', + 'tours.walk.hours.closed': '{name} {arrival} saatinde kapalı, kalış {leave} saatine kadar sürerdi; açık {window}', + 'tours.walk.hours.closes': + '{name} {closes} saatinde kapanıyor, {arrival} ile {leave} arasındaki kalış sırasında (açık {window})', 'tours.walk.hours.unread': 'Açılış saatleri burada okunamadı: {text}', 'tours.walk.hours.source': - "Açılış saatleri OpenStreetMap'teki gibi, tahmini varışa göre; gitmeden önce kontrol et. Saati olmayan durak bilinmiyordur, açık değildir.", + "Açılış saatleri OpenStreetMap'ten veya kendi yerlerinden, planın yerel saatine göre tahmini varışla karşılaştırılır; gitmeden önce kontrol et. Saati olmayan durak bilinmiyordur, açık değildir.", 'tours.walk.hours.noStart': 'Varışları ve açılış saatlerini görmek için yürüyüşe bu gün bir saat ver.', + 'tours.walk.day.label': 'Saatler:', + 'tours.walk.day.start': '{day}, başlangıç {time}', + 'tours.walk.day.noStart': '{day}, başlangıç saati yok', + 'tours.walk.hours.noDate': 'Bu günün tarihi yok, bu yüzden varışlar ve açılış saatleri gösterilmiyor.', + 'tours.walk.hours.noDay': 'Varışları ve açılış saatlerini görmek için yürüyüşü başlangıç saati olan bir güne koy.', + 'tours.walk.hours.closedNoStay': '{name} {arrival} saatinde kapalı; açık {window}', + 'tours.walk.hours.closedAllDay': '{name} bu tarihte bütün gün kapalı', + 'tours.walk.hours.open': 'Açık {window}', + 'tours.walk.hours.window': '{from} ile {to} arası', + 'tours.walk.hours.uncertain': 'Açılış saatleri kontrol edilmedi: önceki bir durağın kalış süresi henüz girilmedi.', + 'tours.walk.arrivalNextDay': + 'Varış ertesi gün yaklaşık {time}; gece yarısından sonra açılış saatleri kontrol edilmez.', + 'tours.walk.arrivalStay': 'Varış yaklaşık {arrival}, kalış {leave} saatine kadar', + 'tours.walk.arrivalStayAtLeast': 'En erken varış {arrival}, kalış {leave} saatine kadar veya daha uzun', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Seyahat yeri', + 'tours.walk.hours.sourceOsmHint': "Durakla birlikte OpenStreetMap'ten bulundu; güncel olmayabilir.", + 'tours.walk.hours.sourcePlaceHint': 'Seyahatin bu yeri için girildi.', 'tours.poi.categoriesNone': 'Kategori seçilmedi', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Tur türü', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 087933852..7c6c09e26 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -538,12 +538,30 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Запропоновано для теми {theme}', 'tours.walk.arrival': 'Прибуття близько {time}', 'tours.walk.arrivalAtLeast': 'Прибуття не раніше {time}', - 'tours.walk.hours.closed': 'За годинами роботи о {arrival} зачинено', - 'tours.walk.hours.closes': 'Прибуття {arrival}, перебування до {leave}, але зачиняється о {closes}', + 'tours.walk.hours.closed': '{name}: о {arrival} зачинено, перебування тривало б до {leave}; відчинено {window}', + 'tours.walk.hours.closes': + '{name}: зачиняється о {closes}, під час перебування з {arrival} до {leave} (відчинено {window})', 'tours.walk.hours.unread': 'Години роботи тут не розібрано: {text}', 'tours.walk.hours.source': - 'Години роботи як в OpenStreetMap, відносно розрахункового прибуття; перевір перед виходом. Зупинка без годин невідома, а не відкрита.', + 'Години роботи з OpenStreetMap або з твоїх власних місць, відносно розрахункового прибуття за місцевим часом плану; перевір перед виходом. Зупинка без годин невідома, а не відкрита.', 'tours.walk.hours.noStart': 'Задай прогулянці час цього дня, щоб бачити прибуття й години роботи.', + 'tours.walk.day.label': 'Час для', + 'tours.walk.day.start': '{day}, початок о {time}', + 'tours.walk.day.noStart': '{day}, без часу початку', + 'tours.walk.hours.noDate': 'Цей день не має дати, тому прибуття й години роботи не показано.', + 'tours.walk.hours.noDay': 'Постав прогулянку на день із часом початку, щоб бачити прибуття й години роботи.', + 'tours.walk.hours.closedNoStay': '{name}: о {arrival} зачинено; відчинено {window}', + 'tours.walk.hours.closedAllDay': '{name}: цієї дати зачинено весь день', + 'tours.walk.hours.open': 'Відчинено {window}', + 'tours.walk.hours.window': 'з {from} до {to}', + 'tours.walk.hours.uncertain': 'Години роботи не перевірено: в одній із попередніх зупинок ще не вказано перебування.', + 'tours.walk.arrivalNextDay': 'Прибуття близько {time} наступного дня; години роботи після опівночі не перевіряються.', + 'tours.walk.arrivalStay': 'Прибуття близько {arrival}, перебування до {leave}', + 'tours.walk.arrivalStayAtLeast': 'Прибуття не раніше {arrival}, перебування до {leave} або довше', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Місце подорожі', + 'tours.walk.hours.sourceOsmHint': 'Знайдено разом із зупинкою в OpenStreetMap; може бути застарілим.', + 'tours.walk.hours.sourcePlaceHint': 'Вказано для цього місця подорожі.', 'tours.poi.categoriesNone': 'Категорію не вибрано', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Тип туру', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index e43bd4243..d1635b139 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -523,12 +523,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': 'Gợi ý cho {theme}', 'tours.walk.arrival': 'Đến khoảng {time}', 'tours.walk.arrivalAtLeast': 'Đến sớm nhất lúc {time}', - 'tours.walk.hours.closed': 'Theo giờ mở cửa, lúc {arrival} đóng cửa', - 'tours.walk.hours.closes': 'Đến {arrival}, ở đến {leave}, nhưng đóng cửa lúc {closes}', + 'tours.walk.hours.closed': '{name} đóng cửa lúc {arrival}, thời gian ở sẽ kéo dài đến {leave}; mở {window}', + 'tours.walk.hours.closes': '{name} đóng cửa lúc {closes}, trong lúc ở từ {arrival} đến {leave} (mở {window})', 'tours.walk.hours.unread': 'Không đọc được giờ mở cửa ở đây: {text}', 'tours.walk.hours.source': - 'Giờ mở cửa theo OpenStreetMap, so với giờ đến ước tính; hãy kiểm tra trước khi đi. Điểm dừng không có giờ là chưa rõ, không phải đang mở.', + 'Giờ mở cửa từ OpenStreetMap hoặc từ các địa điểm bạn tự nhập, so với giờ đến ước tính theo giờ địa phương của kế hoạch; hãy kiểm tra trước khi đi. Điểm dừng không có giờ là chưa rõ, không phải đang mở.', 'tours.walk.hours.noStart': 'Đặt giờ cho lộ trình trong ngày này để xem giờ đến và giờ mở cửa.', + 'tours.walk.day.label': 'Thời gian theo', + 'tours.walk.day.start': '{day}, bắt đầu {time}', + 'tours.walk.day.noStart': '{day}, chưa có giờ bắt đầu', + 'tours.walk.hours.noDate': 'Ngày này chưa có ngày tháng nên không hiển thị giờ đến và giờ mở cửa.', + 'tours.walk.hours.noDay': 'Đặt lộ trình vào một ngày có giờ bắt đầu để xem giờ đến và giờ mở cửa.', + 'tours.walk.hours.closedNoStay': '{name} đóng cửa lúc {arrival}; mở {window}', + 'tours.walk.hours.closedAllDay': '{name} đóng cửa cả ngày vào ngày này', + 'tours.walk.hours.open': 'Mở {window}', + 'tours.walk.hours.window': 'từ {from} đến {to}', + 'tours.walk.hours.uncertain': 'Chưa kiểm tra giờ mở cửa: một điểm dừng trước đó chưa nhập thời gian ở.', + 'tours.walk.arrivalNextDay': 'Đến khoảng {time} ngày hôm sau; giờ mở cửa không được kiểm tra qua nửa đêm.', + 'tours.walk.arrivalStay': 'Đến khoảng {arrival}, ở đến {leave}', + 'tours.walk.arrivalStayAtLeast': 'Đến sớm nhất lúc {arrival}, ở đến {leave} hoặc lâu hơn', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': 'Địa điểm của chuyến đi', + 'tours.walk.hours.sourceOsmHint': 'Tìm thấy cùng điểm dừng trên OpenStreetMap; có thể đã cũ.', + 'tours.walk.hours.sourcePlaceHint': 'Được nhập cho địa điểm này của chuyến đi.', 'tours.poi.categoriesNone': 'Chưa chọn danh mục', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': 'Loại chuyến', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 6efc4754e..c8e3bc28a 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -489,12 +489,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': '推薦用於{theme}', 'tours.walk.arrival': '約 {time} 抵達', 'tours.walk.arrivalAtLeast': '最早 {time} 抵達', - 'tours.walk.hours.closed': '按營業時間,{arrival} 時已關門', - 'tours.walk.hours.closes': '{arrival} 抵達,停留至 {leave},但 {closes} 關門', + 'tours.walk.hours.closed': '{name} 在 {arrival} 時未營業,停留將持續到 {leave};營業時間 {window}', + 'tours.walk.hours.closes': '{name} 在 {closes} 關門,正值 {arrival} 至 {leave} 的停留期間(營業時間 {window})', 'tours.walk.hours.unread': '此處無法解析營業時間:{text}', 'tours.walk.hours.source': - '營業時間以 OpenStreetMap 為準,並與預計抵達時間對照;出發前請再確認。沒有營業時間的站點視為未知,而非營業中。', + '營業時間來自 OpenStreetMap 或你自己填寫的地點,並按計畫的當地時間與預計抵達時間對照;出發前請再確認。沒有營業時間的站點視為未知,而非營業中。', 'tours.walk.hours.noStart': '為當天的漫步設定時間,即可查看抵達時間和營業時間。', + 'tours.walk.day.label': '時間依據', + 'tours.walk.day.start': '{day},{time} 出發', + 'tours.walk.day.noStart': '{day},未設出發時間', + 'tours.walk.hours.noDate': '這一天沒有日期,因此不顯示抵達時間和營業時間。', + 'tours.walk.hours.noDay': '把漫步放到有出發時間的某一天,即可查看抵達時間和營業時間。', + 'tours.walk.hours.closedNoStay': '{name} 在 {arrival} 時未營業;營業時間 {window}', + 'tours.walk.hours.closedAllDay': '{name} 在這一天全天未營業', + 'tours.walk.hours.open': '營業 {window}', + 'tours.walk.hours.window': '{from} 至 {to}', + 'tours.walk.hours.uncertain': '未核對營業時間:之前某個站點的停留時間尚未填寫。', + 'tours.walk.arrivalNextDay': '約在隔天 {time} 抵達;跨過午夜的營業時間不作核對。', + 'tours.walk.arrivalStay': '約 {arrival} 抵達,停留至 {leave}', + 'tours.walk.arrivalStayAtLeast': '最早 {arrival} 抵達,停留至 {leave} 或更晚', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': '行程地點', + 'tours.walk.hours.sourceOsmHint': '隨站點從 OpenStreetMap 取得,可能已過時。', + 'tours.walk.hours.sourcePlaceHint': '為行程中的這個地點填寫。', 'tours.poi.categoriesNone': '未選擇類別', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': '路線類型', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index a3fdf2da3..4263b45f4 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -490,12 +490,29 @@ const tours: TranslationStrings = { 'tours.walk.themeReason': '推荐用于{theme}', 'tours.walk.arrival': '约 {time} 到达', 'tours.walk.arrivalAtLeast': '最早 {time} 到达', - 'tours.walk.hours.closed': '按营业时间,{arrival} 时已关门', - 'tours.walk.hours.closes': '{arrival} 到达,停留至 {leave},但 {closes} 关门', + 'tours.walk.hours.closed': '{name} 在 {arrival} 时不营业,停留将持续到 {leave};营业时间 {window}', + 'tours.walk.hours.closes': '{name} 在 {closes} 关门,正值 {arrival} 至 {leave} 的停留期间(营业时间 {window})', 'tours.walk.hours.unread': '此处无法解析营业时间:{text}', 'tours.walk.hours.source': - '营业时间以 OpenStreetMap 为准,并与预计到达时间对照;出发前请再确认。没有营业时间的站点视为未知,而非营业中。', + '营业时间来自 OpenStreetMap 或你自己填写的地点,并按计划的当地时间与预计到达时间对照;出发前请再确认。没有营业时间的站点视为未知,而非营业中。', 'tours.walk.hours.noStart': '为当天的漫步设定时间,即可查看到达时间和营业时间。', + 'tours.walk.day.label': '时间依据', + 'tours.walk.day.start': '{day},{time} 出发', + 'tours.walk.day.noStart': '{day},未设出发时间', + 'tours.walk.hours.noDate': '这一天没有日期,因此不显示到达时间和营业时间。', + 'tours.walk.hours.noDay': '把漫步放到有出发时间的某一天,即可查看到达时间和营业时间。', + 'tours.walk.hours.closedNoStay': '{name} 在 {arrival} 时不营业;营业时间 {window}', + 'tours.walk.hours.closedAllDay': '{name} 在这一天全天不营业', + 'tours.walk.hours.open': '营业 {window}', + 'tours.walk.hours.window': '{from} 至 {to}', + 'tours.walk.hours.uncertain': '未核对营业时间:之前某个站点的停留时间尚未填写。', + 'tours.walk.arrivalNextDay': '约在次日 {time} 到达;跨过午夜的营业时间不作核对。', + 'tours.walk.arrivalStay': '约 {arrival} 到达,停留至 {leave}', + 'tours.walk.arrivalStayAtLeast': '最早 {arrival} 到达,停留至 {leave} 或更晚', + 'tours.walk.hours.sourceOsm': 'OpenStreetMap', + 'tours.walk.hours.sourcePlace': '行程地点', + 'tours.walk.hours.sourceOsmHint': '随站点从 OpenStreetMap 获取,可能已过时。', + 'tours.walk.hours.sourcePlaceHint': '为行程中的这个地点填写。', 'tours.poi.categoriesNone': '未选择类别', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': '路线类型', diff --git a/shared/src/index.ts b/shared/src/index.ts index 83b6dc966..199c571a9 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -61,6 +61,7 @@ export * from './tours/tour-poi-search.schema'; export * from './tours/tour-trail-grade.schema'; export * from './tours/tour-walk.schema'; export * from './tours/tour-walk'; +export * from './tours/walk-hours'; export * from './tours/tours.metrics'; export * from './tours/tour-gpx-import.schema'; export * from './tours/paddle-plan.schema'; diff --git a/shared/src/tours/walk-hours.spec.ts b/shared/src/tours/walk-hours.spec.ts new file mode 100644 index 000000000..390e86f4a --- /dev/null +++ b/shared/src/tours/walk-hours.spec.ts @@ -0,0 +1,278 @@ +import { + clockOf, + minutesOf, + openWindows, + placeWindows, + stationArrivals, + stationFit, + stationHoursOn, + type StationHours, +} from './walk-hours'; + +import { describe, expect, it } from 'vitest'; + +// 2026-10-19 is a Monday. +const MONDAY = '2026-10-19'; +const TUESDAY = '2026-10-20'; +const WEDNESDAY = '2026-10-21'; +const THURSDAY = '2026-10-22'; +const FRIDAY = '2026-10-23'; +const SATURDAY = '2026-10-24'; +const SUNDAY = '2026-10-25'; + +const at = (clock: string, atLeast = false) => ({ arrival: minutesOf(clock) ?? Number.NaN, atLeast }); +const osm = (windows: Array<[number, number]>): StationHours => ({ source: 'osm', windows }); + +describe('minutesOf and clockOf', () => { + it('reads a time as TREK stores it, with or without seconds or a leading zero', () => { + expect(minutesOf('09:30')).toBe(570); + expect(minutesOf('9:30')).toBe(570); + expect(minutesOf(' 17:20:45 ')).toBe(1040); + expect(minutesOf('00:00')).toBe(0); + }); + + it('reads a time typed in twelve hours', () => { + expect(minutesOf('9:30 PM')).toBe(1290); + expect(minutesOf('9:30pm')).toBe(1290); + expect(minutesOf('12:00 AM')).toBe(0); + expect(minutesOf('12:15 PM')).toBe(735); + expect(minutesOf('7 AM')).toBe(420); + }); + + it('reads nothing that is no time of a day', () => { + for (const text of ['24:00', '9:60', '13:00 PM', '0:30 AM', 'noon', '', null, undefined]) { + expect(minutesOf(text)).toBeNull(); + } + }); + + it('writes minutes after midnight as HH:MM, round the day', () => { + expect(clockOf(0)).toBe('00:00'); + expect(clockOf(1290)).toBe('21:30'); + expect(clockOf(570.4)).toBe('09:30'); + expect(clockOf(1440)).toBe('00:00'); + expect(clockOf(1500)).toBe('01:00'); + expect(clockOf(-30)).toBe('23:30'); + }); +}); + +describe('openWindows (OpenStreetMap)', () => { + const week = 'Mo-Fr 09:00-18:00; Sa 10:00-14:00; Su off'; + + it('reads the plain forms for the weekday of the date', () => { + expect(openWindows(week, MONDAY)).toEqual([[540, 1080]]); + expect(openWindows(week, SATURDAY)).toEqual([[600, 840]]); + expect(openWindows(week, SUNDAY)).toEqual([]); + expect(openWindows('24/7', TUESDAY)).toEqual([[0, 1440]]); + expect(openWindows('Mo 10:00-12:00,14:00-18:00;', MONDAY)).toEqual([ + [600, 720], + [840, 1080], + ]); + expect(openWindows('Mo 10:00-24:00', MONDAY)).toEqual([[600, 1440]]); + }); + + it('lets a later rule replace an earlier one, and a range wrap round the week', () => { + expect(openWindows('Mo-Su 09:00-18:00; We 12:00-14:00', WEDNESDAY)).toEqual([[720, 840]]); + expect(openWindows('Mo-Su 09:00-18:00; We closed', WEDNESDAY)).toEqual([]); + expect(openWindows('Sa-Mo 10:00-16:00', MONDAY)).toEqual([[600, 960]]); + expect(openWindows('Sa-Mo 10:00-16:00', WEDNESDAY)).toEqual([]); + }); + + it('leaves anything else unread, rather than guessed', () => { + for (const text of [ + 'PH off', + 'Mo-Fr 09:00-18:00; PH off', + 'Mo 18:00-02:00', + 'sunrise-sunset', + '09:00-18:00', + 'Mo-Fr-Sa 09:00-18:00', + 'Mo 09:00-12:00-14:00', + 'Mo 12:00-10:00', + 'Mo', + ]) { + expect(openWindows(text, MONDAY)).toBeNull(); + } + expect(openWindows(week, '19.10.2026')).toBeNull(); + }); +}); + +describe("placeWindows (a trip place's own hours, Monday first)", () => { + const week = JSON.stringify([ + { closed: false, open: '10:00', close: '18:00' }, + { closed: true }, + { closed: false }, + { closed: false, open: '10:00' }, + { closed: false, open: '18:00', close: '02:00' }, + { closed: false, open: '09:00', close: '13:00' }, + { closed: true }, + ]); + + it('gives the window of the weekday, none on a closed day', () => { + expect(placeWindows(week, MONDAY)).toEqual([[600, 1080]]); + expect(placeWindows(week, TUESDAY)).toEqual([]); + expect(placeWindows(week, SUNDAY)).toEqual([]); + }); + + it('runs a closing at or before the opening past midnight into the next date', () => { + expect(placeWindows(week, FRIDAY)).toEqual([[1080, 1440]]); + expect(placeWindows(week, SATURDAY)).toEqual([ + [0, 120], + [540, 780], + ]); + const sundayNight = JSON.stringify([ + { closed: true }, + ...Array.from({ length: 5 }, () => ({ closed: true })), + { closed: false, open: '22:00', close: '01:00' }, + ]); + expect(placeWindows(sundayNight, MONDAY)).toEqual([[0, 60]]); + const allDay = JSON.stringify(Array.from({ length: 7 }, () => ({ closed: false, open: '00:00', close: '00:00' }))); + expect(placeWindows(allDay, TUESDAY)).toEqual([[0, 1440]]); + }); + + it('says nothing for a day without both times, without hours, or for a date it cannot read', () => { + expect(placeWindows(week, WEDNESDAY)).toBeNull(); + expect(placeWindows(week, THURSDAY)).toBeNull(); + expect(placeWindows(null, MONDAY)).toBeNull(); + expect(placeWindows('', MONDAY)).toBeNull(); + expect(placeWindows('{broken', MONDAY)).toBeNull(); + expect(placeWindows(week, 'someday')).toBeNull(); + }); +}); + +describe('stationHoursOn', () => { + const place = JSON.stringify([ + { closed: false, open: '11:00', close: '17:00' }, + ...Array.from({ length: 6 }, () => ({ closed: false })), + ]); + + it("takes the trip place's own hours first, with their source", () => { + expect(stationHoursOn({ place, osm: 'Mo-Fr 09:00-18:00' }, MONDAY)).toEqual({ + source: 'place', + windows: [[660, 1020]], + }); + }); + + it('falls back to OpenStreetMap where the place says nothing that day', () => { + expect(stationHoursOn({ place, osm: 'Mo-Fr 09:00-18:00' }, TUESDAY)).toEqual({ + source: 'osm', + windows: [[540, 1080]], + }); + expect(stationHoursOn({ osm: 'sunrise-sunset' }, TUESDAY)).toEqual({ source: 'osm', unread: 'sunrise-sunset' }); + }); + + it('is null where neither says anything', () => { + expect(stationHoursOn({ place, osm: ' ' }, TUESDAY)).toBeNull(); + expect(stationHoursOn({}, MONDAY)).toBeNull(); + }); +}); + +describe('stationFit', () => { + const museum = osm([[600, 1080]]); + + it('names a closing during the stay: arrival 17:20, a stay until 18:20 and closing at 18:00', () => { + expect(stationFit(museum, at('17:20'), 60)).toEqual({ kind: 'closes', source: 'osm', window: [600, 1080] }); + }); + + it('is open from the arrival to the end of the stay, and checks the arrival alone without a stay', () => { + expect(stationFit(museum, at('16:30'), 90)).toEqual({ kind: 'open', source: 'osm', window: [600, 1080] }); + expect(stationFit(museum, at('17:59'), 0)).toEqual({ kind: 'open', source: 'osm', window: [600, 1080] }); + expect(stationFit(museum, at('17:50'), null)).toEqual({ kind: 'open', source: 'osm', window: [600, 1080] }); + }); + + it('is closed at an arrival outside the windows, with the windows of the day', () => { + expect(stationFit(museum, at('09:00'), 30)).toEqual({ kind: 'closed', source: 'osm', windows: [[600, 1080]] }); + expect(stationFit(museum, at('18:00'), null)).toEqual({ kind: 'closed', source: 'osm', windows: [[600, 1080]] }); + expect(stationFit({ source: 'place', windows: [] }, at('12:00'), 30)).toEqual({ + kind: 'closedAllDay', + source: 'place', + }); + }); + + it('takes a window running to midnight as open past it', () => { + expect(stationFit(osm([[600, 1440]]), at('23:20'), 90).kind).toBe('open'); + }); + + it('says nothing for hours not known, and gives unread hours back as written', () => { + expect(stationFit(null, at('12:00'), 30)).toEqual({ kind: 'unknown' }); + expect(stationFit({ source: 'osm', unread: 'PH off' }, at('12:00'), 30)).toEqual({ + kind: 'unread', + text: 'PH off', + }); + }); + + it('checks no arrival at or after midnight against the next day', () => { + expect(stationFit(museum, { arrival: 1440, atLeast: false }, 30)).toEqual({ kind: 'nextDay' }); + expect(stationFit(null, { arrival: 1510, atLeast: true }, null)).toEqual({ kind: 'nextDay' }); + }); + + it('for an arrival that may come later, names only a conflict no later arrival escapes', () => { + const lunch = osm([ + [600, 720], + [840, 1080], + ]); + expect(stationFit(museum, at('09:00', true), 30)).toEqual({ kind: 'uncertain', source: 'osm' }); + expect(stationFit(museum, at('12:00', true), 30)).toEqual({ kind: 'uncertain', source: 'osm' }); + expect(stationFit(museum, at('17:20', true), 60).kind).toBe('closes'); + expect(stationFit(museum, at('18:30', true), 30).kind).toBe('closed'); + expect(stationFit(lunch, at('11:30', true), 60)).toEqual({ kind: 'uncertain', source: 'osm' }); + expect(stationFit(lunch, at('13:00', true), 60)).toEqual({ kind: 'uncertain', source: 'osm' }); + expect(stationFit(osm([]), at('13:00', true), 60).kind).toBe('closedAllDay'); + }); +}); + +describe('stationArrivals', () => { + const line: Array<[number, number]> = [ + [48, 11], + [48.001, 11], + [48.002, 11], + ]; + const stop = (lat: number, lng: number, stayMinutes?: number | null) => ({ lat, lng, stayMinutes }); + + it('shares the walking time out by distance and adds the stays before each stop', () => { + const stops = [stop(48, 11, 90), stop(48.001, 11, 30), stop(48.002, 11, 15)]; + expect(stationArrivals(stops, line, 600, 40)).toEqual([ + { arrival: 600, atLeast: false }, + { arrival: 710, atLeast: false }, + { arrival: 760, atLeast: false }, + ]); + }); + + it('makes every arrival after a stay not known a lower bound; a stay of 0 is known', () => { + expect(stationArrivals([stop(48, 11), stop(48.001, 11, 30), stop(48.002, 11)], line, 600, 40)).toEqual([ + { arrival: 600, atLeast: false }, + { arrival: 620, atLeast: true }, + { arrival: 670, atLeast: true }, + ]); + expect(stationArrivals([stop(48, 11, 0), stop(48.002, 11, null)], line, 600, 40)).toEqual([ + { arrival: 600, atLeast: false }, + { arrival: 640, atLeast: false }, + ]); + }); + + it("gives a loop's end the end of the walk, not its start", () => { + const loop: Array<[number, number]> = [ + [48, 11], + [48.001, 11], + [48.001, 11.0015], + [48, 11.0015], + [48, 11], + ]; + const arrivals = stationArrivals([stop(48, 11, 0), stop(48.001, 11.0015, 0), stop(48, 11, 0)], loop, 540, 60); + expect(arrivals?.map((arrival) => arrival.arrival)).toEqual([540, 570, 600]); + }); + + it('matches a stop passed out and back where the walk passes it in its turn', () => { + const outAndBack: Array<[number, number]> = [...line, [48.001, 11], [48, 11]]; + const back = stationArrivals([stop(48, 11, 0), stop(48.002, 11, 0), stop(48.001, 11, 0)], outAndBack, 600, 40); + expect(back?.map((arrival) => arrival.arrival)).toEqual([600, 620, 630]); + const out = stationArrivals([stop(48, 11, 0), stop(48.001, 11, 0), stop(48.002, 11, 0)], outAndBack, 600, 40); + expect(out?.map((arrival) => arrival.arrival)).toEqual([600, 610, 620]); + }); + + it('is null without a start, a walking time or a route', () => { + const stops = [stop(48, 11, 10)]; + expect(stationArrivals(stops, line, null, 40)).toBeNull(); + expect(stationArrivals(stops, line, 600, null)).toBeNull(); + expect(stationArrivals(stops, [[48, 11]], 600, 40)).toBeNull(); + expect(stationArrivals([], line, 600, 40)).toEqual([]); + }); +}); diff --git a/shared/src/tours/walk-hours.ts b/shared/src/tours/walk-hours.ts new file mode 100644 index 000000000..b122f348b --- /dev/null +++ b/shared/src/tours/walk-hours.ts @@ -0,0 +1,321 @@ +import { parsePlaceHours, type PlaceHoursDay } from '../place/place-hours'; + +/** + * CW-02, CW-04: a city walk's arrivals at its stops and what their opening + * hours say about them on one day, worked out the same in the app and on the + * server. + * + * TREK keeps no time zone on a trip or a day: a start time, an arrival and + * opening hours are all the wall clock where the walk takes place, so they are + * compared as they are written. An arrival needs a date and a start time; one + * at or after midnight is not checked against the next day. + * + * Hours come from the trip place a stop stands for (its own entry, seven days + * from Monday) or from OpenStreetMap, read only in its 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 stop without hours is unknown, never open. + */ + +const DAY_MINUTES = 24 * 60; +const OSM_DAYS: readonly string[] = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; +const CLOCK = /^(\d{1,2}):(\d{2})(?::\d{2})?$/; +const MERIDIEM = /^(\d{1,2})(?::(\d{2}))?\s?([AaPp])[Mm]$/; +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** A span a place is open, in minutes after midnight; the end may be 1440, midnight. */ +export type HoursWindow = readonly [number, number]; + +/** Where a stop's hours come from: OpenStreetMap, or the trip place's own entry. */ +export type HoursSource = 'osm' | 'place'; + +/** A stop's hours on one date: its open windows (none when closed all day), or text that could not be read. */ +export type StationHours = { source: HoursSource; windows: readonly HoursWindow[] } | { source: 'osm'; unread: string }; + +function clockMinutes(hours: number, minutes: number): number | null { + return hours < 24 && minutes < 60 ? hours * 60 + minutes : null; +} + +/** Minutes after midnight of a time as TREK stores it (`H:mm`, `HH:mm`, `HH:mm:ss`) or typed in 12 hours (`9:30 PM`). */ +export function minutesOf(time: string | null | undefined): number | null { + const text = (time ?? '').trim(); + const clock = CLOCK.exec(text); + if (clock) return clockMinutes(Number(clock[1]), Number(clock[2])); + const meridiem = MERIDIEM.exec(text); + if (!meridiem) return null; + const hours = Number(meridiem[1]); + if (hours < 1 || hours > 12) return null; + const afternoon = meridiem[3]?.toLowerCase() === 'p'; + return clockMinutes((hours % 12) + (afternoon ? 12 : 0), Number(meridiem[2] ?? 0)); +} + +/** `HH:MM` of minutes after midnight, wrapping round a day. */ +export function clockOf(minutes: number): string { + const day = ((Math.round(minutes) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; + return `${String(Math.floor(day / 60)).padStart(2, '0')}:${String(day % 60).padStart(2, '0')}`; +} + +/** Monday 0 to Sunday 6, of a `YYYY-MM-DD` date read as a calendar day. */ +function weekdayOf(date: string): number | null { + const match = ISO_DATE.exec(date); + if (!match) return null; + const day = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))).getUTCDay(); + return Number.isNaN(day) ? null : (day + 6) % 7; +} + +/** The weekdays of an OpenStreetMap day list such as `Mo-Fr,Su`; a range may wrap round the week (`Sa-Mo`). */ +function daysOf(spec: string): Set | null { + const days = new Set(); + for (const part of spec.split(',')) { + const [from = '', to, ...rest] = part.split('-'); + const start = OSM_DAYS.indexOf(from); + const end = to === undefined ? start : OSM_DAYS.indexOf(to); + if (start < 0 || end < 0 || rest.length > 0) return null; + for (let day = start; ; day = (day + 1) % 7) { + days.add(day); + if (day === end) break; + } + } + return days; +} + +/** The spans of a rule such as `09:00-12:00,13:00-18:00`; null where one is not a plain span within the day. */ +function spansOf(times: string): HoursWindow[] | null { + const windows: HoursWindow[] = []; + for (const span of times.split(',')) { + const [open, close, ...rest] = span.trim().split('-'); + const from = minutesOf(open); + const to = close === '24:00' ? DAY_MINUTES : minutesOf(close); + if (rest.length > 0 || from === null || to === null || to <= from) return null; + windows.push([from, to]); + } + return windows; +} + +/** One rule, its days and its spans (none for `off`); null where it is not one of the plain forms. */ +function ruleOf(rule: string): { days: Set; windows: HoursWindow[] } | null { + const gap = rule.search(/\s/); + if (gap <= 0) return null; + const days = daysOf(rule.slice(0, gap)); + const times = rule.slice(gap + 1).trim(); + if (!days) return null; + if (times === 'off' || times === 'closed') return { days, windows: [] }; + const windows = spansOf(times); + return windows && { days, windows }; +} + +/** The windows OpenStreetMap's hours give a date, in minutes; null where they cannot be read. */ +export function openWindows(hours: string, date: string): HoursWindow[] | null { + const weekday = weekdayOf(date); + if (weekday === null) return null; + const text = hours.trim(); + if (text === '24/7') return [[0, DAY_MINUTES]]; + let windows: HoursWindow[] = []; + for (const raw of text.split(';')) { + if (!raw.trim()) continue; + const rule = ruleOf(raw.trim()); + if (!rule) return null; + // A later rule for the day replaces an earlier one, as OpenStreetMap reads them. + if (rule.days.has(weekday)) windows = rule.windows; + } + return windows; +} + +/** A day of a place's own week as its window that day: none when closed, null when it says nothing. */ +function dayWindows(day: PlaceHoursDay | undefined): HoursWindow[] | null { + if (!day) return null; + if (day.closed) return []; + const open = minutesOf(day.open); + const close = minutesOf(day.close); + if (open === null || close === null) return null; + // Closing at or before it opens runs past midnight: open to the end of this day. + return [[open, close > open ? close : DAY_MINUTES]]; +} + +/** The part after midnight of a day open past it, which falls on the next date. */ +function spillOf(day: PlaceHoursDay | undefined): HoursWindow | null { + if (!day || day.closed) return null; + const open = minutesOf(day.open); + const close = minutesOf(day.close); + return open !== null && close !== null && close <= open && close > 0 ? [0, close] : null; +} + +/** + * The windows a trip place's own hours (#2472: seven days, Monday first) give + * a date, with the night of the day before where it runs past midnight; null + * where that weekday says nothing, which is unknown and never open. + */ +export function placeWindows(raw: string | null | undefined, date: string): HoursWindow[] | null { + const week = parsePlaceHours(raw); + const weekday = weekdayOf(date); + if (!week || weekday === null) return null; + const today = dayWindows(week[weekday]); + if (today === null) return null; + const spill = spillOf(week[(weekday + 6) % 7]); + return spill ? [spill, ...today] : today; +} + +/** + * A stop's hours on a date: the trip place it stands for first, as the + * travellers entered them, else what OpenStreetMap had for it; null where + * neither says anything. + */ +export function stationHoursOn( + hours: { place?: string | null; osm?: string | null }, + date: string, +): StationHours | null { + const own = placeWindows(hours.place, date); + if (own) return { source: 'place', windows: own }; + const text = hours.osm?.trim(); + if (!text) return null; + const windows = openWindows(text, date); + return windows ? { source: 'osm', windows } : { source: 'osm', unread: text }; +} + +/** + * What a stop's hours say about the visit planned there: + * - `unknown`: no hours, so nothing is said, and never that it is open; + * - `unread`: hours that are not in a plain form, given back as written; + * - `nextDay`: an arrival at or after midnight, which is not checked against the next day; + * - `uncertain`: the arrival is only a lower bound and a later one could still fit; + * - `open`: open from the arrival to the end of the stay, in `window`; + * - `closedAllDay`, `closed` (at the arrival; `windows` are the day's) and + * `closes` (during the stay, at the end of `window`) are conflicts. + */ +export type StationFit = + | { kind: 'unknown' } + | { kind: 'unread'; text: string } + | { kind: 'nextDay' } + | { kind: 'uncertain'; source: HoursSource } + | { kind: 'open'; source: HoursSource; window: HoursWindow } + | { kind: 'closedAllDay'; source: HoursSource } + | { kind: 'closed'; source: HoursSource; windows: readonly HoursWindow[] } + | { kind: 'closes'; source: HoursSource; window: HoursWindow }; + +/** An arrival in minutes after midnight; `atLeast` when a stay before it is not known, so it may come later. */ +export interface WalkArrival { + arrival: number; + atLeast: boolean; +} + +/** Inside the window holding the arrival: whether the stay ends after it closes. */ +function fitInWindow( + source: HoursSource, + window: HoursWindow, + leave: number | null, + later: { atLeast: boolean; windowAfter: boolean }, +): StationFit { + // A window running to midnight is taken as open past it. + const closes = leave !== null && leave > window[1] && window[1] !== DAY_MINUTES; + // An arrival that may come later can still miss the closing, or reach the next window instead. + if (later.atLeast) + return closes && !later.windowAfter ? { kind: 'closes', source, window } : { kind: 'uncertain', source }; + return closes ? { kind: 'closes', source, window } : { kind: 'open', source, window }; +} + +/** + * Whether a stop is open from its arrival to the end of its stay. A stay that + * is not known checks the arrival alone, and a stay of 0, a point only passed, + * checks that it is open as the walk passes. + */ +export function stationFit(hours: StationHours | null, at: WalkArrival, stay: number | null): StationFit { + if (at.arrival >= DAY_MINUTES) return { kind: 'nextDay' }; + if (!hours) return { kind: 'unknown' }; + if ('unread' in hours) return { kind: 'unread', text: hours.unread }; + const { source, windows } = hours; + if (windows.length === 0) return { kind: 'closedAllDay', source }; + const leave = stay === null ? null : at.arrival + stay; + const window = windows.find(([from, to]) => at.arrival >= from && at.arrival < to); + const windowAfter = windows.some(([from]) => from > at.arrival); + if (window) return fitInWindow(source, window, leave, { atLeast: at.atLeast, windowAfter }); + return at.atLeast && windowAfter ? { kind: 'uncertain', source } : { kind: 'closed', source, windows }; +} + +/** A stop of a walk as the arrivals need it: where it is, and its stay (null or left out when not known). */ +export interface WalkStop { + lat: number; + lng: number; + stayMinutes?: number | null; +} + +type RoutePoint = readonly [number, number, ...number[]]; + +const RAD = Math.PI / 180; + +/** The route laid flat around its start: a city walk spans a few kilometres, so distances keep their ratios. */ +function flatRoute(route: readonly RoutePoint[]): Array<[number, number]> { + const kx = Math.cos((route[0]?.[0] ?? 0) * RAD); + return route.map(([lat, lng]) => [lng * kx, lat]); +} + +function flatGap(a: readonly [number, number], b: readonly [number, number]): number { + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2; +} + +/** + * The route point each stop is passed at, keeping the stops' order: the + * nearest points that do, so a loop's end is its last point rather than its + * first, and a stop passed on the way out and back is matched where the walk + * passes it in its turn. Of two equally near points the earlier is taken. + */ +function passedAt(stops: readonly WalkStop[], flat: ReadonlyArray<[number, number]>, kx: number): number[] { + // best[j]: the least summed distance of the stops so far, the last of them at point j or before. + let best = new Array(flat.length).fill(0); + const picks: Int32Array[] = []; + for (const stop of stops) { + const spot: [number, number] = [stop.lng * kx, stop.lat]; + const next = new Array(flat.length); + const pick = new Int32Array(flat.length); + let low = Number.POSITIVE_INFINITY; + let lowAt = 0; + flat.forEach((point, j) => { + const cost = (best[j] ?? 0) + flatGap(point, spot); + if (cost < low) { + low = cost; + lowAt = j; + } + next[j] = low; + pick[j] = lowAt; + }); + best = next; + picks.push(pick); + } + const indices = new Array(stops.length).fill(0); + let at = flat.length - 1; + for (let s = stops.length - 1; s >= 0; s -= 1) { + at = picks[s]?.[at] ?? 0; + indices[s] = at; + } + return indices; +} + +/** + * The estimated arrival at each stop, in minutes after midnight and whole: the + * start, the walking time shared out by how far along the route the stop is + * passed, and the stays before it. A stay not known counts as nothing and + * makes every later arrival a lower bound; a stay of 0 is known. Null when the + * start, the walking time or the route is not known. + */ +export function stationArrivals( + stops: readonly WalkStop[], + route: readonly RoutePoint[], + start: number | null, + walkingMinutes: number | null, +): WalkArrival[] | null { + if (start === null || walkingMinutes === null || route.length < 2) return null; + const flat = flatRoute(route); + const along = [0]; + for (let i = 1; i < flat.length; i += 1) { + along.push((along[i - 1] ?? 0) + Math.sqrt(flatGap(flat[i] ?? [0, 0], flat[i - 1] ?? [0, 0]))); + } + const total = along[along.length - 1] || 1; + const indices = passedAt(stops, flat, Math.cos((route[0]?.[0] ?? 0) * RAD)); + let stays = 0; + let unknown = false; + return stops.map((stop, index) => { + const share = (along[indices[index] ?? 0] ?? 0) / total; + const arrival = { arrival: Math.round(start + walkingMinutes * share + stays), atLeast: unknown }; + if (stop.stayMinutes == null) unknown = true; + else stays += stop.stayMinutes; + return arrival; + }); +}