From 15beb0ea40eea8164bb7924fb49ded4156ae14bc Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:44:01 +0200 Subject: [PATCH] feat(roadtrip): work back from the first pinned time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule only ever walked forward, so a stop before the first pinned time stayed blank. That is the wrong way round for how people plan: the museum opens at ten, so when do we have to leave? Pinning the time on the second stop and reading the first one's departure off the chain is the whole point of pinning it. The chain now runs both ways — forward from the first known arrival as before, backward for everything ahead of it, departure being the next arrival minus the drive and arrival that minus the stay. It stops at a leg that never routed rather than inventing a duration, and when working back crosses midnight the whole chain shifts up a day so the earliest stop stays on day zero and the boundary is drawn between the two stops instead of under the first one. --- .../components/Roadtrip/roadtripModel.test.ts | 38 ++++++++++- .../src/components/Roadtrip/roadtripModel.ts | 63 ++++++++++++++----- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 0b7ea966a..9dfc297d2 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -89,7 +89,10 @@ describe('computeSchedule', () => { expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false }) }) - it('leaves stops before the first pinned time blank rather than inventing one', () => { + it('works back from the first pinned time to say when to set off', () => { + // The museum opens at ten and we want an hour at the stop before it, an hour of + // driving in between: be there at eight. Answering that is the reason to pin a time + // on the second stop at all. const { entries } = computeSchedule( [ { anchor: null, dwellMinutes: 60 }, @@ -97,10 +100,41 @@ describe('computeSchedule', () => { ], [hours(1)], ) - expect(entries[0]).toMatchObject({ arrival: null, departure: null }) + expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false }) expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true }) }) + it('stops working back at a leg that never routed rather than inventing one', () => { + const { entries } = computeSchedule( + [ + { anchor: null, dwellMinutes: 60 }, + { anchor: null, dwellMinutes: 30 }, + { anchor: '10:00', dwellMinutes: 30 }, + ], + [undefined, hours(1)], + ) + expect(entries[0]).toMatchObject({ arrival: null, departure: null }) + // Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30. + expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' }) + expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true }) + }) + + it('keeps the earliest stop on day zero when working back crosses midnight', () => { + // Pinned at one in the morning with three hours of driving before it: the stop before + // is the evening before. The chain shifts up a day rather than printing a day below + // zero, so the boundary shows between the two stops instead of under the first one. + const { entries, warnings } = computeSchedule( + [ + { anchor: null, dwellMinutes: 0 }, + { anchor: '01:00', dwellMinutes: 0 }, + ], + [hours(3)], + ) + expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 }) + expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true }) + expect(warnings).toContainEqual({ index: 1, code: 'overnight' }) + }) + it('restarts the cascade at a pinned stop instead of pushing it', () => { // The drive would arrive at 09:00, but the museum ticket says 11:00: everything // after it counts from 11:00, not from the drive. diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index c61a8cefd..3b093ef78 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -212,8 +212,10 @@ function resolveArrival( } export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule { - const entries: ScheduleEntry[] = [] const warnings: ScheduleWarning[] = [] + // Arrivals in minutes, so the chain can be walked twice before anything is formatted. + const arrivals: (number | null)[] = new Array(stops.length).fill(null) + const anchored: boolean[] = new Array(stops.length).fill(false) // Minutes since the first stop's midnight, so a chain crossing midnight keeps counting. let cursor: number | null = null let dayOffset = 0 @@ -224,28 +226,57 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset) if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }) - if (arrival === null) { + if (arrival === null) continue + + const offset = Math.floor(arrival / DAY_MINUTES) + if (offset > dayOffset) dayOffset = offset + + arrivals[i] = arrival + anchored[i] = anchor !== null + + const leg = legSeconds[i] + cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60) + } + + // Then backwards, for the stops the forward walk left blank because nobody had pinned + // a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum + // opens at ten, so when do we have to leave? Working back from the first known arrival + // answers it — departure = the next arrival minus the drive, arrival = that minus the + // stay — and it stops at the first leg that never routed rather than inventing one. + const firstKnown = arrivals.findIndex(a => a !== null) + for (let i = firstKnown - 1; i >= 0; i--) { + const leg = legSeconds[i] + const next = arrivals[i + 1] + if (leg === undefined || next === null) break + arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0) + } + + // Working back can land before the anchor day's midnight, which would print as a + // negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts + // up instead until the earliest stop sits on day zero again. + const earliest = arrivals.reduce((m, a) => (a === null ? m : m === null || a < m ? a : m), null) + const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES + + const entries: ScheduleEntry[] = [] + let lastOffset = 0 + for (let i = 0; i < stops.length; i++) { + const raw = arrivals[i] + if (raw === null) { entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 }) continue } - - const offset = Math.floor(arrival / (24 * 60)) - if (offset > dayOffset) { - dayOffset = offset - warnings.push({ index: i, code: 'overnight' }) - } - - const dwell = stop.dwellMinutes ?? 0 - const departure = arrival + dwell + const arrival = raw + shift + const offset = Math.floor(arrival / DAY_MINUTES) + // Read off the finished chain rather than during the forward walk, so a midnight the + // backward pass introduced is marked too. + if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' }) + lastOffset = offset entries.push({ arrival: formatClock(arrival), - departure: formatClock(departure), - anchored: anchor !== null, + departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)), + anchored: anchored[i], dayOffset: offset, }) - - const leg = legSeconds[i] - cursor = leg === undefined ? null : departure + Math.round(leg / 60) } return { entries, warnings }