From 3d201e1ced1baf65d91ee9547352026d031d647f Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 14:23:49 +0200 Subject: [PATCH] fix(roadtrip): the check-out leaves the drive, and a long stay carries the clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A check-out is the LATEST the room has to be handed back, never the earliest anybody may drive on, so it says nothing the chain can use. It shaped four things it had no business shaping: the departure the day was built from, the length the rail printed for a booked night, a day-selector and a clock field in the stop dialog, and the branch that sent a night's STAY badge to the booking form because its length was not editable here. All four go. The booking keeps its check-out; it is a booking detail and lives under Days, where it is entered. What replaces it is the thing that was always the honest answer: how long you stand there. That is the traveller's own statement rather than a deadline, and a stay running past midnight now carries the clock into the morning after — a stop pinned before it ends keeps its hour and collects the late finding, as it would from any leg it cannot make. A stay spanning whole days walks the clock forward over them. An ordinary evening hands on nothing: the night between two days is not a wait somebody is serving out. The stay dialog goes to a full day rather than four hours, since it is now the only place a night is given its hours, and offers eight and twelve as presets. Every stop answers the same question the same way. Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so the first time each dialog was ever opened a pale block flashed under the planner and never again once its chunk was cached. --- .../components/Roadtrip/RoadtripSidebar.tsx | 30 ++------- .../components/Roadtrip/RoadtripStayModal.tsx | 13 +++- .../Roadtrip/RoadtripStopPopup.test.tsx | 11 +++- .../components/Roadtrip/RoadtripStopPopup.tsx | 61 +++++------------- .../components/Roadtrip/useRoadtripRoutes.ts | 14 ++--- client/src/pages/TripPlannerPage.tsx | 15 +++-- .../src/pages/tripPlanner/useTripPlanner.ts | 16 +++-- shared/src/i18n/ar/roadtrip.ts | 2 - shared/src/i18n/br/roadtrip.ts | 2 - shared/src/i18n/ca/roadtrip.ts | 2 - shared/src/i18n/cs/roadtrip.ts | 2 - shared/src/i18n/de/roadtrip.ts | 2 - shared/src/i18n/en/roadtrip.ts | 2 - shared/src/i18n/es/roadtrip.ts | 2 - shared/src/i18n/fr/roadtrip.ts | 2 - shared/src/i18n/gr/roadtrip.ts | 2 - shared/src/i18n/hu/roadtrip.ts | 2 - shared/src/i18n/id/roadtrip.ts | 2 - shared/src/i18n/it/roadtrip.ts | 2 - shared/src/i18n/ja/roadtrip.ts | 2 - shared/src/i18n/ko/roadtrip.ts | 2 - shared/src/i18n/nl/roadtrip.ts | 2 - shared/src/i18n/pl/roadtrip.ts | 2 - shared/src/i18n/ru/roadtrip.ts | 2 - shared/src/i18n/sv/roadtrip.ts | 2 - shared/src/i18n/tr/roadtrip.ts | 2 - shared/src/i18n/uk/roadtrip.ts | 2 - shared/src/i18n/vi/roadtrip.ts | 2 - shared/src/i18n/zh-TW/roadtrip.ts | 2 - shared/src/i18n/zh/roadtrip.ts | 2 - shared/src/roadtrip/assemble.ts | 11 +--- shared/src/roadtrip/continueSchedule.ts | 9 +-- shared/src/roadtrip/dayWindow.spec.ts | 43 ++++--------- shared/src/roadtrip/dayWindow.ts | 21 ------- shared/src/roadtrip/nightSpill.spec.ts | 62 +++++++++++++++++++ shared/src/roadtrip/nightSpill.ts | 27 +++++++- shared/src/roadtrip/planning-types.ts | 1 - shared/src/roadtrip/roadtripModel.spec.ts | 2 +- shared/src/roadtrip/roadtripModel.ts | 41 +++++++++++- 39 files changed, 208 insertions(+), 215 deletions(-) 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 }[] {