fix(roadtrip): file a via on the drive between connected days after the stop it leaves

With connected days the drive from one day's last stop to the next day's
first is drawn at the head of the arriving card, in the colour of the day
before. anchorFor only knew that stretch as a night spill, so a click on it
anchored to the arriving card's first stop: the via was stored on the leg
after that stop, and the next day drove out to the point, turned and came
back, while the stretch that was clicked kept its shape.

assembleRoadtrip now names the stop that joined drive leaves from, and
anchorFor treats it like the stop a night drive left from. Placing and
dragging both resolve to the previous day's last stop, which is the anchor
the seam request already reads its vias from.
This commit is contained in:
Maurice
2026-09-14 22:36:29 +02:00
parent 1b49995c15
commit 2ffe4d6054
5 changed files with 131 additions and 9 deletions
@@ -1691,6 +1691,49 @@ describe('useTripPlanner road trip: a card that opens with yesterday stop', () =
expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 53.3, 10.7)
})
/**
* A card on a trip with connected days: the drive from where day 5 ended to where day 6
* begins is drawn at the head of card 6, in day 5's colour, with no night drive involved.
* Day 5 ends on its third stop, so the index the via has to take differs from anything
* card 6 could offer.
*/
const connectedCard = () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
rt.corridor.day = { dayId: 6, dayNumber: 2 }
rt.routes.days = [{
dayId: 6,
dayNumber: 2,
stops: [drawn(1103, 52.52, 13.4, 6, 0), drawn(1104, 52.0, 14.5, 6, 1)],
geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4], [52.3, 14.0], [52.0, 14.5]],
spills: [],
arrivingFrom: drawn(1102, 53.55, 9.99, 5, 2),
}]
}
it('FE-TP-ROAD-087: a via dropped on the drive between two connected days follows the stop that drive left from', async () => {
// The reported bug: the click was filed after the first stop of the day the drive
// arrives on, so that day ran out to the point, turned and came back, while the
// stretch the traveller meant to bend stayed as it was.
connectedCard()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53.0, 11.5) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 2, 53.0, 11.5)
// Past the first stop it is the card's own leg again.
await act(async () => { await result.current.addRoadtripVia(52.3, 14.0) })
expect(rt.vias.add).toHaveBeenLastCalledWith(6, 0, 52.3, 14.0)
})
it('FE-TP-ROAD-088: a via on the drive between connected days can be dragged along it without changing hands', async () => {
connectedCard()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.moveRoadtripVia(5, 9, 53.2, 11.0) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 53.2, 11.0, 2)
})
it('FE-TP-ROAD-067: a drag keeps to the day the via is stored on, wherever that day stops are drawn', async () => {
// Not "the card with that id". After a night drive the stops of day 5 are drawn on
// card 6, and measuring the drag against card 5 alone leaves it with no anchor to
+10 -8
View File
@@ -1469,10 +1469,12 @@ export function useTripPlanner() {
if (best && hit.offRouteKm >= best.offRouteKm) continue
// Which stop the via follows: the last one the car passes before reaching it.
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
// Before the card's first stop means the incoming night drive, which is drawn here
// but leaves from a stop on the card BEFORE this one (`nightSpill.ts`). Anchoring
// it to this card's first stop would file the via on the leg AFTER that stop, and
// the route would run forward, double back to the point, and carry on.
// Before the card's first stop means a drive that arrives here but leaves from a
// stop on the card BEFORE this one: the incoming night drive (`nightSpill.ts`), or
// on a trip with connected days the drive from where yesterday ended, which is drawn
// at the head of this card in yesterday's colour. Anchoring either to this card's
// first stop would file the via on the leg AFTER that stop, and the route would run
// forward, double back to the point, and carry on.
//
// Asked of the distance rather than of the index, because the index cannot answer
// it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and
@@ -1480,11 +1482,11 @@ export function useTripPlanner() {
// index this read as a guard and behaved as dead code, so a via dropped on the
// night stretch went to the first drawn stop after all, which is the exact failure
// the paragraph above describes.
const spilledIn = day.spills?.find(sp => sp.at === 0)?.fromStop
if (spilledIn && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = spilledIn.ownerDayId ?? day.dayId
const arrivedFrom = day.spills?.find(sp => sp.at === 0)?.fromStop ?? day.arrivingFrom
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = arrivedFrom.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = { dayId: owner, afterIndex: spilledIn.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
continue
}
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
+62 -1
View File
@@ -1,5 +1,6 @@
/**
* ROADTRIP-ASSEMBLE-001..003 — what the assembler does with the model's warnings.
* ROADTRIP-ASSEMBLE-001..005: what the assembler does with the model's warnings, and
* what it tells a surface about the drive between connected days.
*
* The model itself is pinned by roadtripModel.spec.ts. This file pins the
* wrapper, which is where the arguments are chosen: `deriveDriveWarnings` takes
@@ -102,3 +103,63 @@ describe('assembleRoadtrip drive warnings', () => {
expect(rangeWarnings(assemble(stops, [700, 500]))).toEqual([]);
});
});
describe('assembleRoadtrip connected days', () => {
const lineFrom = (from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({
...leg(100),
line: [
[from.lat, from.lng],
[to.lat, to.lng],
],
});
function assembleTwoDays(connectDays: boolean) {
const first = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 }), stop({ ownerIndex: 2 })];
const second = [
stop({ ownerDayId: 2, ownerIndex: 0, assignmentId: 300, placeId: 400, lat: 60 }),
stop({ ownerDayId: 2, ownerIndex: 1, assignmentId: 301, placeId: 401, lat: 61 }),
];
const allLegs: Record<string, RoutedLeg> = {};
for (const run of [first, second, [first[2]!, second[0]!]]) {
run.slice(0, -1).forEach((from, i) => {
allLegs[legKey(from, run[i + 1]!)] = lineFrom(from, run[i + 1]!);
});
}
return assembleRoadtrip({
plan: [
{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops: first },
{ dayId: 2, dayNumber: 2, date: '2026-06-02', title: null, stops: second },
],
quietDays: [],
window: null,
distanceUnit: 'metric',
allLegs,
snapByDay: {},
missedByDay: {},
loading: false,
limits: { rangeKm: null, legMinutes: null, dayMinutes: null },
vehicleKind: null,
connectDays,
boundaries: [],
labels: { start: 'start', end: 'end' },
});
}
it('ROADTRIP-ASSEMBLE-004: the drive drawn at the head of a connected day names the stop it left from', () => {
const routes = assembleTwoDays(true);
const [first, second] = routes.days;
// Yesterday's last stop, with the numbers it is stored under. A via placed on that
// stretch has to be filed after it, and the card's own stops cannot say which it is.
expect(second!.arrivingFrom).toEqual(expect.objectContaining({ ownerDayId: 1, ownerIndex: 2 }));
expect(second!.geometry[0]).toEqual([first!.stops[2]!.lat, first!.stops[2]!.lng]);
expect(first!.arrivingFrom).toBeUndefined();
});
it('ROADTRIP-ASSEMBLE-005: days that are not connected draw no such drive and name no such stop', () => {
const routes = assembleTwoDays(false);
expect(routes.days[1]!.arrivingFrom).toBeUndefined();
expect(routes.days[1]!.geometry[0]).toEqual([60, 10]);
});
});
+3
View File
@@ -113,6 +113,7 @@ export function assembleRoadtrip({
const inboundAt = new Map<number, { seg: RouteSegment | undefined; line: [number, number][]; drawnAs: number }>();
let arrivingLeg: RouteSegment | undefined;
let arrivingFrom: RoadtripStop | undefined;
if (connectDays && !automaticSchedule) {
for (const spill of chain.spills) {
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber });
@@ -122,6 +123,7 @@ export function assembleRoadtrip({
if (joined) {
inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber });
arrivingLeg = joined.seg;
arrivingFrom = previousStop;
}
}
previousStop = chain.stops[chain.stops.length - 1]! ?? previousStop;
@@ -246,6 +248,7 @@ export function assembleRoadtrip({
// Only where no stop actually crossed over: a crossing already draws its own band,
// with this same road under it, and a second one would be the drive twice.
arrivingLeg: chain.spills.length ? undefined : arrivingLeg,
arrivingFrom,
stops,
legs,
legVias,
+13
View File
@@ -111,6 +111,19 @@ export interface RoadtripDay {
*/
arrivingLeg?: RouteSegment;
/**
* The stop the drive at the head of this card's `geometry` sets off from, when the
* days are connected.
*
* Kept even where `arrivingLeg` is withheld for a crossing further down the card: the
* band goes, the road stays drawn. A point placed on that stretch belongs after THIS
* stop, on the day it is stored on, and nothing else on the card says which stop that
* is. Measured against the card's own first stop instead, a via dropped there was
* filed on the leg after it, and the drive ran on, turned back to the point and came
* the same way again.
*/
arrivingFrom?: RoadtripStop;
schedule: Schedule;
legVias: RouteVia[][];