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
+78 -1
View File
@@ -43,6 +43,11 @@ const { db } = vi.hoisted(() => {
check_out TEXT, confirmation TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')));`);
tmp.exec(`CREATE TABLE assignment_participants (assignment_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
UNIQUE(assignment_id, user_id));`);
// A start that reorders a day re-pins that day's vias in the same transaction, so
// the sort reads this table whenever it moves a stop.
tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0, lat REAL NOT NULL, lng REAL NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at TEXT);`);
tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);`);
// StorageRegistryService (behind StorageModule, now in this module chain) reads
@@ -55,7 +60,8 @@ const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast }));
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() }));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
@@ -213,6 +219,77 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined);
});
describe('PUT /:id/time and the order of the day', () => {
const seedDay = (times: (string | null)[]) => times.map((time, i) => {
const id = seedAssignment(3, 2, i);
if (time) db.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, id);
return id;
});
const dayOrder = () =>
(db.prepare('SELECT id FROM day_assignments WHERE day_id = 3 ORDER BY order_index, id').all() as { id: number }[]).map(r => r.id);
const eventsSent = () => broadcast.mock.calls.map(call => call[1]);
beforeEach(() => broadcast.mockClear());
it('keeps the untimed stops in front of the stop that gets a start', async () => {
const [a, b, c] = seedDay([null, null, null]);
const res = await request(server)
.put(`/api/trips/5/assignments/${c}/time`)
.set('Cookie', sessionCookie(1))
.set('X-Socket-Id', 'sock-1')
.send({ place_time: '14:00', end_time: null });
expect(res.status).toBe(200);
expect(res.body.assignment).toMatchObject({ id: c, assignment_time: '14:00', order_index: 2 });
expect(dayOrder()).toEqual([a, b, c]);
expect(eventsSent()).toEqual(['assignment:updated']);
});
it('sorts the timed stops, keeps the untimed head first and sends the whole day', async () => {
const [a, b, c] = seedDay([null, '15:00', null]);
const res = await request(server)
.put(`/api/trips/5/assignments/${c}/time`)
.set('Cookie', sessionCookie(1))
.set('X-Socket-Id', 'sock-1')
.send({ place_time: '10:00', end_time: null });
expect(res.status).toBe(200);
expect(dayOrder()).toEqual([a, c, b]);
// No socket left out, so the writer gets the order too.
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:reordered', { dayId: 3, orderedIds: [a, c, b] }, undefined);
// No located stops and no vias on this day, so there is nothing to re-pin.
expect(eventsSent()).not.toContain('roadtripVia:changed');
});
it('stores the order it sends: a day with a gap in its keys is numbered from 0', async () => {
// The gap a deleted stop leaves. Clients number the ids they are sent by position.
const [a, b, c] = [0, 4, 7].map(key => seedAssignment(3, 2, key));
db.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('15:00', b);
const res = await request(server)
.put(`/api/trips/5/assignments/${c}/time`)
.set('Cookie', sessionCookie(1))
.send({ place_time: '10:00', end_time: null });
expect(res.status).toBe(200);
const sent = broadcast.mock.calls.find(call => call[1] === 'assignment:reordered')?.[2] as { orderedIds: number[] };
expect(sent.orderedIds).toEqual([a, c, b]);
const keyOf = (id: number) => (db.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
expect(sent.orderedIds.map(keyOf)).toEqual([0, 1, 2]);
expect(res.body.assignment).toMatchObject({ id: c, order_index: 1 });
});
it('leaves a day dragged out of time order alone when only the End changes', async () => {
const [a, b] = seedDay(['14:00', '10:00']);
db.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b);
const res = await request(server)
.put(`/api/trips/5/assignments/${b}/time`)
.set('Cookie', sessionCookie(1))
.set('X-Socket-Id', 'sock-1')
.send({ place_time: '10:00', end_time: null });
expect(res.status).toBe(200);
expect(res.body.assignment).toMatchObject({ id: b, assignment_time: '10:00', assignment_end_time: null });
expect(dayOrder()).toEqual([a, b]);
expect(eventsSent()).toEqual(['assignment:updated']);
});
});
it('200 participants (access-only)', async () => {
const id = seedAssignment();
db.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, 2)').run(id);