fix(roadtrip): a stop with a start is driven where the day list shows it

The day list draws a day by its starts, the road trip drives the order
it is stored in. Moving a stop with a start onto another day, or adding
a place that carries one, stored it at the end of the day or where it
was dropped, so the list showed it among the stops its hour falls
between while the road trip went there last. It is now stored right
behind the row the list draws it after, with a booked night read by its
check-in as the server's time sort reads it. A stop without a start
lands exactly where it did before, and wherever a moved stop lands in
the middle of a day, the via points drawn on that day stay on the legs
they were drawn for.

Behind a later start the road trip reached such a stop late, and the
night spill took its earlier clock for a midnight: the stop and every
stop after it opened the next day's card, and a day left with one stop
fell to the placeholders at the bottom. A stop the schedule marks late
now stays on the day the schedule files it on, and the stops behind it
are read from its hour, so a clock that goes back past that hour still
starts the next day. The desktop rail, the phone's road trip tab and
the MCP calculate_roadtrip tool share that spill.
This commit is contained in:
Maurice
2026-09-22 21:17:24 +02:00
parent ed8acf4c4b
commit 1a8d5a3dd9
11 changed files with 629 additions and 41 deletions
+50 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems, timedSlot } from './dayMerge'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
@@ -303,3 +303,52 @@ describe('getMergedItems', () => {
expect(result.map(i => i.data.id)).toEqual([1, 3, 10, 2])
})
})
// A stop with a start is drawn by it, and the road trip drives the stored order, so a
// stop joining a day from elsewhere is stored where the list will draw it.
describe('timedSlot', () => {
const visit = (order_index: number, place_time: string | null, accommodation_id: number | null = null) =>
({ order_index, accommodation_id, place: { place_time } })
const day = [visit(0, '09:00'), visit(1, '11:00'), visit(2, '14:00')]
it('leaves a stop without a start to the drop', () => {
expect(timedSlot(day, [], null, 1)).toBeNull()
expect(timedSlot(day, [], undefined)).toBeNull()
expect(timedSlot(day, [], 'soon')).toBeNull()
})
it('puts a stop between the stored stops its start falls between, wherever it was dropped', () => {
expect(timedSlot(day, [], '10:00')).toBe(1)
expect(timedSlot(day, [], '10:00', 2)).toBe(1)
expect(timedSlot(day, [], '10:00', 0)).toBe(1)
expect(timedSlot(day, [], '08:00')).toBe(0)
expect(timedSlot(day, [], '15:00', 0)).toBe(3)
})
it('keeps the drop among the stops without a time behind the same start', () => {
const loose = [visit(0, '09:00'), visit(1, null), visit(2, '11:00')]
expect(timedSlot(loose, [], '10:00')).toBe(2)
expect(timedSlot(loose, [], '10:00', 1)).toBe(1)
})
it('reads the day by order_index, not by the order the rows come in', () => {
const shuffled = [visit(2, '14:00'), visit(0, '09:00'), visit(1, '11:00')]
expect(timedSlot(shuffled, [], '12:00')).toBe(2)
expect(timedSlot([], [], '12:00')).toBe(0)
})
it('stores it right behind the stop the list draws it after on a day stored against its hours', () => {
// Stored 11:00 then 09:00: the list draws 09:00, 10:00, 11:00, so 10:00 goes behind 09:00.
expect(timedSlot([visit(0, '11:00'), visit(1, '09:00')], [], '10:00')).toBe(2)
})
it('counts the booked night the list hides, timed by its check-in', () => {
const nights = [{ id: 7, check_in: '15:00' }, { id: 8, check_in: null }]
const withCheckIn = [visit(0, '09:00'), visit(1, null, 7), visit(2, '16:00')]
expect(timedSlot(withCheckIn, nights, '12:00')).toBe(1)
expect(timedSlot(withCheckIn, nights, '15:30')).toBe(2)
// Without a check-in the night leads the day and stays in front.
const leading = [visit(0, null, 8), visit(1, '09:00')]
expect(timedSlot(leading, nights, '08:00')).toBe(1)
})
})
+46
View File
@@ -356,3 +356,49 @@ export function getMergedItems(opts: {
return applyChronoOrder(result.sort((a, b) => a.sortKey - b.sortKey), dayId, getDisplayTime)
}
/** A stored day row, as far as `timedSlot` reads it. */
interface StoredVisit {
order_index: number
accommodation_id?: number | null
place?: { place_time?: string | null } | null
}
/**
* Where a stop joining a day is stored when it carries a start of its own, as an index
* into the day's stored rows sorted by order_index. Null when it has none: that stop
* goes where it was dropped, as it always has.
*
* The list draws a day by time (`applyChronoOrder`) and the road trip drives it in the
* order it is stored. Put at the end of the day, or wherever it was dropped, a stop with
* a start sat in one place on the list and in another on the road, and behind a later
* start the road trip reached it late. So it is stored right behind the row it will be
* drawn after. Among stops without a time it keeps the spot it was dropped on; where the
* drop and the start disagree, the start wins.
*
* Read over every stored row, the ones the list hides included, because those are what
* the index counts. A booked night is timed by its check-in, the way the server's time
* sort reads it, so a stop pinned before the check-in lands ahead of the hotel and one
* pinned after it behind. A night without one counts as a stop without a time, so a
* night that leads its day keeps leading it.
*/
export function timedSlot(
day: readonly StoredVisit[],
nights: readonly { id: number; check_in?: string | null }[],
start: string | null | undefined,
dropAt?: number | null,
): number | null {
const minutes = parseTimeToMinutes(start)
if (minutes === null) return null
const stored = [...day].sort((a, b) => a.order_index - b.order_index)
const at = dropAt == null ? stored.length : Math.min(Math.max(dropAt, 0), stored.length)
const joining: StoredVisit = { order_index: -1 }
const startOf = (row: StoredVisit): number | null => {
if (row === joining) return minutes
const checkIn = row.accommodation_id == null ? null : nights.find(n => n.id === row.accommodation_id)?.check_in
return parseTimeToMinutes(row.place?.place_time ?? checkIn)
}
const drawn = chronoOrder([...stored.slice(0, at), joining, ...stored.slice(at)], startOf)
const before = drawn[drawn.indexOf(joining) - 1]
return before ? stored.indexOf(before) + 1 : 0
}