fix(assignments): keep untimed stops in place when a start is saved

Saving a start on a stop rewrote the whole day as the timed stops in
time order followed by every untimed stop. Its own comment and the
commit that added it promised that untimed stops keep their place, but
a day of two untimed stops and a third one given 14:00 came back with
the third one first. On a road trip the stop order is the drive, so
the day was re-routed, its vias landed on other legs, and collaborators,
who only got the one edited row, saw a third order until they reloaded.

The intended rule, an untimed stop inheriting the time of the timed
stop before it, now lives once as chronoOrder in @trek/shared, used by
both the day plan (dayMerge) and AssignmentsService.updateTime. The
server still reads a booked night's hour from its check-in, and a value
without a colon still sorts after every clock time.

The day is sorted only when the stop's start actually changed, so
taking an End off or saving an unchanged start no longer snaps back a
day somebody dragged out of time order. A sorted day is numbered from 0
like a drag. In the same transaction the day's road trip vias follow
the stop they were drawn behind, the planner's own drag rule, and a
via behind a last stop that stays last is kept, so the drive into the
next day keeps its road.

PUT /assignments/:id/time and the MCP update_assignment_time tool both
broadcast assignment:reordered with the whole day, to the writer as
well, and roadtripVia:changed when vias moved, so every client ends up
with the same order.

ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the
intended one.
This commit is contained in:
Maurice
2026-09-19 19:24:38 +02:00
parent 40f8912937
commit cc1c16ec34
12 changed files with 677 additions and 86 deletions
+26
View File
@@ -276,4 +276,30 @@ describe('getMergedItems', () => {
const types = result.map(i => i.type)
expect(types).toEqual(['place', 'transport', 'place'])
})
// The same rule the server stores when a start time is saved, so on a day of places
// alone the day it sends back after the save and the day drawn before it agree.
it('keeps untimed places where they were put and sorts only the timed ones', () => {
const place = (id: number, order_index: number, place_time: string | null) => ({ id, order_index, place: { place_time } })
const ids = (dayAssignments: ReturnType<typeof place>[]) =>
getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [], dayId: 5 }).map(i => i.data.id)
expect(ids([place(1, 0, null), place(2, 1, null), place(3, 2, '14:00')])).toEqual([1, 2, 3])
expect(ids([place(1, 0, '09:00'), place(2, 1, null), place(3, 2, '14:00')])).toEqual([1, 2, 3])
expect(ids([place(1, 0, null), place(2, 1, '15:00'), place(3, 2, '10:00')])).toEqual([1, 3, 2])
})
// Where the server's sort and this one part: the server sorts the stops alone, so
// for it place 2 follows the 09:00 place and stays in front of the 10:00 one. Here it
// follows the 12:00 note and is drawn behind both.
it('lets an untimed place take the time of a timed note in front of it', () => {
const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: '09:00' } },
{ id: 2, order_index: 1, place: { place_time: null } },
{ id: 3, order_index: 2, place: { place_time: '10:00' } },
]
const dayNotes = [{ id: 10, sort_order: 0.5, time: '12:00' }]
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
expect(result.map(i => i.data.id)).toEqual([1, 3, 10, 2])
})
})
+8 -11
View File
@@ -1,3 +1,4 @@
import { chronoOrder } from '@trek/shared'
// `orderedEndpoints` is the geometry order's single source of truth, in the module
// that documents the multi-leg model. Sorting endpoints a second time here is how
// the two drift.
@@ -270,26 +271,22 @@ export function getAssignmentReservations<T extends {
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
* time inherits the time of the timed item before it, so untimed items stay where
* they were manually placed. Stable on the incoming order for ties.
*
* The rule itself is `chronoOrder` in @trek/shared, the same one the server uses when
* a start time is saved. The server feeds it the day's stops only, though. An untimed
* place behind a timed note or booking inherits that item's time here and the previous
* stop's time there, so on such a day the stored order and this one can differ.
*/
function applyChronoOrder(
items: MergedItem[],
dayId: number,
getDisplayTime: (r: any, dayId: number) => string | null
): MergedItem[] {
const timeOf = (it: MergedItem): number | null => {
return chronoOrder(items, it => {
if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time)
if (it.type === 'note') return parseTimeToMinutes(it.data?.time)
return parseTimeToMinutes(getDisplayTime(it.data, dayId))
}
let last = -Infinity
return items
.map((it, i) => {
const t = timeOf(it)
if (t != null) last = t
return { it, i, eff: t != null ? t : last }
})
.sort((a, b) => a.eff - b.eff || a.i - b.i)
.map(k => k.it)
})
}
/** Merge places, notes, and transports into a single ordered day timeline. */