From 2ffe4d60543fcc1c74185fb5ed8de575c5ee579f Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 22:36:29 +0200 Subject: [PATCH] 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. --- .../useTripPlanner.roadtrip.test.tsx | 43 +++++++++++++ .../src/pages/tripPlanner/useTripPlanner.ts | 18 +++--- shared/src/roadtrip/assemble.spec.ts | 63 ++++++++++++++++++- shared/src/roadtrip/assemble.ts | 3 + shared/src/roadtrip/planning-types.ts | 13 ++++ 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 34bc75e1b..ae73c8448 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -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 diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 531dc99e9..21e5d98b8 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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 diff --git a/shared/src/roadtrip/assemble.spec.ts b/shared/src/roadtrip/assemble.spec.ts index 21223a8d1..c2a8a90a0 100644 --- a/shared/src/roadtrip/assemble.spec.ts +++ b/shared/src/roadtrip/assemble.spec.ts @@ -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 = {}; + 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]); + }); +}); diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index f4f252c82..f2003d9ac 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -113,6 +113,7 @@ export function assembleRoadtrip({ const inboundAt = new Map(); 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, diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index 0b9014243..20ead9100 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -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[][];