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;