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
+52
View File
@@ -0,0 +1,52 @@
import { chronoOrder } from './chrono-order';
import { describe, it, expect } from 'vitest';
type Stop = { name: string; at: number | null };
const stop = (name: string, at: number | null = null): Stop => ({ name, at });
const order = (stops: Stop[]) => chronoOrder(stops, (s) => s.at).map((s) => s.name);
describe('chronoOrder', () => {
it('leaves untimed stops in front of the first timed one where they are', () => {
expect(order([stop('A'), stop('B'), stop('C', 14 * 60)])).toEqual(['A', 'B', 'C']);
});
it('keeps an untimed stop behind the timed stop it followed', () => {
expect(order([stop('A', 9 * 60), stop('B'), stop('C', 14 * 60)])).toEqual(['A', 'B', 'C']);
});
it('sorts timed stops among themselves and leaves the untimed head alone', () => {
expect(order([stop('A'), stop('B', 15 * 60), stop('C', 10 * 60)])).toEqual(['A', 'C', 'B']);
});
it('carries an untimed stop along with the timed stop in front of it', () => {
// B inherits 15:00 from A, so both go behind C at 10:00, and B stays behind A.
expect(order([stop('A', 15 * 60), stop('B'), stop('C', 10 * 60)])).toEqual(['C', 'A', 'B']);
});
it('keeps the incoming order for equal times', () => {
expect(order([stop('A', 600), stop('B', 600), stop('C', 540), stop('D', 540)])).toEqual(['C', 'D', 'A', 'B']);
});
it('treats undefined like null and orders midnight as a real time', () => {
const stops = [
{ name: 'A', at: undefined },
{ name: 'B', at: 60 },
{ name: 'C', at: 0 },
];
expect(chronoOrder(stops, (s) => s.at).map((s) => s.name)).toEqual(['A', 'C', 'B']);
});
it('returns a new array and leaves the input as it was', () => {
const stops = [stop('A', 600), stop('B', 540)];
const sorted = chronoOrder(stops, (s) => s.at);
expect(sorted).not.toBe(stops);
expect(stops.map((s) => s.name)).toEqual(['A', 'B']);
expect(sorted[0]).toBe(stops[1]);
});
it('handles an empty day', () => {
expect(chronoOrder([], () => null)).toEqual([]);
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* The order of a day plan by time. An item with a time sorts by it. An item without
* one inherits the time of the timed item before it, so it stays where it was put:
* behind the stop it followed, or at the front if nothing timed comes before it.
* Ties keep the incoming order.
*
* Shared so both sides apply one rule. The server persists this order when a stop's
* start changes, and the client applies it again on every render. The server used to
* keep its own copy that put every untimed stop at the end of the day, so one start
* time pulled a stop to the top of a day it had been planned at the bottom of.
*
* The rule is the same, the input is not. The server sorts the day's stops alone; the
* client sorts the day list, with its notes and bookings between the stops. An untimed
* stop behind a timed note inherits the note's time on the client and the previous
* stop's time on the server, so on such a day the two orders can differ.
*
* `minutesOf` is where the two sides differ as well: what counts as a time, and which
* one. It returns null (or undefined) for an item without a time.
*/
export function chronoOrder<T>(items: readonly T[], minutesOf: (item: T) => number | null | undefined): T[] {
let last = -Infinity;
return items
.map((item, index) => {
const minutes = minutesOf(item);
if (minutes !== null && minutes !== undefined) last = minutes;
return { item, index, at: minutes ?? last };
})
.sort((a, b) => a.at - b.at || a.index - b.index)
.map((entry) => entry.item);
}
+1
View File
@@ -37,6 +37,7 @@ export * from './dawarich/dawarich.schema';
export * from './docsync/docsync.schema';
export * from './day/day.schema';
export * from './day/note-colors';
export * from './day/chrono-order';
export * from './assignment/assignment.schema';
export * from './place/place.schema';
export * from './place/place-match';