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 }