diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 0e2bfeae2..f7cbb3f47 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -3,7 +3,7 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import React, { useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, LogOut, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -837,7 +837,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content-secondary" style={{ fontSize: FS.name }} > - {stop.name}{stop.stopType === 'charging' && } + {stop.name}{stop.stopType === 'charging' && } @@ -951,28 +951,6 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea ) } -/** - * The arrival — the one value in the rail the user may have decided themselves. - * - * A pinned time is filled, pilled and carries the clock; a computed one is bare text. - * Fill, shape and icon are three signals where a font weight used to be one, and this is - * the state the whole view gets opened for. It is also the only filled accent left in the - * rail: selection moved to the row's own background, so nothing else competes with it. - */ -function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: ScheduleEntry }): React.ReactElement | null { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - if (stop.checkoutAt === undefined || !entry?.departure) return null - return - - {/* Leaving, not just a time of day: this chip is the one clock in the rail that - says when you give the room back, and beside an arrival it read as a second - arrival. The door-out mark is what a hotel desk calls check-out. */} - {formatClockTime(entry.departure, is12h)} - - -} - function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement { const { t } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' @@ -1108,7 +1086,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }} > - {stop.name}{stop.stopType === 'charging' && } + {stop.name}{stop.stopType === 'charging' && } {/* Two halves under one border: the word says what the number means, so the number needs no unit of explanation beside it. */} @@ -1271,7 +1249,7 @@ function SpillBlock({ spill, children }: { The stop it leaves from is not named — it is drawn on yesterday's card directly above, and a second row for it would read as a stop made twice. */} - {departure && spill.fromStop?.checkoutAt === undefined ? ( + {departure ? ( { wrap() expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByText('Check out on')).toBeInTheDocument() + // Check-in and nothing else. A check-out says when the room has to be back, never + // when anybody drives on, so the drive has no use for it and does not ask. + expect(screen.getByRole('textbox', { name: 'Check-in' })).toBeInTheDocument() + expect(screen.queryByText('Check out on')).not.toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: 'Check-out' })).not.toBeInTheDocument() // The two questions a pause asks are gone: a hotel has no kind and no dwell. expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument() }) @@ -285,7 +289,10 @@ describe('editing a roadtrip stop', () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 })) }) - it('loads and preserves an existing overnight booking', async () => { + it('loads an existing overnight booking and keeps its check-in', async () => { + // The stored check-out is left where it is rather than resaved: this surface no + // longer asks for one, and writing back a blank would quietly drop what somebody + // entered under Days. const onSaveNight = vi.fn() wrap() fireEvent.click(screen.getByRole('button', { name: 'Save' })) diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index c8b29cbb1..f910b35c3 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -1,12 +1,10 @@ import React, { useState } from 'react' import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react' import Modal from '../shared/Modal' -import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' import StayPortals from './StayPortals' import { useTranslation } from '../../i18n/TranslationContext' import { safeExternalHref } from '../../utils/safeUrl' -import { formatDate } from '../../utils/formatters' import { formatDurationShort } from './roadtripModel' import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds' import type { CorridorPoi } from './useCorridorPois' @@ -76,7 +74,7 @@ interface RoadtripStopPopupProps { export default function RoadtripStopPopup({ draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails, }: RoadtripStopPopupProps): React.ReactElement | null { - const { t, language } = useTranslation() + const { t } = useTranslation() // Through the same guard every other external link in the app goes through. // This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are // routinely written without a scheme — `www.hotel.de` as a raw href is a @@ -91,9 +89,7 @@ export default function RoadtripStopPopup({ // means everywhere else — but both offer the other, because a campsite is somewhere // people sleep and a hotel is somewhere people stop for lunch. const [night, setNight] = useState(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel') - const [endDayId, setEndDayId] = useState(draft?.overnight?.defaultEndDayId ?? null) const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '') - const [checkOut, setCheckOut] = useState(draft?.editing?.checkOut ?? '') const [saving, setSaving] = useState(false) if (!draft) return null @@ -107,9 +103,13 @@ export default function RoadtripStopPopup({ try { if (asNight) { await onSaveNight?.({ - endDayId: endDayId ?? overnight!.defaultEndDayId, + // The night runs to the day the rail suggests. Choosing another one is a + // booking decision, and the booking lives under Days. + endDayId: overnight!.defaultEndDayId, checkIn, - checkOut, + // Passed back exactly as it was loaded. This surface no longer asks for a + // check-out, and sending a blank would quietly erase one entered under Days. + checkOut: draft.editing?.checkOut ?? '', }) } else { await onSave({ stopType, dwellMinutes: dwell }) @@ -178,44 +178,17 @@ export default function RoadtripStopPopup({ {asNight ? (
-
+ {/* Check-in only, and optional at that: it is the hour the drive can stop + for the night, which is the one thing about a booking the chain uses. + A check-out says when the room has to be back, never when anybody sets + off, so it is a booking detail and lives under Days with the rest of + them. A hotel found on a map has no idea when its reception opens. */} +
- {/* Both optional: leaving them empty is normal, and the server stores null. - A hotel found on a map has no idea when its reception opens. */} -
- - -
+ + {websiteHref || draft.poi.phone ? (
{websiteHref ? ( @@ -299,7 +272,7 @@ export default function RoadtripStopPopup({
{overnight ? day.id === draft.dayId)?.date} - departure={overnight.days.find(day => day.id === (endDayId ?? overnight.defaultEndDayId))?.date} /> : null} + departure={overnight.days.find(day => day.id === overnight.defaultEndDayId)?.date} /> : null}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 2e1b27c1c..928e96596 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -3,7 +3,7 @@ import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' -import { splitIntoRuns, parseClock, parseAvoid, type DriveLimits } from './roadtripModel' +import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel' import { spillChains } from './nightSpill' import { useSettingsStore } from '../../store/settingsStore' import { useVehicleRange } from './useVehicleRange' @@ -71,14 +71,14 @@ const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from const EMPTY_ACCOMMODATIONS: Accommodation[] = [] -const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[], days: Day[]): RoadtripStop | null => { +const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => { const p = a.place if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null + // Check-in only. A check-out is the LATEST the room has to be handed back, not the + // earliest anybody may leave, so it says nothing about when the drive sets off and + // has no business in the chain. It stays a booking detail, shown under Days. const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId) - const checkoutDay = days.find(day => day.id === stay?.end_day_id)?.day_number - const checkoutTime = parseClock(stay?.check_out) return { - ...(checkoutDay != null && checkoutTime !== null ? { checkoutAt: checkoutDay * 1440 + checkoutTime } : {}), assignmentId: a.id, ownerDayId, ownerIndex, @@ -205,7 +205,7 @@ export function useRoadtripRoutes( const stops = (assignments[String(d.id)] ?? []) .slice() .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map(a => asStop(a, d.id, 0, accommodations, days)) + .map(a => asStop(a, d.id, 0, accommodations)) .filter((s): s is RoadtripStop => s !== null) // The index is filled in after the drop, because it is the index into THIS // list: an assignment whose place has no coordinates never becomes a stop, and @@ -233,7 +233,7 @@ export function useRoadtripRoutes( stops: (assignments[String(d.id)] ?? []) .slice() .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map(a => asStop(a, d.id, 0, accommodations, days)) + .map(a => asStop(a, d.id, 0, accommodations)) .filter((s): s is RoadtripStop => s !== null) .map((s, i) => ({ ...s, ownerIndex: i })), })) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 8e6418d2a..769cdef12 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -96,10 +96,15 @@ const TransportModal = lazyWithRetry(() => * No label: ErrorBoundary lets label win over the panel level and would title a * broken packing list "This plugin could not be shown". */ -function LazyPanel({ id, children }: { id: string; children: React.ReactNode }): React.ReactElement { +function LazyPanel({ id, children, overlay }: { id: string; children: React.ReactNode; overlay?: boolean }): React.ReactElement { return ( - }> + {/* A panel holds its place with a skeleton while its chunk arrives; a dialog has no + place to hold. Drawn in the page flow, that skeleton was a pale block flashing + under the planner the first time each dialog was ever opened, and never again + once the chunk was cached. Nothing is the right placeholder for something that + is about to cover the screen anyway. */} + }> {children} @@ -1025,7 +1030,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {/* Which track a day drives along. Mounted only while it is open, so the chunk and the parsing of every imported line stay out of an ordinary planner session. */} {followTrack.dayId !== null && ( - + d.dayId === followTrack.dayId)?.dayNumber ?? 0} @@ -1033,12 +1038,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null { )} {stayDraft && ( - + setStayDraft(null)} onSave={setRoadtripStay} /> )} {stopDraft && ( - + ) => { - const stay = tripAccommodations.find(stay => stay.place_id === draft.placeId && stay.check_out) - const place = places.find(place => place.id === draft.placeId) - const visit = stay && assignments[String(stay.start_day_id)]?.find(visit => visit.place_id === draft.placeId) - if (stay && place && visit) openPlaceEditor(place, visit.id) - else setStayDraft(draft) - }, [tripAccommodations, places, assignments, openPlaceEditor]) + setStayDraft(draft) + }, []) const handleDeletePlace = useCallback((placeId) => { setDeletePlaceId(placeId) diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 8ac3a19e0..eb0214c0a 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -143,8 +143,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'وجهة', 'roadtrip.stay.mode.pause': 'استراحة', 'roadtrip.stay.mode.night': 'مبيت', - 'roadtrip.stay.checkOutDay': 'المغادرة في', - 'roadtrip.stay.dayNumber': 'اليوم {number}', 'roadtrip.stay.nightAdded': 'أُضيف كمبيت', 'roadtrip.stay.nightAction': 'إضافة كمبيت', 'roadtrip.stop.makeService': 'اجعله توقفاً في الطريق', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index ae6888f9d..02089c4e5 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Um destino', 'roadtrip.stay.mode.pause': 'Pausa', 'roadtrip.stay.mode.night': 'Pernoite', - 'roadtrip.stay.checkOutDay': 'Saída em', - 'roadtrip.stay.dayNumber': 'Dia {number}', 'roadtrip.stay.nightAdded': 'Adicionado como pernoite', 'roadtrip.stay.nightAction': 'Adicionar como pernoite', 'roadtrip.stop.makeService': 'Tornar uma parada no caminho', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 50cec5f3a..24be49233 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Una destinació', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', - 'roadtrip.stay.checkOutDay': 'Sortida el', - 'roadtrip.stay.dayNumber': 'Dia {number}', 'roadtrip.stay.nightAdded': 'Afegit com a pernoctació', 'roadtrip.stay.nightAction': 'Afegeix com a pernoctació', 'roadtrip.stop.makeService': 'Converteix en parada del camí', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 6d9146fee..3c115cdba 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Cíl cesty', 'roadtrip.stay.mode.pause': 'Pauza', 'roadtrip.stay.mode.night': 'Nocleh', - 'roadtrip.stay.checkOutDay': 'Odjezd', - 'roadtrip.stay.dayNumber': 'Den {number}', 'roadtrip.stay.nightAdded': 'Přidáno jako nocleh', 'roadtrip.stay.nightAction': 'Přidat jako nocleh', 'roadtrip.stop.makeService': 'Změnit na zastávku po cestě', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index eaf440e12..ff4e88a0c 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -143,8 +143,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Ein Ziel', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Übernachten', - 'roadtrip.stay.checkOutDay': 'Abreise am', - 'roadtrip.stay.dayNumber': 'Tag {number}', 'roadtrip.stay.nightAdded': 'Als Übernachtung hinzugefügt', 'roadtrip.stay.nightAction': 'Als Übernachtung hinzufügen', 'roadtrip.stop.makeService': 'Zum Halt unterwegs machen', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 0a369764f..bfec101a3 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -142,8 +142,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'A destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Overnight', - 'roadtrip.stay.checkOutDay': 'Check out on', - 'roadtrip.stay.dayNumber': 'Day {number}', 'roadtrip.stay.nightAdded': 'Added as an overnight stay', 'roadtrip.stay.nightAction': 'Add as an overnight stay', 'roadtrip.stop.makeService': 'Make it a stop on the way', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 5678884b2..4dc64dcb9 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Un destino', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', - 'roadtrip.stay.checkOutDay': 'Salida el', - 'roadtrip.stay.dayNumber': 'Día {number}', 'roadtrip.stay.nightAdded': 'Añadido como pernoctación', 'roadtrip.stay.nightAction': 'Añadir como pernoctación', 'roadtrip.stop.makeService': 'Convertir en parada del camino', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index a60999a0f..29353a7e0 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -148,8 +148,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Une destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Nuitée', - 'roadtrip.stay.checkOutDay': 'Départ le', - 'roadtrip.stay.dayNumber': 'Jour {number}', 'roadtrip.stay.nightAdded': 'Ajouté comme nuitée', 'roadtrip.stay.nightAction': 'Ajouter comme nuitée', 'roadtrip.stop.makeService': 'En faire un arrêt en chemin', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index a92bf714f..22c443bcb 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -149,8 +149,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Προορισμός', 'roadtrip.stay.mode.pause': 'Στάση', 'roadtrip.stay.mode.night': 'Διανυκτέρευση', - 'roadtrip.stay.checkOutDay': 'Αναχώρηση', - 'roadtrip.stay.dayNumber': 'Ημέρα {number}', 'roadtrip.stay.nightAdded': 'Προστέθηκε ως διανυκτέρευση', 'roadtrip.stay.nightAction': 'Προσθήκη ως διανυκτέρευση', 'roadtrip.stop.makeService': 'Μετατροπή σε στάση στον δρόμο', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 25f768069..af2f577eb 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Úti cél', 'roadtrip.stay.mode.pause': 'Szünet', 'roadtrip.stay.mode.night': 'Éjszakázás', - 'roadtrip.stay.checkOutDay': 'Kijelentkezés', - 'roadtrip.stay.dayNumber': '{number}. nap', 'roadtrip.stay.nightAdded': 'Hozzáadva éjszakázásként', 'roadtrip.stay.nightAction': 'Hozzáadás éjszakázásként', 'roadtrip.stop.makeService': 'Legyen útközbeni megálló', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index e44a1f254..1b746dae6 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Sebuah tujuan', 'roadtrip.stay.mode.pause': 'Istirahat', 'roadtrip.stay.mode.night': 'Menginap', - 'roadtrip.stay.checkOutDay': 'Check out', - 'roadtrip.stay.dayNumber': 'Hari {number}', 'roadtrip.stay.nightAdded': 'Ditambahkan sebagai menginap', 'roadtrip.stay.nightAction': 'Tambahkan sebagai menginap', 'roadtrip.stop.makeService': 'Jadikan perhentian di jalan', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 1b8f41cdc..9d7498128 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Una destinazione', 'roadtrip.stay.mode.pause': 'Sosta', 'roadtrip.stay.mode.night': 'Pernottamento', - 'roadtrip.stay.checkOutDay': 'Partenza il', - 'roadtrip.stay.dayNumber': 'Giorno {number}', 'roadtrip.stay.nightAdded': 'Aggiunto come pernottamento', 'roadtrip.stay.nightAction': 'Aggiungi come pernottamento', 'roadtrip.stop.makeService': 'Trasforma in sosta lungo il percorso', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index bf47acb55..e68b03e41 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '休憩', 'roadtrip.stay.mode.night': '宿泊', - 'roadtrip.stay.checkOutDay': 'チェックアウト日', - 'roadtrip.stay.dayNumber': '{number}日目', 'roadtrip.stay.nightAdded': '宿泊として追加しました', 'roadtrip.stay.nightAction': '宿泊として追加', 'roadtrip.stop.makeService': '途中の立ち寄りにする', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index ff6409177..fe4be5971 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': '목적지', 'roadtrip.stay.mode.pause': '휴식', 'roadtrip.stay.mode.night': '숙박', - 'roadtrip.stay.checkOutDay': '체크아웃 날짜', - 'roadtrip.stay.dayNumber': '{number}일차', 'roadtrip.stay.nightAdded': '숙박으로 추가했습니다', 'roadtrip.stay.nightAction': '숙박으로 추가', 'roadtrip.stop.makeService': '경유 정차로 변경', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index fd3cb7e97..0e2d09307 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -148,8 +148,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Een bestemming', 'roadtrip.stay.mode.pause': 'Pauze', 'roadtrip.stay.mode.night': 'Overnachten', - 'roadtrip.stay.checkOutDay': 'Uitchecken op', - 'roadtrip.stay.dayNumber': 'Dag {number}', 'roadtrip.stay.nightAdded': 'Toegevoegd als overnachting', 'roadtrip.stay.nightAction': 'Toevoegen als overnachting', 'roadtrip.stop.makeService': 'Maak er een stop onderweg van', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index e543abdbf..f1d35f8c8 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Cel podróży', 'roadtrip.stay.mode.pause': 'Postój', 'roadtrip.stay.mode.night': 'Nocleg', - 'roadtrip.stay.checkOutDay': 'Wymeldowanie', - 'roadtrip.stay.dayNumber': 'Dzień {number}', 'roadtrip.stay.nightAdded': 'Dodano jako nocleg', 'roadtrip.stay.nightAction': 'Dodaj jako nocleg', 'roadtrip.stop.makeService': 'Zamień w postój po drodze', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index a559c5703..e07f94e56 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Пункт назначения', 'roadtrip.stay.mode.pause': 'Остановка', 'roadtrip.stay.mode.night': 'Ночёвка', - 'roadtrip.stay.checkOutDay': 'Выезд', - 'roadtrip.stay.dayNumber': 'День {number}', 'roadtrip.stay.nightAdded': 'Добавлено как ночёвка', 'roadtrip.stay.nightAction': 'Добавить как ночёвку', 'roadtrip.stop.makeService': 'Сделать остановкой в пути', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index b17fcf481..47a1d1fc7 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Ett resmål', 'roadtrip.stay.mode.pause': 'Paus', 'roadtrip.stay.mode.night': 'Övernattning', - 'roadtrip.stay.checkOutDay': 'Utcheckning', - 'roadtrip.stay.dayNumber': 'Dag {number}', 'roadtrip.stay.nightAdded': 'Tillagd som övernattning', 'roadtrip.stay.nightAction': 'Lägg till som övernattning', 'roadtrip.stop.makeService': 'Gör det till ett stopp på vägen', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index ee5fc1608..b1d109999 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Bir varış noktası', 'roadtrip.stay.mode.pause': 'Mola', 'roadtrip.stay.mode.night': 'Konaklama', - 'roadtrip.stay.checkOutDay': 'Çıkış günü', - 'roadtrip.stay.dayNumber': '{number}. gün', 'roadtrip.stay.nightAdded': 'Konaklama olarak eklendi', 'roadtrip.stay.nightAction': 'Konaklama olarak ekle', 'roadtrip.stop.makeService': 'Yol üzerinde molaya dönüştür', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 2e9dcf94b..0a8b59f4c 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Пункт призначення', 'roadtrip.stay.mode.pause': 'Зупинка', 'roadtrip.stay.mode.night': 'Ночівля', - 'roadtrip.stay.checkOutDay': 'Виїзд', - 'roadtrip.stay.dayNumber': 'День {number}', 'roadtrip.stay.nightAdded': 'Додано як ночівлю', 'roadtrip.stay.nightAction': 'Додати як ночівлю', 'roadtrip.stop.makeService': 'Зробити зупинкою в дорозі', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index f0eec0a96..4396cc815 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': 'Một điểm đến', 'roadtrip.stay.mode.pause': 'Nghỉ', 'roadtrip.stay.mode.night': 'Qua đêm', - 'roadtrip.stay.checkOutDay': 'Trả phòng ngày', - 'roadtrip.stay.dayNumber': 'Ngày {number}', 'roadtrip.stay.nightAdded': 'Đã thêm làm nơi nghỉ đêm', 'roadtrip.stay.nightAction': 'Thêm làm nơi nghỉ đêm', 'roadtrip.stop.makeService': 'Biến thành điểm dừng dọc đường', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 90a4c6aa1..08a393e12 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -137,8 +137,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '過夜', - 'roadtrip.stay.checkOutDay': '退房日', - 'roadtrip.stay.dayNumber': '第 {number} 天', 'roadtrip.stay.nightAdded': '已新增為過夜住宿', 'roadtrip.stay.nightAction': '新增為過夜住宿', 'roadtrip.stop.makeService': '設為途中停靠', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 1cc59fb0d..8cbca9a94 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -137,8 +137,6 @@ const roadtrip: TranslationStrings = { 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '过夜', - 'roadtrip.stay.checkOutDay': '退房日', - 'roadtrip.stay.dayNumber': '第 {number} 天', 'roadtrip.stay.nightAdded': '已添加为过夜住宿', 'roadtrip.stay.nightAction': '添加为过夜住宿', 'roadtrip.stop.makeService': '设为途中停靠', diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index d5b8a3ebb..ac9986639 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -90,7 +90,6 @@ export function assembleRoadtrip({ d.stops.map((s) => ({ anchor: s.time ?? s.checkInTime ?? null, dwellMinutes: s.dwellMinutes, - departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, })), d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration), ), @@ -160,15 +159,7 @@ export function assembleRoadtrip({ const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)]; const line = spurFor(snap); if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) }); - const entry = schedule.entries[index]; - const arrival = parseClock(entry?.arrival); - const dwellMinutes = - s.checkoutAt !== undefined && arrival !== null - ? Math.max(0, s.checkoutAt - ((chain.dayNumber + (entry?.dayOffset ?? 0)) * 1440 + arrival)) - : s.checkoutAt !== undefined - ? null - : s.dwellMinutes; - return { ...s, dwellMinutes, offRoadMeters: line ? snap!.meters : null }; + return { ...s, offRoadMeters: line ? snap!.meters : null }; }); const drive = { diff --git a/shared/src/roadtrip/continueSchedule.ts b/shared/src/roadtrip/continueSchedule.ts index c9cda06ac..15f40ebd2 100644 --- a/shared/src/roadtrip/continueSchedule.ts +++ b/shared/src/roadtrip/continueSchedule.ts @@ -18,15 +18,10 @@ export function continueSchedule( if (departure === null || duration === undefined) continue; const previousArrival = parseClock(previous.arrival); const departureAt = - stops[i - 1]!.checkoutAt !== undefined - ? stops[i - 1]!.checkoutAt! - dayNumber * 1440 - : previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0); + previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0); const arrivalAt = departureAt + Math.round(duration / 60); const stop = stops[i]!; - const leaveAt = - stop.checkoutAt === undefined - ? arrivalAt + (stop.dwellMinutes ?? 0) - : Math.max(arrivalAt, stop.checkoutAt - dayNumber * 1440); + const leaveAt = arrivalAt + (stop.dwellMinutes ?? 0); entry.arrival = formatClock(arrivalAt); entry.departure = formatClock(leaveAt); entry.dayOffset = Math.floor(arrivalAt / 1440); diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts index 4ebe91f01..af89057d7 100644 --- a/shared/src/roadtrip/dayWindow.spec.ts +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -366,47 +366,28 @@ describe('daily travel window', () => { }); }); -describe('booked checkout', () => { - it('waits at the hotel until noon on the checkout day', () => { - const hotel = stop(2, { checkoutAt: 2 * 1440 + 720, dwellMinutes: 30 }); - const stops = [stop(1), hotel, stop(3)]; - const plan = calculate(stops, [60, 60]); - expect(plan.issue).toBeNull(); - expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00'); - expect(plan.chains[0]!.schedule.entries[1]!.departure).toBe('12:00'); - expect(plan.chains[1]!.schedule.entries[0]!.arrival).toBe('12:00'); - expect(plan.chains[1]!.schedule.entries[1]!.arrival).toBe('13:00'); - }); - it('honours an early checkout and multiple nights', () => { - const stops = [stop(1, { checkoutAt: 3 * 1440 + 420 }), stop(2)]; - const plan = calculate(stops, [60], [day(2), day(3)]); - expect(plan.issue).toBeNull(); - expect(plan.chains[2]!.schedule.entries[0]!.arrival).toBe('07:00'); - expect(plan.chains[2]!.schedule.entries[1]!.arrival).toBe('08:00'); - }); - it('does not move time backwards when arrival is after checkout', () => { - const stops = [stop(1, { time: '13:00', checkoutAt: 1440 + 720 }), stop(2)]; - const plan = calculate(stops, [60]); - expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('14:00'); - }); -}); - -it('keeps checkout when the drive arrives after suggested check-in', () => { +it('waits for a check-in the drive overshoots rather than rushing it', () => { const stops = [ stop(1, { time: '07:00', dwellMinutes: 60 }), - stop(2, { checkInTime: '09:11', checkoutAt: 2 * 1440 + 600 }), + stop(2, { checkInTime: '09:11' }), stop(3), ]; const plan = calculate(stops, [72.5, 29]); expect(plan.issue).toBeNull(); + // Arrives two minutes past the hour it may check in from, and is left there rather + // than pulled back to the round number. expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13'); - expect(plan.chains[1]!.stops.at(-1)!.placeId).toBe(3); - expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29'); + const stops0 = plan.chains[0]!.stops; + expect(stops0.at(-1)!.placeId).toBe(3); + // And carries straight on from there: nothing holds the traveller at the stop. + expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:42'); }); it('waits for check-in without treating it as a fixed appointment', () => { - const plan = calculate([stop(1), stop(2, { checkInTime: '15:00', checkoutAt: 2 * 1440 + 600 }), stop(3)], [60, 29]); + const plan = calculate([stop(1), stop(2, { checkInTime: '15:00' }), stop(3)], [60, 29]); expect(plan.issue).toBeNull(); + // The check-in is a door opening, not an appointment: the drive waits for it and then + // carries straight on, rather than the day being rebuilt around it. expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00'); - expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29'); + expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29'); }); diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index 8ba7edb53..196f8e63e 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -181,7 +181,6 @@ export function planDayWindow( name: labels.end, time: null, dwellMinutes: 0, - checkoutAt: undefined, checkInTime: undefined, stopType: null, fillPercent: null, @@ -278,26 +277,6 @@ export function planDayWindow( append(stop, clock); previous = stop; - if (stop.checkoutAt !== undefined) { - const checkoutDay = Math.floor(stop.checkoutAt / 1440); - const checkoutTime = stop.checkoutAt % 1440; - const arrivalAt = number * 1440 + clock; - if (stop.checkoutAt > arrivalAt) { - const entries = chainAt(number).schedule.entries; - entries[entries.length - 1]!.departure = formatClock(checkoutTime); - while (number < checkoutDay) { - previous = night(previous); - if (issue) return failed(issue); - } - clock = checkoutTime; - if (previous.automaticNight?.phase === 'start') { - const entries = chainAt(number).schedule.entries; - entries[entries.length - 1]!.arrival = formatClock(clock); - entries[entries.length - 1]!.departure = formatClock(clock); - } - } - continue; - } let dwell = Math.max(0, stop.dwellMinutes ?? 0); while (dwell > 0) { const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock); diff --git a/shared/src/roadtrip/nightSpill.spec.ts b/shared/src/roadtrip/nightSpill.spec.ts index 3093959c1..000dfb910 100644 --- a/shared/src/roadtrip/nightSpill.spec.ts +++ b/shared/src/roadtrip/nightSpill.spec.ts @@ -276,4 +276,66 @@ describe('spillChains', () => { expect(chains[1]!.spills).toHaveLength(1); expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 }); }); + + describe('a stay that runs past midnight carries the clock into the next day', () => { + // Standing somewhere for twenty-four hours is not over when the date changes. The + // day after used to begin at nothing, so a trip whose second day had no pinned time + // showed no times at all, right after the first day had said exactly when it ends. + it('FE-NIGHTSPILL-013: the next day starts where the stay ends', () => { + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '07:56' }), + stop({ assignmentId: 2, name: 'Mercure', time: '08:00', dwellMinutes: 24 * 60 }), + ]), + day(2, 2, [stop({ assignmentId: 3, name: 'Panorama' }), stop({ assignmentId: 4, name: 'Bergedorf' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + const dayTwo = chains.find((c) => c.dayNumber === 2)!; + + expect(dayTwo.schedule.entries[0]!.arrival).toBe('08:00'); + expect(dayTwo.schedule.entries[1]!.arrival).toBe('08:11'); + }); + + it('FE-NIGHTSPILL-014: an ordinary evening hands on nothing', () => { + // The night between two days is not a wait somebody is serving out. Only a stay + // that genuinely runs past midnight reaches into the morning. + const plan = [ + day(1, 1, [stop({ assignmentId: 1, name: 'Hamburg', time: '18:00', dwellMinutes: 60 })]), + day(2, 2, [stop({ assignmentId: 2, name: 'Panorama' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + + expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull(); + }); + + it('FE-NIGHTSPILL-015: a stop pinned before the stay ends keeps its clock and is flagged', () => { + const plan = [ + day(1, 1, [stop({ assignmentId: 1, name: 'Mercure', time: '08:00', dwellMinutes: 24 * 60 })]), + day(2, 2, [stop({ assignmentId: 2, name: 'Panorama', time: '07:00' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + const dayTwo = chains.find((c) => c.dayNumber === 2)!; + + // Never moved: the hour is the traveller's. + expect(dayTwo.schedule.entries[0]!.arrival).toBe('07:00'); + expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'late', minutes: 60 }); + }); + + it('FE-NIGHTSPILL-016: a stay of two whole days walks the clock forward day by day', () => { + const plan = [ + day(1, 1, [stop({ assignmentId: 1, name: 'Mercure', time: '08:00', dwellMinutes: 48 * 60 })]), + day(2, 2, [stop({ assignmentId: 2, name: 'Nothing planned' })]), + day(3, 3, [stop({ assignmentId: 3, name: 'Berlin' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + + // Day two is still inside the stay, so it cannot begin at all. + expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull(); + expect(chains.find((c) => c.dayNumber === 3)!.schedule.entries[0]!.arrival).toBe('08:00'); + }); + }); }); diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts index 9c51af731..9c1264395 100644 --- a/shared/src/roadtrip/nightSpill.ts +++ b/shared/src/roadtrip/nightSpill.ts @@ -69,17 +69,40 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL const landing = new Map(); for (const d of all) landing.set(d.dayNumber, []); + /** + * How far into a day the one before it still reaches. + * + * A stop stood at for twenty-four hours is not over when the date changes: the day + * after it begins where it ends, not at nothing. Only ever a floor, and only when a + * stay genuinely runs past midnight — an ordinary day that finishes at six in the + * evening hands on nothing, because the night between them is not a wait, it is a + * night. + */ + let carriedInto: { dayNumber: number; minute: number } | null = null; + for (const d of all) { + const notBefore = carriedInto?.dayNumber === d.dayNumber ? carriedInto.minute : null; const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]!)); const legs = routed.map((l) => l?.seg); const schedule = computeSchedule( d.stops.map((s) => ({ anchor: s.time ?? s.checkInTime ?? null, dwellMinutes: s.dwellMinutes, - departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, })), legs.map((l) => l?.duration), + { notBefore }, ); + // Where this day's last clock lands, carried to whichever day that turns out to be. + const ends = schedule.endsAt ?? null; + if (ends !== null && ends > 1440) { + carriedInto = { dayNumber: d.dayNumber + Math.floor(ends / 1440), minute: ends % 1440 }; + } else if (carriedInto && carriedInto.dayNumber <= d.dayNumber) { + // Spent: this is the day it was pointing at. + carriedInto = null; + } + // Otherwise it is left alone. A stay of several days passes over the days in the + // middle without them having anything to say, and clearing it there would lose the + // morning it was aimed at. let day = 0; let previous: number | null = null; @@ -90,7 +113,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL if (previous !== null && clock < previous) day += 1; previous = clock; } - const offset = d.stops.some((s) => s.checkoutAt !== undefined) ? (entry?.dayOffset ?? day) : day; + const offset = day; const marks = schedule.warnings.filter((w) => w.index === i); const reachable = offset > 0 && numbers.has(d.dayNumber + offset); diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index 696777155..92d946aa3 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -18,7 +18,6 @@ export interface RoadtripStop { time: string | null; dwellMinutes: number | null; - checkoutAt?: number; checkInTime?: string | null; endDay?: boolean; diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index 7b163cfda..2a2d1a1dd 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -252,7 +252,7 @@ describe('computeSchedule', () => { }); it('handles an empty chain', () => { - expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] }); + expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [], endsAt: null }); }); it('treats a missing dwell as no time spent', () => { diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index efcb9177a..a94cc6320 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -133,6 +133,15 @@ export interface ScheduleEntry { export interface Schedule { entries: ScheduleEntry[]; warnings: ScheduleWarning[]; + /** + * Minute the last stop is left at, counted from the start of this day, so a stay + * running past midnight comes back as more than 1440. + * + * The one number the next day needs. A stop stood at for twenty-four hours is not + * over when the date changes, and the day after it cannot begin before it ends. + * Null or absent when nothing on this day has a time at all. + */ + endsAt?: number | null; } const DAY_MINUTES = 24 * 60; @@ -154,13 +163,24 @@ function resolveArrival( }; } -export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule { +/** + * @param opts.notBefore Minute of this day the first stop cannot be reached before, + * because the day before is still running into it: a stop stood at past midnight ends + * where it ends, and nothing can happen ahead of that. Behaves like the arrival of an + * imaginary stop just before the first, so a stop pinned earlier keeps its clock and + * picks up the same late finding any leg it cannot make in time would give it. + */ +export function computeSchedule( + stops: ScheduleStop[], + legSeconds: (number | undefined)[], + opts: { notBefore?: number | null } = {}, +): Schedule { const warnings: ScheduleWarning[] = []; const arrivals: (number | null)[] = new Array(stops.length).fill(null); const anchored: boolean[] = new Array(stops.length).fill(false); - let cursor: number | null = null; + let cursor: number | null = opts.notBefore ?? null; let dayOffset = 0; for (let i = 0; i < stops.length; i++) { @@ -231,7 +251,22 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und }); } - return { entries, warnings }; + // Read off the last stop that actually has a clock: a trailing stop with no time of + // its own carries nothing forward, and a day whose times peter out halfway should + // hand on what it does know rather than nothing. + let endsAt: number | null = null; + for (let i = stops.length - 1; i >= 0; i--) { + const raw = arrivals[i]; + if (raw === null || raw === undefined) continue; + const arrival = raw + shift; + endsAt = + stops[i]!.departureAt === undefined + ? arrival + (stops[i]!.dwellMinutes ?? 0) + : Math.max(arrival, stops[i]!.departureAt! + shift); + break; + } + + return { entries, warnings, endsAt }; } export function splitIntoRuns(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {