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[][];