keep overnight checkout planning after a later arrival

This commit is contained in:
Maurice
2026-09-12 16:22:11 +02:00
parent bc15036cfb
commit bc04072dc9
12 changed files with 39 additions and 10 deletions
+2
View File
@@ -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',
@@ -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(<VisitControls stay={{ minutes: null, onEdit: vi.fn() }} />)
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()
})
@@ -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,
@@ -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()
})
+1 -1
View File
@@ -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 })
}
@@ -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<Record<string, unknown>>).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<string, unknown>]
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] },
@@ -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,
+1 -1
View File
@@ -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,
})),
+20
View File
@@ -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');
});
+2
View File
@@ -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;
+1 -1
View File
@@ -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,
})),
+1
View File
@@ -19,6 +19,7 @@ export interface RoadtripStop {
dwellMinutes: number | null;
checkoutAt?: number;
checkInTime?: string | null;
endDay?: boolean;
legMode: string | null;