fix(roadtrip): the drive between two days can take another way too

The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.

The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
This commit is contained in:
Maurice
2026-09-23 09:42:22 +02:00
parent 53a422d22d
commit e6daed9403
51 changed files with 901 additions and 125 deletions
+6 -6
View File
@@ -1,4 +1,5 @@
import type Database from 'better-sqlite3';
import { carriedSeam } from '@trek/shared/roadtrip';
/**
* Where a booked night sits in its day, and how the drawn roads follow when a stop
@@ -176,7 +177,8 @@ export function reseatOwnStop(
* its stop, a stop that left the day hands its road to the stop before it, and a
* stop that is last has no leg to keep a via on. A via behind the day's last stop
* bends the drive into the next day and stays with that stop while it is still last,
* whatever its number is now; measured as a leg it would be dropped.
* whatever its number is now; measured as a leg it would be dropped. That last rule is
* the planner's own (`carriedSeam`), so the two cannot disagree about it.
*
* Returns what changed, or null when nothing did.
*/
@@ -185,14 +187,12 @@ export function carryVias(db: SeatConnection, dayId: number, previousIds: number
const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[];
if (!vias.length) return null;
const previousLast = previousIds.length - 1;
const nextLast = nextIds.length - 1;
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
const seam = carriedSeam(previousIds, nextIds);
const remove: number[] = [];
const moved: { id: number; after_order_index: number }[] = [];
for (const via of vias) {
const next = lastStayed && via.after_order_index === previousLast
? nextLast
const next = seam && via.after_order_index === seam.from
? seam.to
: legAfter(via.after_order_index, previousIds, nextIds);
if (next === null) remove.push(via.id);
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
@@ -342,12 +342,9 @@ export class AssignmentsService {
// A via behind the day's last stop bends the drive into the next day, on a trip
// with connected days or a night drive (the planner's `anchorFor` files it there).
// `reanchorByStopOrder` gives a last stop no leg and deletes what follows it, which
// is right for a stop the sort made last and wrong for one that was last already.
const lastAt = previousIds.length - 1;
const seam = previousIds[lastAt] === nextIds[lastAt] ? lastAt : null;
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId)
.filter(via => via.after_order_index !== seam);
// It stays with that stop while the sort leaves it last (`carriedSeam`, applied
// inside `reanchorByStopOrder`), the same rule the planner's own drags follow.
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId);
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
for (const viaId of plan.remove) {
this.dbs.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
@@ -573,6 +573,24 @@ describe('updateTime', () => {
expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterB, 2], [intoTomorrow, 3]]);
});
it('ASG-SVC-042: a sort that moves the last stop up the day takes the drive behind it along, never leaving it on no stop', () => {
// The rule the planner applies too (carriedSeam): a via behind the last stop stays
// there only while that stop is last. Once another stop is, the via follows its stop
// like any other, and nothing is left on an index behind the day's end.
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]);
const afterA = addVia(day.id, 0);
const intoTomorrow = addVia(day.id, 2);
const update = svc.updateTime(c, '08:00', null);
expect(dayOrder(day.id)).toEqual([c, a, b]);
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 1, sequence: 0 },
{ id: intoTomorrow, after_order_index: 0, sequence: 0 },
]);
expect(update.vias?.dayId).toBe(day.id);
});
it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', () => {
// B belongs first by time, and the traveller put it second on purpose.
const { day, ids: [a, b, c] } = dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]);