From 60cbb41d5f552c5ffcce05e936026f9f37a6ee39 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 22:43:46 +0200 Subject: [PATCH] fix(roadtrip): put a corridor hit on the drive from the day before ahead of the first stop The corridor searches the whole line a card draws, and a card that opens with a drive from the day before (a night carried over, or the joined road on connected days) draws that drive ahead of its first stop. The insert index still came from insertIndexForAlong, which never answers 0, so a charger found on that stretch was added after the first stop and the day drove there, turned back to the charger and came the same way again. insertIndexFor now answers 0 for a hit before the first stop when the card arrives from an earlier stop, and dropping a hit on the map asks it instead of computing its own index, so a click and a drop cannot disagree. --- .../Roadtrip/useRoadtripCorridor.test.ts | 54 +++++++++++++++++++ .../Roadtrip/useRoadtripCorridor.ts | 15 ++++-- .../useTripPlanner.roadtrip.test.tsx | 18 +++++++ .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts index 85b4fd522..0b1fbc798 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts @@ -259,4 +259,58 @@ describe('useRoadtripCorridor', () => { expect(result.current.visible).toBe(hits) }) + + describe('a card that opens with the drive from the day before', () => { + /** + * Three stops, so "before the first" and "after the first" are different answers: + * with two, every hit lands between them whatever the rule is. The line starts where + * the previous day ended, 250-odd km before this card's first stop. + */ + const stopAt = (index: number, lat: number, lng: number) => ({ + assignmentId: 10 + index, ownerDayId: 2, ownerIndex: index, placeId: 100 + index, name: `S${index}`, + lat, lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null, + }) + const spine = [ + { lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }, { lat: 52.0, lng: 14.5 }, { lat: 51.5, lng: 15.0 }, + ] + const arriving = (over: Partial) => day({ + dayId: 2, + dayNumber: 2, + stops: [stopAt(0, 52.5, 13.4), stopAt(1, 52.0, 14.5), stopAt(2, 51.5, 15.0)], + geometry: spine.map(p => [p.lat, p.lng] as [number, number]), + ...over, + }) + const beforeFirstStop = poi({ osm_id: 'early', name: 'Rasthof', alongKm: 60 }) + const pastFirstStop = poi({ osm_id: 'late', name: 'Autohof', alongKm: 300 }) + + beforeEach(() => { + useCorridorPois.mockReturnValue({ ...searchWith([beforeFirstStop, pastFirstStop]), spine }) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-035: a hit on the drive from the connected day before goes in ahead of the first stop', () => { + // Filed after the first stop, the day drove to that stop, turned back to the hit + // and then came the same way again. + const previousLast = { ...stopAt(4, 53.5, 9.9), ownerDayId: 1 } + const { result } = renderHook(() => useRoadtripCorridor(routes([arriving({ arrivingFrom: previousLast })]))) + + expect(result.current.insertIndexFor(beforeFirstStop)).toBe(0) + expect(result.current.insertIndexFor(pastFirstStop)).toBe(1) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-036: the same holds for the drive a night carried over', () => { + const previousLast = { ...stopAt(4, 53.5, 9.9), ownerDayId: 1 } + const spill = { at: 0, count: 1, fromDayNumber: 1, departure: null, leg: undefined, line: [], fromStop: previousLast } + const { result } = renderHook(() => useRoadtripCorridor(routes([arriving({ spills: [spill] })]))) + + expect(result.current.insertIndexFor(beforeFirstStop)).toBe(0) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-037: a day nothing arrives on keeps its first stop first', () => { + // Its line starts at that stop, so a hit measured ahead of it is a projection + // wobble, and a new first stop would move where the whole day sets off from. + const { result } = renderHook(() => useRoadtripCorridor(routes([arriving({})]))) + + expect(result.current.insertIndexFor(beforeFirstStop)).toBe(1) + }) + }) }) diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.ts b/client/src/components/Roadtrip/useRoadtripCorridor.ts index 31e6549c9..691756e37 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.ts @@ -52,7 +52,7 @@ export interface RoadtripCorridor { * Which position in the day's chain a hit belongs at, so adding one lands it in the * order it will actually be driven past rather than at the end of the day. */ - insertIndexFor: (poi: CorridorPoi) => number + insertIndexFor: (poi: Pick) => number /** How far along the drive each of the day's stops sits, in the same units as a hit. */ stopsAlongKm: number[] /** @@ -227,8 +227,17 @@ export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | st }, [search.results, nameFilter, socketFilter, minKw, anchorKm, sectionKm]) const insertIndexFor = useCallback( - (poi: CorridorPoi) => insertIndexForAlong(stopsAlongKm, poi.alongKm), - [stopsAlongKm], + (poi: Pick) => { + // A card can open with a drive that left from a stop on the day before: the one a + // night carried over, or on connected days the road from where yesterday ended. A + // hit on that stretch is passed before this card's first stop, so it goes in ahead + // of it. `insertIndexForAlong` never answers 0, which is right for every other card, + // whose line starts at its first stop. + const arrivesFromEarlier = day?.spills?.find(sp => sp.at === 0)?.fromStop ?? day?.arrivingFrom + if (arrivesFromEarlier && stopsAlongKm.length > 1 && poi.alongKm < stopsAlongKm[0]!) return 0 + return insertIndexForAlong(stopsAlongKm, poi.alongKm) + }, + [day, stopsAlongKm], ) /** diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index ae73c8448..71e6f4eef 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1231,6 +1231,24 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => { expect(result.current.stopDraft?.poi.osm_id).toBe('node/7') }) + it('FE-TP-ROAD-089: a dropped hit is placed by the same rule as a clicked one', async () => { + // Computed on the side, a drop on the drive from the day before went in after this + // card's first stop while the click on the same hit went in ahead of it. + corridorWithHit() + rt.corridor.day = { + dayId: 6, + dayNumber: 2, + stops: [drawn(1301, 52.52, 13.4, 6, 0), drawn(1302, 52.0, 14.5, 6, 1)], + } + rt.corridor.insertIndexFor.mockReturnValue(0) + const { result } = await renderRoadtrip() + + act(() => { result.current.dropPoiOnRoute('node/7', 53.0, 11.5) }) + + expect(rt.corridor.insertIndexFor).toHaveBeenCalledWith(expect.objectContaining({ alongKm: expect.any(Number) })) + expect(result.current.stopDraft).toMatchObject({ dayId: 6, position: 0 }) + }) + it('FE-TP-ROAD-054: a drop nowhere near the drive is ignored rather than guessed at', async () => { corridorWithHit() const { result } = await renderRoadtrip() diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 21e5d98b8..24764ceba 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -1728,7 +1728,7 @@ export function useTripPlanner() { if (!hit || !day) return const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine) if (!at || at.offRouteKm > roadtripCorridor.widthKm) return - const insert = roadtripInsertion(day, insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm)) + const insert = roadtripInsertion(day, roadtripCorridor.insertIndexFor(at)) if (!insert) return setStopDraft({ poi: hit,