From 32dfc75756a64ef5826e00a0ee5c180a44530d17 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 03:22:11 +0000 Subject: [PATCH] feat(tours): a city walk's arrivals and opening hours for the day it is on, at each stop in the planner and the detail (CW-02, CW-04) A city walk now shows, at each stop, when the walk gets there, until when the stay lasts, and what the stop's opening hours say about that visit. The same lines appear under each stop row in the planner and under each stop in the walk's detail, in the user's 12 or 24 hour format. A conflict names the stop, the arrival, the end of the stay and the hours it was checked against, for instance "Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)", in the warning colour. Nothing is changed by these hints: no stop, order or route moves. The times belong to one day the walk is on. Opened from a day of the plan, the detail keeps that day, and checking stops off stays bound to it. Opened otherwise, and in the planner, a small pill above the stops names the day and its start, and the walk's other days can be picked from it; it starts on the first day with a start time. The pick is only what is looked at and is never saved, and another day never takes over the times quietly. Without a day, a date or a start time the line says what is missing and no arrival is shown. TREK keeps no time zone on a trip, so start, arrivals and opening hours are all read as the local time of the plan, and an arrival after midnight is said to be on the next day without being checked. Opening hours come from the trip place a stop stands for, as the travellers entered them, and otherwise from OpenStreetMap; a pill at each stop names the source, and a notice says the hours are a hint to check. A stop without hours stays unknown and is never shown as open, hours that are not in a plain form are shown as written, and an arrival that is only a lower bound, because a stay before it is not entered yet, is not judged unless no later arrival could escape the conflict. A trip place that a stop stands for also gives the stop its name before "Stop N". The arrival at a stop is found where the route passes it in the walk's order, so the end of a loop and a stop passed on the way out and back get the right time. The rules live once in the shared package, for the server to give the same answers. The walk's detail reads its stops again after a save, its own or a teammate's. --- .../Tours/TourDetailDialog.test.tsx | 32 +- .../src/components/Tours/TourDetailDialog.tsx | 15 +- .../Tours/planner/TourWaypointList.test.tsx | 88 ++++- .../Tours/planner/TourWaypointList.tsx | 10 +- .../Tours/planner/useTripPlaceWaypoints.ts | 10 +- .../Tours/walk/StationSchedule.test.tsx | 136 ++++++++ .../components/Tours/walk/StationSchedule.tsx | 177 ++++++++++ .../Tours/walk/WalkDayLine.test.tsx | 111 ++++++ .../src/components/Tours/walk/WalkDayLine.tsx | 120 +++++++ .../Tours/walk/WalkStations.test.tsx | 202 +++++++++-- .../components/Tours/walk/WalkStations.tsx | 181 +++------- .../components/Tours/walk/useWalkDay.test.ts | 80 +++++ .../src/components/Tours/walk/useWalkDay.ts | 65 ++++ .../components/Tours/walk/useWalkSchedule.ts | 69 ++++ .../components/Tours/walk/useWalkStations.ts | 46 +++ .../Tours/walk/walkSchedule.test.ts | 88 +++++ .../src/components/Tours/walk/walkSchedule.ts | 189 ++++------- shared/src/i18n/ar/tours.ts | 23 +- shared/src/i18n/az/tours.ts | 26 +- shared/src/i18n/br/tours.ts | 25 +- shared/src/i18n/ca/tours.ts | 25 +- shared/src/i18n/cs/tours.ts | 23 +- shared/src/i18n/de/tours.ts | 25 +- shared/src/i18n/en/tours.ts | 23 +- shared/src/i18n/es/tours.ts | 26 +- shared/src/i18n/et/tours.ts | 24 +- shared/src/i18n/fr/tours.ts | 24 +- shared/src/i18n/gr/tours.ts | 24 +- shared/src/i18n/hu/tours.ts | 26 +- shared/src/i18n/id/tours.ts | 23 +- shared/src/i18n/it/tours.ts | 26 +- shared/src/i18n/ja/tours.ts | 23 +- shared/src/i18n/ko/tours.ts | 24 +- shared/src/i18n/nl/tours.ts | 25 +- shared/src/i18n/pl/tours.ts | 25 +- shared/src/i18n/ru/tours.ts | 25 +- shared/src/i18n/sk/tours.ts | 23 +- shared/src/i18n/sv/tours.ts | 23 +- shared/src/i18n/th/tours.ts | 23 +- shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 25 +- shared/src/i18n/uk/tours.ts | 24 +- shared/src/i18n/vi/tours.ts | 23 +- shared/src/i18n/zh-TW/tours.ts | 23 +- shared/src/i18n/zh/tours.ts | 23 +- shared/src/index.ts | 1 + shared/src/tours/walk-hours.spec.ts | 278 +++++++++++++++ shared/src/tours/walk-hours.ts | 321 ++++++++++++++++++ 48 files changed, 2476 insertions(+), 397 deletions(-) create mode 100644 client/src/components/Tours/walk/StationSchedule.test.tsx create mode 100644 client/src/components/Tours/walk/StationSchedule.tsx create mode 100644 client/src/components/Tours/walk/WalkDayLine.test.tsx create mode 100644 client/src/components/Tours/walk/WalkDayLine.tsx create mode 100644 client/src/components/Tours/walk/useWalkDay.test.ts create mode 100644 client/src/components/Tours/walk/useWalkDay.ts create mode 100644 client/src/components/Tours/walk/useWalkSchedule.ts create mode 100644 client/src/components/Tours/walk/useWalkStations.ts create mode 100644 client/src/components/Tours/walk/walkSchedule.test.ts create mode 100644 shared/src/tours/walk-hours.spec.ts create mode 100644 shared/src/tours/walk-hours.ts 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; + }); +}