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': '路线类型',