feat(roadtrip): work back from the first pinned time

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.
This commit is contained in:
Maurice
2026-09-09 14:44:01 +02:00
committed by Maurice
parent f283254bb0
commit 15beb0ea40
2 changed files with 83 additions and 18 deletions
@@ -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.
+47 -16
View File
@@ -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<number | null>((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 }