From bc04072dc9d2379f923c42a168a11d8aea97b628 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 16:22:11 +0200 Subject: [PATCH] keep overnight checkout planning after a later arrival --- client/src/api/wsEventPolicy.ts | 2 ++ .../Roadtrip/VisitControls.test.tsx | 2 +- .../components/Roadtrip/useRoadtripRoutes.ts | 3 ++- .../shared/ContextMenu.extra.test.tsx | 2 +- client/src/components/shared/ContextMenu.tsx | 2 +- .../useTripPlanner.roadtrip.test.tsx | 8 +++++--- .../nest/roadtrip/roadtrip-plan.service.ts | 3 ++- shared/src/roadtrip/assemble.ts | 2 +- shared/src/roadtrip/dayWindow.spec.ts | 20 +++++++++++++++++++ shared/src/roadtrip/dayWindow.ts | 2 ++ shared/src/roadtrip/nightSpill.ts | 2 +- shared/src/roadtrip/planning-types.ts | 1 + 12 files changed, 39 insertions(+), 10 deletions(-) diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 1ad8c95ba..268083586 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -36,6 +36,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'roadtripVia:changed', 'roadtripTrack:changed', 'roadtripPreferences:changed', + // components/Roadtrip/useDayBoundaries reloads the shared boundaries. + 'roadtripBoundary:changed', // In-app notifications — hooks/useInAppNotificationListener 'notification:new', 'notification:updated', diff --git a/client/src/components/Roadtrip/VisitControls.test.tsx b/client/src/components/Roadtrip/VisitControls.test.tsx index d5c3ff357..d5d6ce6eb 100644 --- a/client/src/components/Roadtrip/VisitControls.test.tsx +++ b/client/src/components/Roadtrip/VisitControls.test.tsx @@ -14,6 +14,6 @@ it('edits the shared stay without changing the day ending', () => { it('keeps stay editing when daily travel times are off', () => { render() - expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 0 min' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled() expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument() }) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index e01333d0c..2e1b27c1c 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -86,7 +86,8 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda name: p.name, lat: p.lat, lng: p.lng, - time: a.assignment_time ?? p.place_time ?? stay?.check_in ?? null, + time: a.assignment_time ?? p.place_time ?? null, + checkInTime: stay?.check_in ?? null, dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null, endDay: a.end_day === true, legMode: a.leg_transport_mode ?? null, diff --git a/client/src/components/shared/ContextMenu.extra.test.tsx b/client/src/components/shared/ContextMenu.extra.test.tsx index 67e274eb3..a0a292779 100644 --- a/client/src/components/shared/ContextMenu.extra.test.tsx +++ b/client/src/components/shared/ContextMenu.extra.test.tsx @@ -20,7 +20,7 @@ describe('useContextMenu', () => { result.current.open({ clientX: 120, clientY: 40, preventDefault, stopPropagation } as unknown as React.MouseEvent, items) }) - expect(result.current.menu).toEqual({ x: 120, y: 40, items }) + expect(result.current.menu).toEqual({ x: 120, y: 40, items, alignEnd: false }) expect(preventDefault).toHaveBeenCalled() expect(stopPropagation).toHaveBeenCalled() }) diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index a1a1d4886..3558a9398 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -23,7 +23,7 @@ export function useContextMenu() { const open = (e: React.MouseEvent, items: MenuItem[], alignEnd = false) => { e.preventDefault() e.stopPropagation() - const anchor = e.currentTarget.getBoundingClientRect() + const anchor = alignEnd ? e.currentTarget.getBoundingClientRect() : { right: e.clientX, bottom: e.clientY } setMenu({ x: alignEnd ? anchor.right : e.clientX, y: alignEnd ? anchor.bottom + 6 : e.clientY, items, alignEnd }) } diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index b0bda76b8..4947af668 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -200,6 +200,8 @@ async function renderRoadtrip() { rt.routes.days = [{ ...rt.corridor.day, stops }] } rt.routes.days = rt.routes.days.map(day => ({ + schedule: { entries: [] }, + legs: [], ...day, stops: (day.stops as Array>).map((stop, i) => ({ ownerDayId: day.dayId, ownerIndex: i, ...stop, @@ -512,10 +514,8 @@ describe('useTripPlanner road trip: saving a hit', () => { await result.current.saveStopDraftAsNight({ endDayId: 6, checkIn: '15:00', checkOut: '10:00' }) }) - // No stop_type and no dwell: a hotel is where the day ends, not something the drive - // passes through, so it keeps its number in the chain. const [, payload] = actions.addPlace.mock.calls[0] as unknown as [number, Record] - expect(payload.stop_type).toBeUndefined() + expect(payload.stop_type).toBe('hotel') expect(payload.duration_minutes).toBeUndefined() expect(accommodationsApi.create).toHaveBeenCalledWith(42, { place_id: 900, start_day_id: 5, end_day_id: 6, check_in: '15:00', check_out: '10:00', @@ -1292,6 +1292,8 @@ describe('useTripPlanner road trip: folding a day off the map', () => { buildPlace({ id: 1104, lat: 48.13, lng: 11.58 }), ], }) + const places = useTripStore.getState().places + useTripStore.setState({ assignments: { '5': places.map((place, i) => buildAssignment({ id: i + 1, day_id: 5, place_id: place.id, place })) } }) rt.corridor.day = { dayId: 5, dayNumber: 1 } rt.routes.days = [ { dayId: 5, dayNumber: 1, stops: [drawn(1101, 53.55, 9.99, 5, 0), drawn(1102, 52.52, 13.4, 5, 1)], geometry: LINES[0] }, diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index 42647b470..5b714d1f9 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -113,7 +113,8 @@ export class RoadtripPlanService { name: v.name, lat: v.lat!, lng: v.lng!, - time: v.time ?? v.check_in ?? null, + time: v.time, + checkInTime: v.check_in, ...(v.checkout_day != null && parseClock(v.check_out) !== null ? { checkoutAt: v.checkout_day * 1440 + parseClock(v.check_out)! } : {}), dwellMinutes: v.duration_minutes, endDay: v.end_day === 1, diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index 03c1b5f68..d5b8a3ebb 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -88,7 +88,7 @@ export function assembleRoadtrip({ spills: [], schedule: computeSchedule( d.stops.map((s) => ({ - anchor: s.time, + anchor: s.time ?? s.checkInTime ?? null, dwellMinutes: s.dwellMinutes, departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, })), diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts index 1cc422e76..4ebe91f01 100644 --- a/shared/src/roadtrip/dayWindow.spec.ts +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -390,3 +390,23 @@ describe('booked checkout', () => { expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('14:00'); }); }); + +it('keeps checkout when the drive arrives after suggested check-in', () => { + const stops = [ + stop(1, { time: '07:00', dwellMinutes: 60 }), + stop(2, { checkInTime: '09:11', checkoutAt: 2 * 1440 + 600 }), + stop(3), + ]; + const plan = calculate(stops, [72.5, 29]); + expect(plan.issue).toBeNull(); + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13'); + expect(plan.chains[1]!.stops.at(-1)!.placeId).toBe(3); + expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29'); +}); + +it('waits for check-in without treating it as a fixed appointment', () => { + const plan = calculate([stop(1), stop(2, { checkInTime: '15:00', checkoutAt: 2 * 1440 + 600 }), stop(3)], [60, 29]); + expect(plan.issue).toBeNull(); + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00'); + expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29'); +}); diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index 046ad25b3..d79569a6a 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -182,6 +182,7 @@ export function planDayWindow( time: null, dwellMinutes: 0, checkoutAt: undefined, + checkInTime: undefined, stopType: null, fillPercent: null, automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) }, @@ -273,6 +274,7 @@ export function planDayWindow( const target = targets.get(number); if (target !== undefined && target < position) return failed('conflict'); if (pin !== null) clock = pin; + if (pin === null && number === day.dayNumber) clock = Math.max(clock, parseClock(stop.checkInTime) ?? 0); append(stop, clock); previous = stop; diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts index b1d0175f1..5e10315be 100644 --- a/shared/src/roadtrip/nightSpill.ts +++ b/shared/src/roadtrip/nightSpill.ts @@ -74,7 +74,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL const legs = routed.map((l) => l?.seg); const schedule = computeSchedule( d.stops.map((s) => ({ - anchor: s.time, + anchor: s.time ?? s.checkInTime ?? null, dwellMinutes: s.dwellMinutes, departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, })), diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index 75e4e3736..696777155 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -19,6 +19,7 @@ export interface RoadtripStop { dwellMinutes: number | null; checkoutAt?: number; + checkInTime?: string | null; endDay?: boolean; legMode: string | null;