From d3da6ca08d4903869c7e45f18cf0a8bf9c79b24b Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 05:14:48 +0000 Subject: [PATCH] feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08) "Public transit" on the connector between two city walks in the day plan, on the desktop and on the phone, now opens the existing search from where the first walk really ends to where the next one starts, at the coordinates the day's route draws, named after the first walk's last stop and the next walk's first stop. A loop ends at its first stop again. A stop without a name takes the name of the trip place it stands for, and a walk whose stops cannot be read on this device names that end "End of" or "Start of" the walk. The search opens on the walks' day and departs when the first walk ends: its start that day, the time on the day's entry before the place's own, plus its planned total, a total entered by hand before walking and stays. The assignment's own end time is not used. Above the search a note says when the first walk ends, "at the earliest" where a stay or the walking time is not known yet, and when the next walk starts. Without a start or a total no time is made up: the time field stays empty and Search waits until one is chosen. A walk that only ends after midnight says so, and the time field stays empty as well. On any other day, or for any other leg, the search opens as before, with 09:00 as its default. A connection that arrives after the next walk starts says so in its card, before its legs are opened and before it is taken. Nothing is saved until "Add to day", which keeps the connection as its own journey in the plan as before; both walks stay two walks on foot and nothing is moved. A journey saved before a walk that now arrives after the walk starts says so on its row in the plan, on the desktop and the phone, while the plan is looked at. Nothing is rewritten. The desktop no longer draws a connector of nothing between a stop and a booking at the same spot, as the phone already did, so a journey saved at the walks' ends adds no walk on either side. A leg leaving a loop tour no longer departs at the tour's start time. The leg is told by the walk it leaves, so two walks of a day that end at one spot, such as two loops from the hotel, each search from their own end and time, and a stop between two walks keeps the leg an ordinary one. Only the leg asked for last opens the search, and none opens once the plan is left while the walks' stops are still being read. Both views run on one shared hook, the walk's stops are read through one helper the stop list uses as well, and the walk's start on a day is worked out in one place for the times shown in the detail and here. --- client/scripts/eslint-baseline.json | 2 +- client/scripts/feature-pairs.json | 6 + client/scripts/size-baseline.json | 4 +- .../Planner/DayPlanSidebar.tours.test.tsx | 242 ++++++++++++- .../src/components/Planner/DayPlanSidebar.tsx | 34 +- .../components/Planner/TransitSearchPanel.tsx | 22 +- .../Planner/TransitSearchPanel.walk.test.tsx | 181 ++++++++++ .../Planner/TransportModal.test.tsx | 9 + .../src/components/Planner/TransportModal.tsx | 6 +- .../src/components/Planner/transitLeg.test.ts | 9 +- client/src/components/Planner/transitLeg.ts | 19 +- .../Planner/useTransitLegPlanner.test.ts | 255 +++++++++++++ .../Planner/useTransitLegPlanner.ts | 102 ++++++ .../components/Tours/walk/WalkTransitNote.tsx | 60 ++++ .../src/components/Tours/walk/useWalkDay.ts | 10 +- .../components/Tours/walk/useWalkStations.ts | 25 +- .../src/components/Tours/walk/walkSchedule.ts | 11 +- .../components/Tours/walk/walkStationsRead.ts | 28 ++ .../components/Tours/walk/walkTransit.test.ts | 339 ++++++++++++++++++ .../src/components/Tours/walk/walkTransit.ts | 222 ++++++++++++ .../screens/trip/plan/MPlanTimeline.tsx | 3 +- .../screens/trip/plan/MPlanTimelineRows.tsx | 7 +- .../screens/trip/plan/useMPlanTimeline.ts | 41 ++- .../trip/sheets/MTransportFormSheet.tsx | 1 + client/src/pages/TripPlannerPage.tsx | 4 +- .../pages/tripPlanner/usePlannerDialogs.ts | 3 +- .../unit/mobile/trip/MPlanTimeline.test.tsx | 10 +- .../mobile/trip/MPlanTimelineRows.test.tsx | 8 + .../mobile/trip/MTransportFormSheet.test.tsx | 15 +- .../trip/useMPlanTimeline.transit.test.tsx | 67 +++- shared/src/i18n/ar/tours.ts | 8 + shared/src/i18n/az/tours.ts | 8 + shared/src/i18n/br/tours.ts | 8 + shared/src/i18n/ca/tours.ts | 8 + shared/src/i18n/cs/tours.ts | 8 + shared/src/i18n/de/tours.ts | 8 + shared/src/i18n/en/tours.ts | 8 + shared/src/i18n/es/tours.ts | 8 + shared/src/i18n/et/tours.ts | 8 + shared/src/i18n/fr/tours.ts | 9 + shared/src/i18n/gr/tours.ts | 10 + shared/src/i18n/hu/tours.ts | 8 + shared/src/i18n/id/tours.ts | 8 + shared/src/i18n/it/tours.ts | 8 + shared/src/i18n/ja/tours.ts | 8 + shared/src/i18n/ko/tours.ts | 8 + shared/src/i18n/nl/tours.ts | 8 + shared/src/i18n/pl/tours.ts | 9 + shared/src/i18n/ru/tours.ts | 10 + shared/src/i18n/sk/tours.ts | 8 + shared/src/i18n/sv/tours.ts | 8 + shared/src/i18n/th/tours.ts | 8 + shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 9 + shared/src/i18n/uk/tours.ts | 8 + shared/src/i18n/vi/tours.ts | 8 + shared/src/i18n/zh-TW/tours.ts | 8 + shared/src/i18n/zh/tours.ts | 8 + 58 files changed, 1879 insertions(+), 91 deletions(-) create mode 100644 client/src/components/Planner/TransitSearchPanel.walk.test.tsx create mode 100644 client/src/components/Planner/useTransitLegPlanner.test.ts create mode 100644 client/src/components/Planner/useTransitLegPlanner.ts create mode 100644 client/src/components/Tours/walk/WalkTransitNote.tsx create mode 100644 client/src/components/Tours/walk/walkStationsRead.ts create mode 100644 client/src/components/Tours/walk/walkTransit.test.ts create mode 100644 client/src/components/Tours/walk/walkTransit.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 84edd05d7..ad5eb6d1f 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -16,7 +16,7 @@ "react-hooks/immutability": 28, "max-lines-per-function": 133, "react-hooks/static-components": 78, - "react-hooks/preserve-manual-memoization": 15, + "react-hooks/preserve-manual-memoization": 12, "no-useless-assignment": 7, "no-empty": 19, "trek/no-window-globals": 41, diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index ea88f99ff..2e5784a0a 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -571,6 +571,12 @@ "desktop": ["src/components/Admin/AdminPluginsPanel.tsx"], "mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"] }, + { + "feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk", + "hooks": ["src/components/Planner/useTransitLegPlanner.ts"], + "desktop": ["src/components/Planner/DayPlanSidebar.tsx"], + "mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"] + }, { "feature": "Reservation form incl. hotel accommodation creation", "hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"], diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 98e76ebe2..5bf49f75a 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -12,7 +12,7 @@ "src/components/PDF/TripPDF.tsx": 1082, "src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayPlanSidebar.test.tsx": 5378, - "src/components/Planner/DayPlanSidebar.tsx": 2971, + "src/components/Planner/DayPlanSidebar.tsx": 2969, "src/components/Planner/PlaceFormModal.test.tsx": 2864, "src/components/Planner/PlaceFormModal.tsx": 1250, "src/components/Planner/PlaceInspector.tsx": 1304, @@ -24,7 +24,7 @@ "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/LoginPage.tsx": 1348, - "src/pages/TripPlannerPage.tsx": 1142, + "src/pages/TripPlannerPage.tsx": 1141, "src/pages/atlas/useAtlas.ts": 1287, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3309, diff --git a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx index 0c0e3b4e3..6f327caef 100644 --- a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx @@ -1,16 +1,28 @@ // RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on, // and that it falls back to the plain Place row when it is off. The mocks are the // ones DayPlanSidebar.test.tsx renders the sidebar with. +// CW-08: public transit between two city walks from the day plan's connector. +import userEvent from '@testing-library/user-event'; +import type { TourListItem } from '@trek/shared'; import { http, HttpResponse } from 'msw'; -import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories'; +import { + buildAssignment, + buildDay, + buildPlace, + buildReservation, + buildTrip, + buildUser, +} from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; -import { render, screen } from '../../../tests/helpers/render'; +import { render, screen, waitFor, within } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; import { useAddonStore } from '../../store/addonStore'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import { useTripStore } from '../../store/tripStore'; +import type { Reservation } from '../../types'; +import { tourPlannedTimes } from '../Tours/tourPresentation'; import DayPlanSidebar from './DayPlanSidebar'; // ── Hoisted mock state (accessible in vi.mock factories) ──────────────────── const mockDayNotesState = vi.hoisted(() => ({ @@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({ generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'), generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'), optimizeRoute: vi.fn().mockImplementation((places) => places), - // One leg per waypoint gap; the connector between two stops reads distanceText. - calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) => + // One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText. + calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) => Promise.resolve({ distanceText: '2 km', durationText: '10 min', - legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({ + legs: waypoints.slice(1).map((to, index) => ({ + from: [waypoints[index]?.lat, waypoints[index]?.lng], + to: [to.lat, to.lng], distanceText: '2 km', durationText: '10 min', drivingText: '10 min', @@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({ ), })); +// CW-08: a walk's stops are read for the names of a transit leg's two ends. +const mockTourDetail = vi.hoisted(() => vi.fn()); +vi.mock('../../repo/tourRepo', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } }; +}); + // PlaceAvatar needs IntersectionObserver class MockIO { observe = vi.fn(); @@ -328,3 +349,214 @@ describe('DayPlanSidebar', () => { expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1); }); }); + +// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market. +const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }]; +const oldTown = buildPlace({ + id: 51, + name: 'Old town', + tour_place_id: 51, + lat: 52.52, + lng: 13.4, + route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]', +}); +const harbour = buildPlace({ + id: 52, + name: 'Harbour', + tour_place_id: 52, + lat: 52.5, + lng: 13.45, + route_geometry: '[[52.5,13.45],[52.49,13.46]]', +}); +const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 }); + +function walkTour(place: { id: number; name: string }, overrides: Partial = {}): TourListItem { + return { + place_id: place.id, + name: place.name, + tour_type: 'walk', + distance: 3, + elevation_gain: null, + elevation_loss: null, + duration: 90, + break_additional_minutes: null, + planned_duration_minutes: null, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: true, + caution: false, + station_count: 2, + station_stay_minutes: 45, + station_stays_unknown: 0, + ...overrides, + } as TourListItem; +} + +function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }); + const assignments = { + '10': [ + buildAssignment({ + id: 201, + day_id: 10, + order_index: 0, + tour_place_id: 51, + place: { ...oldTown, place_time: '10:00' }, + }), + buildAssignment({ + id: 202, + day_id: 10, + order_index: 1, + tour_place_id: 52, + place: { ...harbour, place_time: '13:00' }, + }), + ...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []), + ], + }; + const tours = [walkTour(oldTown), walkTour(harbour)]; + return makeDefaultProps({ + days: [day], + places: [oldTown, harbour, cafe], + assignments, + selectedDayId: 10, + routeShown: true, + tourPlaceIds: new Set([51, 52]), + plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])), + reservations: extra.reservations ?? [], + onPlanTransitLeg: extra.onPlanTransitLeg, + }); +} + +/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */ +function savedJourney(arrives: string): Reservation { + return { + ...buildReservation({ + id: 300, + type: 'transit', + title: 'Old bridge → Fish market', + reservation_time: '2025-06-01T12:20', + reservation_end_time: `2025-06-01T${arrives}`, + day_id: 10, + }), + endpoints: [ + { + role: 'from', + sequence: 0, + name: 'Old bridge', + lat: 52.51, + lng: 13.42, + local_date: '2025-06-01', + local_time: '12:20', + }, + { + role: 'to', + sequence: 1, + name: 'Fish market', + lat: 52.5, + lng: 13.45, + local_date: '2025-06-01', + local_time: arrives, + }, + ], + metadata: { + transit: { + provider: 'transitous', + duration: 1800, + transfers: 0, + legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }], + }, + }, + } as unknown as Reservation; +} + +function contextMenu() { + return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement); +} + +describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => { + beforeEach(() => { + useAddonStore.setState({ addons: TOURS_ON }); + mockTourDetail.mockImplementation((_tripId: number, placeId: number) => + Promise.resolve({ + tour: walkTour(placeId === 51 ? oldTown : harbour), + waypoints: + placeId === 51 + ? [ + { lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 }, + { lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 }, + ] + : [ + { lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' }, + { lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' }, + ], + }) + ); + }); + + it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => { + const user = userEvent.setup(); + const onPlanTransitLeg = vi.fn(); + render(); + + await user.click(await screen.findByLabelText('Change travel mode')); + await user.click(contextMenu().getByRole('button', { name: 'Public transit' })); + + // The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved. + await waitFor(() => + expect(onPlanTransitLeg).toHaveBeenCalledWith({ + dayId: 10, + from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, + to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, + time: '12:15', + walk: { + dayId: 10, + fromWalk: 'Old town', + toWalk: 'Harbour', + ends: '12:15', + endsNextDay: false, + endsAtLeast: false, + nextStarts: '13:00', + }, + }) + ); + expect(mockTourDetail).toHaveBeenCalledTimes(2); + }); + + it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => { + const user = userEvent.setup(); + const onPlanTransitLeg = vi.fn(); + render(); + + const connectors = await screen.findAllByLabelText('Change travel mode'); + expect(connectors).toHaveLength(2); + await user.click(connectors[1]); + await user.click(contextMenu().getByRole('button', { name: 'Public transit' })); + + expect(onPlanTransitLeg).toHaveBeenCalledWith({ + dayId: 10, + from: { name: '', lat: 52.49, lng: 13.46 }, + to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 }, + time: null, + }); + expect(mockTourDetail).not.toHaveBeenCalled(); + }); + + it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => { + render(); + + // The only leg left is the one from the harbour walk to the cafe; the journey covers the rest. + await screen.findByLabelText('Change travel mode'); + expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1); + expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument(); + }); + + it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => { + const onPlanTransitLeg = vi.fn(); + render(); + + expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00'); + expect(onPlanTransitLeg).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 5f817d295..656171485 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -8,8 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba import { avatarSrc } from '../../utils/avatarSrc' import { safeHttpUrl } from '../../utils/safeUrl' import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react' -import { type PickedPlace } from './TransitSearchPanel' -import { buildTransitLeg, buildTransitNameIndex } from './transitLeg' +import type { PlannedTransitLeg } from './transitLeg' +import { useTransitLegPlanner } from './useTransitLegPlanner' +import { WalkLateLine } from '../Tours/walk/WalkTransitNote' import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator' import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias' import { routeLeg, type LegEnd } from '../Map/dayRouteLeg' @@ -161,8 +162,9 @@ export interface DayPlanSidebarProps { * Opens the public-transit search pre-filled for a single leg (#1281 follow-up): * the leg's origin and destination endpoints plus the origin's departure time, * so "public transport" becomes an option on any connector, not just the day header. + * Between two city walks it carries what the search says of them (CW-08). */ - onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void + onPlanTransitLeg?: (leg: PlannedTransitLeg) => void /** Opens the journey view for a saved transit entry (#1065). */ onOpenTransit?: (reservation: Reservation) => void onEditTransport?: (reservation: Reservation) => void @@ -789,6 +791,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { for (let i = 0; i < run.length - 1; i++) { const from = run[i] const to = run[i + 1] + // A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08). + if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue const mode = resolveLegMode(from, to, dfMode) tasks.push(async () => { const seg = await legBetween(from, to, dayId, mode) @@ -1405,26 +1409,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP }) } - // Coordinates back to names for the transit search, over every located place, - // hotel and booking endpoint on the trip (see transitLeg.ts). - const transitNameIndex = useMemo( - () => buildTransitNameIndex(assignments, accommodations, reservations), - [assignments, accommodations, reservations], - ) - - // The extra connector-menu entry (#1281 follow-up): search public transit for this - // leg instead of drawing a road route. Only when a handler is wired (day has dates). - const transitLegPlanner = (dayId: number, seg?: RouteSegment) => { - if (!onPlanTransitLeg) return undefined - const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations) - if (!leg) return undefined - return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) + // The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has + // dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08). + const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled, + onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour }) + const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => { + const leg = transitLegs.legFor(dayId, seg) + return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined } // Open the mode menu at the clicked connector: every route profile, the optional // "public transport" entry, plus a "use day default" entry that clears the override. const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => { - ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg))) + ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId))) } // Set the mode of the leg ENTERING this stop (a booking arrival or the morning @@ -2359,6 +2356,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP ) })()} + {transitMeta && (() => { const expanded = expandedTransitIds.has(res.id) diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index 5a45b9c57..e693f668c 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore' import { isCarrierTransport } from '../../utils/dayMerge' import type { TransitProvider } from '@trek/shared' import { useIsPhone } from '../../mobile/useIsPhone' +import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote' +import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit' /** * Public transit route search (#1065), backed by Transitous (MOTIS) through the @@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) { ) } -function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: { +function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: { it: TransitItinerary tzFrom: string tzTo: string @@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add onToggle: () => void onAdd: () => void adding: boolean + /** CW-08: the next city walk this connection arrives too late for. */ + late: WalkLate | null t: (k: string, p?: Record) => string }) { const transitLegs = it.legs.filter(l => l.mode !== 'WALK') @@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add ))} + {expanded && ( @@ -460,9 +465,11 @@ interface TransitSearchPanelProps { initialTo?: PickedPlace | null /** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */ initialTime?: string | null + /** CW-08: the two city walks the leg runs between; said only while the search is for their day. */ + walk?: WalkTransitContext | null } -export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) { +export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) { const { t, language } = useTranslation() const toast = useToast() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' @@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations = const [from, setFrom] = useState(initialFrom) const [to, setTo] = useState(initialTo) - const [time, setTime] = useState(initialTime || '09:00') + const walkHere = walk?.dayId === day.id ? walk : null + // Between two walks the time is when the first one ends, never one made up: unknown, it is chosen. + const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00')) const [arriveBy, setArriveBy] = useState(false) const [activeModes, setActiveModes] = useState>(() => new Set(MODE_GROUPS.map(m => m.key))) const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best') @@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations = const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC' const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom + const canSearch = !!from && !!to && !!time && !loading const segBtn = (active: boolean): React.CSSProperties => ({ padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, @@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations = return (
+ {/* from / to — stacked tight on mobile, swap button on desktop only */}
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
+ {open && } diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index 9d404f119..d8fa847a5 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -9,7 +9,8 @@ import { dayWeatherAnchor } from '../../../../utils/dayWeather' import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder' import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds } from '../../../../utils/dayMerge' import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute' -import { buildTransitLeg, buildTransitNameIndex, type TransitLeg } from '../../../../components/Planner/transitLeg' +import type { PlannedTransitLeg, TransitLeg } from '../../../../components/Planner/transitLeg' +import { useTransitLegPlanner } from '../../../../components/Planner/useTransitLegPlanner' import { buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime, type HotelLegs, type PlanRow, type TransportEntry, @@ -352,26 +353,30 @@ export function useMPlanTimeline(planner: TripPlanner) { // ── Public transit for one leg (#2398) ── // The entry the desktop connector menu carries: the automated search, seeded with - // the leg's two ends and the time the stop it leaves is planned for. It needs the - // trip's dates, as on the desktop and as the day sheet's own transit button does. + // the leg's two ends and the time the stop it leaves is planned for, between two city + // walks from the first one's end (CW-08). It needs the trip's dates, as on the desktop + // and as the day sheet's own transit button does. const tripHasDates = Boolean(planner.trip?.start_date && planner.trip?.end_date) - const transitNames = useMemo( - () => buildTransitNameIndex(assignments, tripAccommodations, reservations), - [assignments, tripAccommodations, reservations], - ) - const transitLegFor = useCallback((seg: RouteSegment): TransitLeg | null => { - if (!day || !tripHasDates) return null - return buildTransitLeg(seg, day.id, transitNames, assignments, reservations) - }, [day, tripHasDates, transitNames, assignments, reservations]) - - const planTransitLeg = useCallback((leg: TransitLeg) => { - if (!day) return - planner.setTransportModalDayId(day.id) + const openTransitSearch = useCallback(({ dayId, ...prefill }: PlannedTransitLeg) => { + planner.setTransportModalDayId(dayId) planner.setEditingTransport(null) - planner.setTransitPrefill(leg) + planner.setTransitPrefill(prefill) planner.setTransportModalAutomated(true) planner.setShowTransportModal(true) - }, [planner, day]) + }, [planner]) + const tourOf = useCallback((placeId: number) => planner.tours.find(tour => tour.place_id === placeId), [planner.tours]) + const { legFor, plan, lateBefore } = useTransitLegPlanner({ + tripId, assignments, accommodations: tripAccommodations, reservations, places: planner.places, + toursEnabled: planner.toursEnabled, tourOf, onPlan: tripHasDates ? openTransitSearch : undefined, + }) + const transitLegFor = useCallback((seg: RouteSegment): TransitLeg | null => (day ? legFor(day.id, seg) : null), [day, legFor]) + // `originId` is the stop the leg leaves, which tells two walks ending at one spot apart. + const planTransitLeg = useCallback((leg: TransitLeg, originId: number) => { + if (day) plan(day.id, leg, originId) + }, [day, plan]) + // A saved journey that now arrives after the walk it leads to starts (CW-08). + const journeyLate = useCallback((item: MergedItem) => + (day ? lateBefore(day, item.data, merged.slice(merged.indexOf(item) + 1)) : null), [day, lateBefore, merged]) return { day, rows, hotelLegs, merged, hotelChips, weather, weatherTemp, upNext, @@ -382,7 +387,7 @@ export function useMPlanTimeline(planner: TripPlanner) { moveRowTo, addPlace, addBooking, addTransport, optimize, canExportRoute, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf, - routeModeOptions, setLegMode, transitLegFor, planTransitLeg, + routeModeOptions, setLegMode, transitLegFor, planTransitLeg, journeyLate, } } diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 4b13119a0..7caa6ec00 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -248,6 +248,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo initialFrom={transitPrefill?.from ?? null} initialTo={transitPrefill?.to ?? null} initialTime={transitPrefill?.time ?? null} + walk={transitPrefill?.walk ?? null} /> ) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index bdbac0f3a..822917f75 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -379,7 +379,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined} onOpenTransit={(r) => setTransitJourney(r)} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined} - onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined} + onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, ...leg }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(leg); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined} // The narrow layout keeps what a booking row opened before. @@ -617,7 +617,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, ...leg }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(leg); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> : handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
diff --git a/client/src/pages/tripPlanner/usePlannerDialogs.ts b/client/src/pages/tripPlanner/usePlannerDialogs.ts index 811c15101..cc6b603ed 100644 --- a/client/src/pages/tripPlanner/usePlannerDialogs.ts +++ b/client/src/pages/tripPlanner/usePlannerDialogs.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { RoadtripStopType } from '@trek/shared' import { airtrailApi } from '../../api/client' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' +import type { TransitPrefill } from '../../components/Planner/transitLeg' import { useAirtrailConnection } from '../../hooks/useAirtrailConnection' import type { Place, Reservation } from '../../types' import type { PlannerBase, PlannerSearchParams } from './plannerTypes' @@ -128,7 +129,7 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) { // Public transit (#1065): open the TransportModal in its Automated mode, seed // the search (change-route), and show the journey view for a saved entry. const [transportModalAutomated, setTransportModalAutomated] = useState(false) - const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null; time?: string | null } | null>(null) + const [transitPrefill, setTransitPrefill] = useState(null) const [transitJourney, setTransitJourney] = useState(null) // The booking whose detail is open over the desktop plan: a day row, a rental pill, // the inspector's booking card, a map endpoint or the road-trip rail opened it. diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx index 3270ab256..fe1599d42 100644 --- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx @@ -123,6 +123,7 @@ function buildTl(over: Record = {}): MPlanTimelineController { setLegMode: vi.fn(), transitLegFor: vi.fn(() => null), planTransitLeg: vi.fn(), + journeyLate: vi.fn(() => null), ...over, } as unknown as MPlanTimelineController } @@ -331,6 +332,13 @@ describe('MPlanTimeline', () => { expect(screen.getByText('transit.min:15')).toBeInTheDocument() }) + it('FE-MOB-PLTL-014b: a transit row says when it now arrives after the next city walk starts (CW-08)', () => { + renderTimeline({ journeyLate: vi.fn(() => ({ walk: 'Harbour', time: '13:00' })) }) + + expect(mocks.tl.journeyLate).toHaveBeenCalledWith(expect.objectContaining({ type: 'transport' })) + expect(screen.getByTestId('transit-walk-late')).toBeInTheDocument() + }) + it('FE-MOB-PLTL-015: a note row opens the note sheet with its note in edit mode', () => { const { shell } = renderTimeline({}, {}, { mode: 'edit' }) @@ -427,7 +435,7 @@ describe('MPlanTimeline', () => { expect(mocks.tl.transitLegFor).toHaveBeenCalledWith(SEG) fireEvent.click(screen.getByText('transit.title')) - expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg) + expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg, 11) expect(mocks.tl.setLegMode).not.toHaveBeenCalled() }) diff --git a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx index 118429fbc..11c371cef 100644 --- a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx @@ -375,6 +375,14 @@ describe('TransitRow', () => { expect(onOpenJourney).toHaveBeenCalledTimes(1) }) + + it('FE-MOB-PLROW-026b: a journey that now arrives after the next city walk starts says so under its strip (CW-08)', () => { + const { rerender } = render() + expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00') + + rerender() + expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument() + }) }) describe('ConnRow', () => { diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx index 8a442ba84..14229182d 100644 --- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -80,15 +80,17 @@ vi.mock('../../../../src/components/Planner/LocationSelect', () => ({ })) vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({ - default: ({ day, places, onAdd, initialFrom, initialTime }: { + default: ({ day, places, onAdd, initialFrom, initialTime, walk }: { day: { id: number } places: { name: string }[] onAdd: (p: Record) => void initialFrom: { name: string } | null initialTime: string | null + walk: { fromWalk: string } | null }) => (
{day.id} + {walk?.fromWalk ?? ''} {places.map(p => p.name).join(',')} {initialFrom?.name ?? ''} {initialTime ?? ''} @@ -964,6 +966,17 @@ describe('MTransportFormSheet', () => { expect(screen.getByTestId('transit-from')).toHaveTextContent('Hotel') // A leg picked from the timeline's connector menu carries its departure (#2398). expect(screen.getByTestId('transit-time')).toHaveTextContent('08:15') + expect(screen.getByTestId('transit-walk')).toBeEmptyDOMElement() + }) + + it('FE-MOB-TRFRM-039b: a leg between two city walks hands the panel what it says of them (CW-08)', () => { + const walk = { dayId: 12, fromWalk: 'Old town', toWalk: 'Harbour', ends: null, endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' } + renderSheet(makePlanner({ + transportModalAutomated: true, + transportModalDayId: 12, + transitPrefill: { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: null, time: null, walk }, + })) + expect(screen.getByTestId('transit-walk')).toHaveTextContent('Old town') }) it('FE-MOB-TRFRM-040: the transit panel saves its itinerary through the shared save path', async () => { diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx b/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx index 124f252ed..6cfdb74cb 100644 --- a/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx @@ -1,11 +1,18 @@ import { describe, expect, it, vi } from 'vitest' -import { act, renderHook } from '../../../helpers/render' +import type { TourListItem } from '@trek/shared' +import { act, renderHook, waitFor } from '../../../helpers/render' import { buildPlanner } from '../../../helpers/mobileTrip' -import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../helpers/factories' +import { buildAssignment, buildDay, buildPlace, buildReservation, buildTrip } from '../../../helpers/factories' import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline' +import { tourRepo } from '../../../../src/repo/tourRepo' import type { RouteSegment } from '../../../../src/types' -// FE-MOB-PLTL-HOOK-001 to FE-MOB-PLTL-HOOK-002: public transit for one leg (#2398) +// FE-MOB-PLTL-HOOK-001 to FE-MOB-PLTL-HOOK-004: public transit for one leg (#2398), between two city walks (CW-08) + +vi.mock('../../../../src/repo/tourRepo', async importOriginal => { + const actual = await importOriginal() + return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } } +}) vi.mock('../../../../src/hooks/useRouteCalculation', () => ({ useRouteCalculation: () => ({ routeSegments: [] }), @@ -41,7 +48,7 @@ describe('useMPlanTimeline, public transit for one leg', () => { time: '09:30', }) - act(() => result.current.planTransitLeg(leg!)) + act(() => result.current.planTransitLeg(leg!, 11)) expect(p.setTransportModalDayId).toHaveBeenCalledWith(5) expect(p.setEditingTransport).toHaveBeenCalledWith(null) @@ -57,3 +64,55 @@ describe('useMPlanTimeline, public transit for one leg', () => { expect(result.current.transitLegFor(SEG)).toBeNull() }) }) + +describe('useMPlanTimeline, public transit between two city walks (CW-08)', () => { + const oldTown = buildPlace({ id: 51, name: 'Old town', lat: 52.52, lng: 13.4, route_geometry: '[[52.52,13.4],[52.51,13.42]]' }) + const harbour = buildPlace({ id: 52, name: 'Harbour', lat: 52.5, lng: 13.45, route_geometry: '[[52.5,13.45],[52.49,13.46]]' }) + const walk = (place: { id: number; name: string }) => ({ + place_id: place.id, name: place.name, tour_type: 'walk', duration: 90, planned_duration_minutes: null, + station_count: 2, station_stay_minutes: 45, station_stays_unknown: 0, + }) as TourListItem + const walks = { + '5': [ + buildAssignment({ id: 21, day_id: 5, order_index: 0, tour_place_id: 51, place: { ...oldTown, place_time: '10:00' } }), + buildAssignment({ id: 22, day_id: 5, order_index: 1, tour_place_id: 52, place: { ...harbour, place_time: '13:00' } }), + ], + } + const walkPlanner = (reservations = [] as ReturnType[]) => buildPlanner({ + trip: buildTrip({ id: 1, start_date: '2026-05-01', end_date: '2026-05-05' }), days: [DAY], selectedDayId: DAY.id, + assignments: walks, reservations, places: [oldTown, harbour], toursEnabled: true, tours: [walk(oldTown), walk(harbour)], + } as never) + + it('FE-MOB-PLTL-HOOK-003: opens the search from the last stop to the first, when the first walk ends', async () => { + vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) => Promise.resolve({ + waypoints: [{ lat: 0, lng: 0, role: 'via', sequence: 0, key: `${placeId}`, name: placeId === 51 ? 'Old bridge' : 'Fish market' }], + } as never)) + const p = walkPlanner() + const { result } = renderHook(() => useMPlanTimeline(p)) + + const leg = result.current.transitLegFor({ from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment) + if (!leg) throw new Error('no leg between the walks') + act(() => result.current.planTransitLeg(leg, 21)) + + await waitFor(() => expect(p.setTransitPrefill).toHaveBeenCalledWith({ + from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, + to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, + time: '12:15', + walk: { dayId: 5, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' }, + })) + expect(p.setTransportModalDayId).toHaveBeenCalledWith(5) + expect(p.setShowTransportModal).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-PLTL-HOOK-004: says a saved journey arrives after the next walk starts', () => { + const journey = buildReservation({ + id: 300, type: 'transit', title: 'Old bridge → Fish market', day_id: 5, + reservation_time: '2026-05-02T12:20', reservation_end_time: '2026-05-02T13:10', + }) + const { result } = renderHook(() => useMPlanTimeline(walkPlanner([journey]))) + + const item = result.current.merged.find(it => it.type === 'transport') + if (!item) throw new Error('no journey on the day') + expect(result.current.journeyLate(item)).toEqual({ walk: 'Harbour', time: '13:00' }) + }) +}) diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 441e7f41c..e94a290d9 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -593,6 +593,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': 'مكان في الرحلة', 'tours.walk.hours.sourceOsmHint': 'وُجدت مع المحطة على OpenStreetMap؛ قد تكون قديمة.', 'tours.walk.hours.sourcePlaceHint': 'أُدخلت لهذا المكان من الرحلة.', + 'tours.walk.transit.endOf': 'نهاية جولة {walk}', + 'tours.walk.transit.startOf': 'بداية جولة {walk}', + 'tours.walk.transit.ends': 'تنتهي جولة {walk} نحو {time}.', + 'tours.walk.transit.endsAtLeast': 'تنتهي جولة {walk} في {time} على أقرب تقدير، فبعض أوقاتها غير معروف بعد.', + 'tours.walk.transit.endUnknown': 'موعد انتهاء جولة {walk} غير معروف بعد، لذا اختر وقت المغادرة.', + 'tours.walk.transit.endsNextDay': 'تنتهي جولة {walk} بعد منتصف الليل، لذا اختر وقت المغادرة.', + 'tours.walk.transit.nextStarts': 'تبدأ جولة {walk} في {time}.', + 'tours.walk.transit.late': 'الوصول بعد بدء جولة {walk} في {time}', '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 724f15de3..39f669a16 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -592,6 +592,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': '{walk} sonu', + 'tours.walk.transit.startOf': '{walk} başlanğıcı', + 'tours.walk.transit.ends': '{walk} təxminən {time} bitir.', + 'tours.walk.transit.endsAtLeast': '{walk} ən tez {time} bitir; vaxtların hamısı hələ məlum deyil.', + 'tours.walk.transit.endUnknown': '{walk} gəzintisinin bitmə vaxtı hələ məlum deyil, ona görə yola düşmə vaxtını seç.', + 'tours.walk.transit.endsNextDay': '{walk} gəzintisi gecə yarısından sonra bitir, ona görə yola düşmə vaxtını seç.', + 'tours.walk.transit.nextStarts': '{walk} {time} başlayır.', + 'tours.walk.transit.late': '{walk} {time} başlayır, çatma isə daha gec olur', '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 bb29dee29..e495a32d7 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -597,6 +597,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Fim de {walk}', + 'tours.walk.transit.startOf': 'Início de {walk}', + 'tours.walk.transit.ends': '{walk} termina por volta das {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} termina não antes das {time}; alguns horários ainda não são conhecidos.', + 'tours.walk.transit.endUnknown': 'Ainda não se sabe quando {walk} termina, então escolha um horário de saída.', + 'tours.walk.transit.endsNextDay': '{walk} só termina depois da meia-noite, então escolha um horário de saída.', + 'tours.walk.transit.nextStarts': '{walk} começa às {time}.', + 'tours.walk.transit.late': 'Chega depois do início de {walk}, às {time}', '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 bbee0a430..de0dbd59c 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -598,6 +598,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Final de {walk}', + 'tours.walk.transit.startOf': 'Inici de {walk}', + 'tours.walk.transit.ends': '{walk} acaba cap a les {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} acaba com a aviat a les {time}; encara no se saben tots els temps.', + 'tours.walk.transit.endUnknown': 'Encara no se sap quan acaba {walk}, així que tria una hora de sortida.', + 'tours.walk.transit.endsNextDay': '{walk} acaba després de mitjanit, així que tria una hora de sortida.', + 'tours.walk.transit.nextStarts': '{walk} comença a les {time}.', + 'tours.walk.transit.late': 'Arriba després que comenci {walk}, a les {time}', '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 a29e6630c..16f54b6cb 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -585,6 +585,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Konec procházky {walk}', + 'tours.walk.transit.startOf': 'Začátek procházky {walk}', + 'tours.walk.transit.ends': 'Procházka {walk} končí zhruba v {time}.', + 'tours.walk.transit.endsAtLeast': 'Procházka {walk} končí nejdříve v {time}; zatím nejsou známé všechny časy.', + 'tours.walk.transit.endUnknown': 'Kdy procházka {walk} končí, zatím není známo, proto vyber čas odjezdu.', + 'tours.walk.transit.endsNextDay': 'Procházka {walk} končí až po půlnoci, proto vyber čas odjezdu.', + 'tours.walk.transit.nextStarts': 'Procházka {walk} začíná v {time}.', + 'tours.walk.transit.late': 'Příjezd až po začátku procházky {walk} v {time}', '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 7cd59f6fa..c5dbd2931 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -602,6 +602,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Ende von {walk}', + 'tours.walk.transit.startOf': 'Start von {walk}', + 'tours.walk.transit.ends': '{walk} endet gegen {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} endet frühestens um {time}; noch sind nicht alle Zeiten bekannt.', + 'tours.walk.transit.endUnknown': 'Wann {walk} endet, ist noch nicht bekannt. Wähle deshalb eine Abfahrtszeit.', + 'tours.walk.transit.endsNextDay': '{walk} endet erst nach Mitternacht. Wähle deshalb eine Abfahrtszeit.', + 'tours.walk.transit.nextStarts': '{walk} beginnt um {time}.', + 'tours.walk.transit.late': 'Ankunft nach dem Start von {walk} um {time}', '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 a9893482a..c128ebbb9 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -586,6 +586,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'End of {walk}', + 'tours.walk.transit.startOf': 'Start of {walk}', + 'tours.walk.transit.ends': '{walk} ends at about {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} ends at {time} at the earliest; not all of its times are known yet.', + 'tours.walk.transit.endUnknown': 'When {walk} ends is not known yet, so choose a departure time.', + 'tours.walk.transit.endsNextDay': '{walk} only ends after midnight, so choose a departure time.', + 'tours.walk.transit.nextStarts': '{walk} starts at {time}.', + 'tours.walk.transit.late': 'Arrives after {walk} starts at {time}', '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 47cddb93e..4bdf3a043 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -597,6 +597,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Final de {walk}', + 'tours.walk.transit.startOf': 'Inicio de {walk}', + 'tours.walk.transit.ends': '{walk} termina hacia las {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} termina no antes de las {time}; aún no se conocen todos sus tiempos.', + 'tours.walk.transit.endUnknown': 'Aún no se sabe cuándo termina {walk}, así que elige una hora de salida.', + 'tours.walk.transit.endsNextDay': '{walk} termina después de medianoche, así que elige una hora de salida.', + 'tours.walk.transit.nextStarts': '{walk} empieza a las {time}.', + 'tours.walk.transit.late': 'Llega después de que empiece {walk}, a las {time}', '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 f21bdd023..fa1fe2039 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -588,6 +588,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': '{walk} lõpp', + 'tours.walk.transit.startOf': '{walk} algus', + 'tours.walk.transit.ends': '{walk} lõpeb umbes kell {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} lõpeb kõige varem kell {time}; kõik ajad pole veel teada.', + 'tours.walk.transit.endUnknown': 'Millal {walk} lõpeb, pole veel teada, seega vali väljumisaeg.', + 'tours.walk.transit.endsNextDay': '{walk} lõpeb alles pärast keskööd, seega vali väljumisaeg.', + 'tours.walk.transit.nextStarts': '{walk} algab kell {time}.', + 'tours.walk.transit.late': 'Saabub pärast seda, kui {walk} algab kell {time}', '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 68bde3b39..b4910b481 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -604,6 +604,15 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Fin de {walk}', + 'tours.walk.transit.startOf': 'Début de {walk}', + 'tours.walk.transit.ends': '{walk} se termine vers {time}.', + 'tours.walk.transit.endsAtLeast': + '{walk} se termine au plus tôt à {time} ; certains temps ne sont pas encore connus.', + 'tours.walk.transit.endUnknown': 'On ne sait pas encore quand {walk} se termine : choisis une heure de départ.', + 'tours.walk.transit.endsNextDay': '{walk} se termine après minuit : choisis une heure de départ.', + 'tours.walk.transit.nextStarts': '{walk} commence à {time}.', + 'tours.walk.transit.late': 'Arrivée après le début de {walk} à {time}', '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 1e564106f..3bcf57e54 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -606,6 +606,16 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': 'Μέρος του ταξιδιού', 'tours.walk.hours.sourceOsmHint': 'Βρέθηκε μαζί με τη στάση στο OpenStreetMap. Μπορεί να μην είναι ενημερωμένο.', 'tours.walk.hours.sourcePlaceHint': 'Καταχωρίστηκε για αυτό το μέρος του ταξιδιού.', + 'tours.walk.transit.endOf': 'Τέλος της βόλτας {walk}', + 'tours.walk.transit.startOf': 'Αρχή της βόλτας {walk}', + 'tours.walk.transit.ends': 'Η βόλτα {walk} τελειώνει γύρω στις {time}.', + 'tours.walk.transit.endsAtLeast': + 'Η βόλτα {walk} τελειώνει το νωρίτερο στις {time}, αφού δεν είναι ακόμη γνωστοί όλοι οι χρόνοι.', + 'tours.walk.transit.endUnknown': + 'Δεν είναι ακόμη γνωστό πότε τελειώνει η βόλτα {walk}, οπότε διάλεξε ώρα αναχώρησης.', + 'tours.walk.transit.endsNextDay': 'Η βόλτα {walk} τελειώνει μετά τα μεσάνυχτα, οπότε διάλεξε ώρα αναχώρησης.', + 'tours.walk.transit.nextStarts': 'Η βόλτα {walk} ξεκινά στις {time}.', + 'tours.walk.transit.late': 'Άφιξη μετά την έναρξη της βόλτας {walk} στις {time}', '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 b05bb168a..295d6a172 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -598,6 +598,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': '{walk} vége', + 'tours.walk.transit.startOf': '{walk} kezdete', + 'tours.walk.transit.ends': '{walk}: a séta kb. {time} órakor ér véget.', + 'tours.walk.transit.endsAtLeast': '{walk}: a séta legkorábban {time} órakor ér véget; még nem ismert minden időpont.', + 'tours.walk.transit.endUnknown': '{walk}: a séta vége még nem ismert, ezért válassz indulási időt.', + 'tours.walk.transit.endsNextDay': '{walk}: a séta csak éjfél után ér véget, ezért válassz indulási időt.', + 'tours.walk.transit.nextStarts': '{walk}: a séta {time} órakor kezdődik.', + 'tours.walk.transit.late': 'Érkezés a séta kezdete után: {walk}, {time}', '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 7f5120144..03e5c6e2a 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -590,6 +590,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Akhir {walk}', + 'tours.walk.transit.startOf': 'Awal {walk}', + 'tours.walk.transit.ends': '{walk} selesai sekitar pukul {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} selesai paling cepat pukul {time}; belum semua waktunya diketahui.', + 'tours.walk.transit.endUnknown': 'Belum diketahui kapan {walk} selesai, jadi pilih waktu berangkat.', + 'tours.walk.transit.endsNextDay': '{walk} baru selesai setelah tengah malam, jadi pilih waktu berangkat.', + 'tours.walk.transit.nextStarts': '{walk} dimulai pukul {time}.', + 'tours.walk.transit.late': 'Tiba setelah {walk} dimulai pukul {time}', '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 74605879b..28f057d42 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -599,6 +599,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Fine di {walk}', + 'tours.walk.transit.startOf': 'Inizio di {walk}', + 'tours.walk.transit.ends': '{walk} finisce verso le {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} finisce non prima delle {time}; non tutti i tempi sono ancora noti.', + 'tours.walk.transit.endUnknown': 'Non si sa ancora quando finisce {walk}, quindi scegli un orario di partenza.', + 'tours.walk.transit.endsNextDay': '{walk} finisce dopo mezzanotte, quindi scegli un orario di partenza.', + 'tours.walk.transit.nextStarts': '{walk} inizia alle {time}.', + 'tours.walk.transit.late': "Arrivo dopo l'inizio di {walk} alle {time}", '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 25332f132..4300fd36b 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -577,6 +577,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': '旅行の場所', 'tours.walk.hours.sourceOsmHint': '立ち寄り先と一緒に OpenStreetMap から取得。古い可能性があります。', 'tours.walk.hours.sourcePlaceHint': 'この旅行の場所に入力されたものです。', + 'tours.walk.transit.endOf': '{walk} の終点', + 'tours.walk.transit.startOf': '{walk} の起点', + 'tours.walk.transit.ends': '{walk} は {time} ごろに終わります。', + 'tours.walk.transit.endsAtLeast': '{walk} の終了は早くても {time} です。まだ分からない時間があります。', + 'tours.walk.transit.endUnknown': '{walk} がいつ終わるかはまだ分かりません。出発時刻を選んでください。', + 'tours.walk.transit.endsNextDay': '{walk} は深夜0時を過ぎてから終わります。出発時刻を選んでください。', + 'tours.walk.transit.nextStarts': '{walk} は {time} に始まります。', + 'tours.walk.transit.late': '{walk} の開始({time})より後に到着', '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 271713a96..625f50401 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -575,6 +575,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': '여행 장소', 'tours.walk.hours.sourceOsmHint': '정류지와 함께 OpenStreetMap에서 가져왔습니다. 오래된 정보일 수 있습니다.', 'tours.walk.hours.sourcePlaceHint': '이 여행 장소에 입력된 시간입니다.', + 'tours.walk.transit.endOf': '{walk} 종료 지점', + 'tours.walk.transit.startOf': '{walk} 시작 지점', + 'tours.walk.transit.ends': '{walk}은(는) {time}쯤 끝납니다.', + 'tours.walk.transit.endsAtLeast': '{walk}은(는) 빨라야 {time}에 끝납니다. 아직 모르는 시간이 있습니다.', + 'tours.walk.transit.endUnknown': '{walk}이(가) 언제 끝나는지 아직 모릅니다. 출발 시간을 선택하세요.', + 'tours.walk.transit.endsNextDay': '{walk}은(는) 자정이 지나서 끝납니다. 출발 시간을 선택하세요.', + 'tours.walk.transit.nextStarts': '{walk}은(는) {time}에 시작합니다.', + 'tours.walk.transit.late': '{walk} 시작({time}) 이후 도착', '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 b302e6d48..fa68f110b 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -599,6 +599,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Einde van {walk}', + 'tours.walk.transit.startOf': 'Begin van {walk}', + 'tours.walk.transit.ends': '{walk} eindigt rond {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} eindigt op zijn vroegst om {time}; nog niet alle tijden zijn bekend.', + 'tours.walk.transit.endUnknown': 'Nog niet bekend wanneer {walk} eindigt, dus kies een vertrektijd.', + 'tours.walk.transit.endsNextDay': '{walk} eindigt pas na middernacht, dus kies een vertrektijd.', + 'tours.walk.transit.nextStarts': '{walk} begint om {time}.', + 'tours.walk.transit.late': 'Aankomst na de start van {walk} om {time}', '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 d8e23bd5b..e84cf3acf 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -606,6 +606,15 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Koniec trasy {walk}', + 'tours.walk.transit.startOf': 'Początek trasy {walk}', + 'tours.walk.transit.ends': 'Trasa {walk} kończy się około {time}.', + 'tours.walk.transit.endsAtLeast': + 'Trasa {walk} kończy się najwcześniej o {time}; nie wszystkie czasy są jeszcze znane.', + 'tours.walk.transit.endUnknown': 'Nie wiadomo jeszcze, kiedy kończy się trasa {walk}, więc wybierz godzinę odjazdu.', + 'tours.walk.transit.endsNextDay': 'Trasa {walk} kończy się dopiero po północy, więc wybierz godzinę odjazdu.', + 'tours.walk.transit.nextStarts': 'Trasa {walk} zaczyna się o {time}.', + 'tours.walk.transit.late': 'Przyjazd po rozpoczęciu trasy {walk} o {time}', '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 6f290dac0..74efca2d1 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -608,6 +608,16 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': 'Место поездки', 'tours.walk.hours.sourceOsmHint': 'Найдено вместе с остановкой в OpenStreetMap; может быть устаревшим.', 'tours.walk.hours.sourcePlaceHint': 'Указано для этого места поездки.', + 'tours.walk.transit.endOf': 'Конец прогулки {walk}', + 'tours.walk.transit.startOf': 'Начало прогулки {walk}', + 'tours.walk.transit.ends': 'Прогулка {walk} заканчивается около {time}.', + 'tours.walk.transit.endsAtLeast': 'Прогулка {walk} заканчивается не раньше {time}; известно ещё не всё время.', + 'tours.walk.transit.endUnknown': + 'Пока неизвестно, когда заканчивается прогулка {walk}, поэтому выбери время отправления.', + 'tours.walk.transit.endsNextDay': + 'Прогулка {walk} заканчивается только после полуночи, поэтому выбери время отправления.', + 'tours.walk.transit.nextStarts': 'Прогулка {walk} начинается в {time}.', + 'tours.walk.transit.late': 'Прибытие после начала прогулки {walk} в {time}', '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 40828a716..0573c1291 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -587,6 +587,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Koniec prechádzky {walk}', + 'tours.walk.transit.startOf': 'Začiatok prechádzky {walk}', + 'tours.walk.transit.ends': 'Prechádzka {walk} končí približne o {time}.', + 'tours.walk.transit.endsAtLeast': 'Prechádzka {walk} končí najskôr o {time}; zatiaľ nie sú známe všetky časy.', + 'tours.walk.transit.endUnknown': 'Zatiaľ nie je známe, kedy prechádzka {walk} končí, preto vyber čas odchodu.', + 'tours.walk.transit.endsNextDay': 'Prechádzka {walk} končí až po polnoci, preto vyber čas odchodu.', + 'tours.walk.transit.nextStarts': 'Prechádzka {walk} začína o {time}.', + 'tours.walk.transit.late': 'Príchod po začiatku prechádzky {walk} o {time}', '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 24c0fe003..05083b8f2 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -586,6 +586,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Slutet av {walk}', + 'tours.walk.transit.startOf': 'Början av {walk}', + 'tours.walk.transit.ends': '{walk} slutar runt kl. {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} slutar tidigast kl. {time}; alla tider är inte kända än.', + 'tours.walk.transit.endUnknown': 'Det är ännu inte känt när {walk} slutar, så välj en avgångstid.', + 'tours.walk.transit.endsNextDay': '{walk} slutar först efter midnatt, så välj en avgångstid.', + 'tours.walk.transit.nextStarts': '{walk} börjar kl. {time}.', + 'tours.walk.transit.late': 'Ankomst efter att {walk} börjar kl. {time}', '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 47fb674af..e84c0c784 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -563,6 +563,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': 'สถานที่ในทริป', 'tours.walk.hours.sourceOsmHint': 'พบพร้อมจุดแวะใน OpenStreetMap ข้อมูลอาจไม่เป็นปัจจุบัน', 'tours.walk.hours.sourcePlaceHint': 'ใส่ไว้สำหรับสถานที่นี้ในทริป', + 'tours.walk.transit.endOf': 'จุดสิ้นสุดของ {walk}', + 'tours.walk.transit.startOf': 'จุดเริ่มต้นของ {walk}', + 'tours.walk.transit.ends': '{walk} จบประมาณ {time}', + 'tours.walk.transit.endsAtLeast': '{walk} จบเร็วที่สุด {time} เพราะยังไม่ทราบเวลาบางส่วน', + 'tours.walk.transit.endUnknown': 'ยังไม่ทราบว่า {walk} จบเมื่อใด จึงต้องเลือกเวลาออกเดินทางเอง', + 'tours.walk.transit.endsNextDay': '{walk} จบหลังเที่ยงคืน จึงต้องเลือกเวลาออกเดินทางเอง', + 'tours.walk.transit.nextStarts': '{walk} เริ่ม {time}', + 'tours.walk.transit.late': 'ถึงหลังจาก {walk} เริ่มเวลา {time}', '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 7aea6fd10..bd1176f76 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 = 817; +const GENERAL_KEY_COUNT = 825; 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 e52c22d0b..3c30fa0ec 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -592,6 +592,15 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': '{walk} bitişi', + 'tours.walk.transit.startOf': '{walk} başlangıcı', + 'tours.walk.transit.ends': '{walk} yaklaşık {time} saatinde biter.', + 'tours.walk.transit.endsAtLeast': '{walk} en erken {time} saatinde biter; tüm süreler henüz bilinmiyor.', + 'tours.walk.transit.endUnknown': + '{walk} yürüyüşünün ne zaman bittiği henüz bilinmiyor, bu yüzden bir kalkış saati seç.', + 'tours.walk.transit.endsNextDay': '{walk} yürüyüşü gece yarısından sonra bitiyor, bu yüzden bir kalkış saati seç.', + 'tours.walk.transit.nextStarts': '{walk} {time} saatinde başlar.', + 'tours.walk.transit.late': 'Varış, {walk} {time} saatinde başladıktan sonra', '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 5e9018d9b..24d3511ea 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -603,6 +603,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': 'Місце подорожі', 'tours.walk.hours.sourceOsmHint': 'Знайдено разом із зупинкою в OpenStreetMap; може бути застарілим.', 'tours.walk.hours.sourcePlaceHint': 'Вказано для цього місця подорожі.', + 'tours.walk.transit.endOf': 'Кінець прогулянки {walk}', + 'tours.walk.transit.startOf': 'Початок прогулянки {walk}', + 'tours.walk.transit.ends': 'Прогулянка {walk} закінчується близько {time}.', + 'tours.walk.transit.endsAtLeast': 'Прогулянка {walk} закінчується не раніше {time}; ще відомий не весь час.', + 'tours.walk.transit.endUnknown': 'Поки невідомо, коли закінчується прогулянка {walk}, тож вибери час відправлення.', + 'tours.walk.transit.endsNextDay': 'Прогулянка {walk} закінчується лише після опівночі, тож вибери час відправлення.', + 'tours.walk.transit.nextStarts': 'Прогулянка {walk} починається о {time}.', + 'tours.walk.transit.late': 'Прибуття після початку прогулянки {walk} о {time}', '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 b959b3e78..9ff4301e8 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -585,6 +585,14 @@ const tours: TranslationStrings = { '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.walk.transit.endOf': 'Điểm cuối của {walk}', + 'tours.walk.transit.startOf': 'Điểm đầu của {walk}', + 'tours.walk.transit.ends': '{walk} kết thúc khoảng {time}.', + 'tours.walk.transit.endsAtLeast': '{walk} kết thúc sớm nhất lúc {time}; vẫn chưa biết hết các mốc thời gian.', + 'tours.walk.transit.endUnknown': 'Chưa biết khi nào {walk} kết thúc, nên hãy chọn giờ khởi hành.', + 'tours.walk.transit.endsNextDay': '{walk} kết thúc sau nửa đêm, nên hãy chọn giờ khởi hành.', + 'tours.walk.transit.nextStarts': '{walk} bắt đầu lúc {time}.', + 'tours.walk.transit.late': 'Đến sau khi {walk} bắt đầu lúc {time}', '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 8b896dba6..6648b3e97 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -543,6 +543,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': '行程地點', 'tours.walk.hours.sourceOsmHint': '隨站點從 OpenStreetMap 取得,可能已過時。', 'tours.walk.hours.sourcePlaceHint': '為行程中的這個地點填寫。', + 'tours.walk.transit.endOf': '{walk} 終點', + 'tours.walk.transit.startOf': '{walk} 起點', + 'tours.walk.transit.ends': '{walk} 約 {time} 結束。', + 'tours.walk.transit.endsAtLeast': '{walk} 最早 {time} 結束;部分時間尚不清楚。', + 'tours.walk.transit.endUnknown': '尚不清楚 {walk} 何時結束,請選擇出發時間。', + 'tours.walk.transit.endsNextDay': '{walk} 要到午夜之後才結束,請選擇出發時間。', + 'tours.walk.transit.nextStarts': '{walk} {time} 開始。', + 'tours.walk.transit.late': '抵達晚於 {walk} 的開始時間 {time}', '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 550f1b17c..de7d792ea 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -544,6 +544,14 @@ const tours: TranslationStrings = { 'tours.walk.hours.sourcePlace': '行程地点', 'tours.walk.hours.sourceOsmHint': '随站点从 OpenStreetMap 获取,可能已过时。', 'tours.walk.hours.sourcePlaceHint': '为行程中的这个地点填写。', + 'tours.walk.transit.endOf': '{walk} 终点', + 'tours.walk.transit.startOf': '{walk} 起点', + 'tours.walk.transit.ends': '{walk} 约 {time} 结束。', + 'tours.walk.transit.endsAtLeast': '{walk} 最早 {time} 结束;部分时间尚不清楚。', + 'tours.walk.transit.endUnknown': '尚不清楚 {walk} 何时结束,请选择出发时间。', + 'tours.walk.transit.endsNextDay': '{walk} 要到午夜之后才结束,请选择出发时间。', + 'tours.walk.transit.nextStarts': '{walk} {time} 开始。', + 'tours.walk.transit.late': '到达晚于 {walk} 的开始时间 {time}', 'tours.poi.categoriesNone': '未选择类别', 'tours.poi.categoriesMore': '{first} +{more}', 'tours.planner.tourType': '路线类型',