diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
index e91c814d2..f9eecae35 100644
--- a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
@@ -277,6 +277,36 @@ describe('RoadtripLimitsCard', () => {
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
})
+
+ it('FE-ROADTRIP-LIMITS-018: starting and ending each day at the stay is off until it is asked for', async () => {
+ const onSave = vi.fn()
+ open(onSave)
+
+ const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
+ // Missing is off: a trip drives as it always has until somebody switches it on.
+ expect(toggle).toHaveAttribute('aria-pressed', 'false')
+ fireEvent.click(toggle)
+ expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', true)
+ expect(onSave).toHaveBeenCalledTimes(1)
+ // What it does is said on the label, where the panel has no room for it.
+ fireEvent.mouseEnter(screen.getByText('Start and end each day at your stay'))
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
+ 'After a booked night the day starts where you slept, and before one it ends there.',
+ )
+ })
+
+ it('FE-ROADTRIP-LIMITS-019: switched on, it reads on and reports the way back off', () => {
+ useSettingsStore.setState({
+ settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
+ })
+ const onSave = vi.fn()
+ open(onSave)
+
+ const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
+ expect(toggle).toHaveAttribute('aria-pressed', 'true')
+ fireEvent.click(toggle)
+ expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
+ })
})
/**
@@ -313,6 +343,16 @@ describe('RoadtripLimitsCard', () => {
expect(screen.getByText('Connect the days')).toBeInTheDocument()
expect(screen.getByText('A colour per day')).toBeInTheDocument()
})
+
+ it('FE-ROADTRIP-LIMITS-020: the stay switch shows its row and offers no switch', () => {
+ useSettingsStore.setState({
+ settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
+ })
+ open(undefined)
+
+ expect(screen.queryByRole('button', { name: 'Start and end each day at your stay' })).not.toBeInTheDocument()
+ expect(screen.getByText('Start and end each day at your stay')).toBeInTheDocument()
+ })
})
})
diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
index c0031f41f..8839dc477 100644
--- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
+++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
@@ -18,6 +18,9 @@ import { FS } from './typeScale'
import DayWindowFields from './DayWindowFields'
import SettingsHint from './SettingsHint'
import { dayWindow } from './dayWindow'
+import { Tooltip } from '../shared/Tooltip'
+import { BOOKEND_ICON } from './nightBookend'
+import { useHotelBookends } from './useHotelBookends'
/**
* Everything that decides when the rail speaks up about the driving.
@@ -162,18 +165,22 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
* Disabled rather than hidden when there is no engine that can answer: an operator who
* pointed the instance at their own OSRM has no second engine, and a switch that flips
* and changes nothing is worse than one that says why it cannot.
+ *
+ * A `hint` is the sentence a switch needs and its panel has no room for, on the label.
*/
-function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
+function AvoidRow({ icon: Icon, label, hint, on, disabled, onToggle }: {
icon: typeof Coins
label: string
+ hint?: string
on: boolean
disabled: boolean
onToggle: () => void
}): React.ReactElement {
+ const text = {label}
return (
-
{label}
+ {hint ?
{text} : text}
{disabled
?
{'—'}
:
}
@@ -281,6 +288,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
// days gets the dialog with the trip's figures in it, not a form that takes a number
// and drops it.
const readOnly = !onSave
+ const bookends = useHotelBookends(onSave)
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
@@ -657,6 +665,18 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
disabled={readOnly}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>}
+ {/* Where a day begins and ends rather than what joins them: after a booked
+ night the drive sets off from the stay, and before one it ends there.
+ Off by default, because it adds a leg to most days of a trip that has
+ stays and changes their kilometres and times. */}
+
bookends.toggle?.()}
+ />
{/* Which matters most once the line IS continuous: end to end it is one
stroke, and a colour per day is what puts the days back into it. */}
void
+}
+
+/**
+ * The switch that seats a booked night at both ends of its days, as the desktop settings
+ * and the phone's driving figures both offer it.
+ *
+ * One hook so the two cannot disagree about what "on" means or where it is written. The
+ * value is the shared rule's own reading (`hotelBookendsOn`: missing is off), the one the
+ * drive is planned with, and the write is the save the caller holds. On both surfaces that
+ * is the planner's `saveRoadtripLimit`, so a flip on the phone goes through the same repo,
+ * the same offline queue and the same broadcast as one on the desktop.
+ */
+export function useHotelBookends(
+ save: ((key: string, value: boolean) => void) | undefined,
+ tripId?: number | string | null,
+): HotelBookendsSwitch {
+ const on = useRoadtripSettings(hotelBookendsOn, tripId)
+ return { on, toggle: save ? () => save(HOTEL_BOOKENDS_KEY, !on) : undefined }
+}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
index 0d75878e9..24268ca02 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
@@ -1,6 +1,6 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
+import type { Accommodation, Assignment, AssignmentsMap, Day, Reservation, Settings } from '../../types'
import type { RoadtripStop } from './useRoadtripRoutes'
// Hoisted together with the mock: the module factory runs before the file body, so a
@@ -1163,3 +1163,197 @@ describe('a leg asked for again on demand', () => {
expect(result.current.days[0].avoidMissed ?? []).toEqual([])
})
})
+
+/**
+ * FE-ROADTRIP-ROUTES-052..059: a booked night at both ends of the days around it.
+ *
+ * The first cases here that hand the hook the trip's stays. What is pinned is what goes to
+ * the router: the drive from the hotel slept in and to tonight's is part of the day's own
+ * run, a night spent at one hotel asks for nothing, no via bends the hotel's legs, and with
+ * the switch off the days are the stored ones and nothing else.
+ */
+describe('a booked night at both ends of its days', () => {
+ const GETAWAY: [number, number] = [-33.71, 150.31]
+ const LOOKOUT: [number, number] = [-33.73, 150.35]
+ const FALLS: [number, number] = [-33.65, 150.38]
+ const WALLINGA: [number, number] = [-34.1, 150.9]
+ const HOTEL: [number, number] = [45.07, 7.68]
+ const P: [number, number][] = [1, 2, 3, 4, 5, 6].map(n => [45 + n * 0.1, 7 + n * 0.1])
+ const VIA: [number, number] = [44.5, 7.5]
+
+ /** A stay whose place is the one stop spec `stopId` stands for (`place_id` is id * 10). */
+ const stayOf = (id: number, stopId: number, start: number, end: number, at: [number, number], over: Record = {}) =>
+ ({
+ id, trip_id: 7, place_id: stopId * 10, start_day_id: start, end_day_id: end,
+ check_in: null, check_out: '10:00', place_name: `Stay ${id}`, place_lat: at[0], place_lng: at[1],
+ ...over,
+ }) as unknown as Accommodation
+ const booking = (id: number, accommodationId: number) =>
+ ({ id, trip_id: 7, title: 'Motel', type: 'hotel', status: 'confirmed', accommodation_id: accommodationId }) as unknown as Reservation
+
+ /** An hour and 100 km for whatever pair the router is handed. */
+ const hourly = (points: { lat: number; lng: number }[]) => ({
+ coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
+ distance: 100000 * (points.length - 1),
+ duration: 3600 * (points.length - 1),
+ legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
+ })
+ /** Every run the router was asked for, as its waypoints' latitudes. */
+ const asked = () => calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
+ const shape = (stops: RoadtripStop[]) =>
+ stops.map(s => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name))
+ const switchTo = (on: boolean) =>
+ act(() => { useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_hotel_bookends: on } as never }) })
+
+ beforeEach(() => {
+ calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
+ switchTo(true)
+ })
+ afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
+
+ /** A check-in with two places, then a transfer day that is only the next check-in. */
+ const cam = () => ({
+ days: [day(1, 1), day(2, 2), day(3, 3)],
+ assignments: {
+ ...map(1, [{ id: 1, at: GETAWAY }, { id: 2, at: LOOKOUT }, { id: 3, at: FALLS }]),
+ ...map(2, [{ id: 4, at: WALLINGA }]),
+ } as AssignmentsMap,
+ stays: [stayOf(1, 1, 1, 2, GETAWAY, { check_in: '14:00' }), stayOf(2, 4, 2, 3, WALLINGA, { check_in: '15:00' })],
+ })
+
+ /** Three nights in one hotel with two places on every day before the check-out. */
+ const simeon = () => ({
+ days: [day(1, 1), day(2, 2), day(3, 3), day(4, 4)],
+ assignments: {
+ ...map(1, [{ id: 10, at: HOTEL }, { id: 11, at: P[0] }, { id: 12, at: P[1] }]),
+ ...map(2, [{ id: 13, at: P[2] }, { id: 14, at: P[3] }]),
+ ...map(3, [{ id: 15, at: P[4] }, { id: 16, at: P[5] }]),
+ } as AssignmentsMap,
+ stays: [stayOf(5, 10, 1, 4, HOTEL, { check_in: '15:00' })],
+ })
+
+ it('FE-ROADTRIP-ROUTES-052: the check-in day drives back to the stay, the transfer day sets off from it', async () => {
+ const { days, assignments, stays } = cam()
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [booking(90, 1)]))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ expect(result.current.days.map(d => shape(d.stops))).toEqual([
+ ['Stop 1', 'Stop 2', 'Stop 3', 'evening:1'],
+ ['morning:1', 'Stop 4'],
+ ])
+ // One run per day, the hotel legs inside it.
+ expect(asked()).toEqual([
+ [GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]],
+ [GETAWAY[0], WALLINGA[0]],
+ ])
+ expect(result.current.days[1].stops[0].bookend).toMatchObject({ checkingOut: true, checkOut: '10:00', reservationId: 90 })
+ expect(result.current.days[0].legs).toHaveLength(3)
+ // Nothing numbered changes: the hotel is a service stop, the day's own stops are counted.
+ expect(result.current.totalStops).toBe(4)
+ // The check-out day with nothing of its own is no card.
+ expect(result.current.days.map(d => d.dayId)).toEqual([1, 2])
+ })
+
+ it('FE-ROADTRIP-ROUTES-053: every day between the nights starts and ends at the hotel', async () => {
+ const { days, assignments, stays } = simeon()
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ expect(result.current.days.map(d => shape(d.stops))).toEqual([
+ ['Stop 10', 'Stop 11', 'Stop 12', 'evening:5'],
+ ['morning:5', 'Stop 13', 'Stop 14', 'evening:5'],
+ ['morning:5', 'Stop 15', 'Stop 16', 'evening:5'],
+ ])
+ expect(asked()).toEqual([
+ [HOTEL[0], P[0][0], P[1][0], HOTEL[0]],
+ [HOTEL[0], P[2][0], P[3][0], HOTEL[0]],
+ [HOTEL[0], P[4][0], P[5][0], HOTEL[0]],
+ ])
+ // The check-out day has nothing of its own and is not driven.
+ expect(result.current.days.map(d => d.dayId)).toEqual([1, 2, 3])
+ })
+
+ it('FE-ROADTRIP-ROUTES-054: with the days connected, a night spent at one hotel asks the router nothing', async () => {
+ act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_connect_days: true } })) })
+ const { days, assignments, stays } = cam()
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ // The two day runs and nothing else: no seam from the Getaway to the Getaway.
+ expect(asked()).toEqual([
+ [GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]],
+ [GETAWAY[0], WALLINGA[0]],
+ ])
+ expect(result.current.days[1].arrivingLeg).toBeFalsy()
+ expect(result.current.totalDistance).toBe(400000)
+ })
+
+ it('FE-ROADTRIP-ROUTES-055: a via behind the last place of a day does not bend the drive to tonight’s stay', async () => {
+ const { days, assignments, stays } = cam()
+ const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 2, sequence: 0, lat: VIA[0], lng: VIA[1] }] }
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', vias, [], stays))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ expect(asked()[0]).toEqual([GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]])
+ expect(asked().flat()).not.toContain(VIA[0])
+ })
+
+ it('FE-ROADTRIP-ROUTES-056: no via leaves the morning hotel; one after the first place stays after it', async () => {
+ const { days, assignments, stays } = simeon()
+ const vias = { 2: [{ id: 1, day_id: 2, after_order_index: 0, sequence: 0, lat: VIA[0], lng: VIA[1] }] }
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', vias, [], stays))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ expect(asked()[1]).toEqual([HOTEL[0], P[2][0], VIA[0], P[3][0], HOTEL[0]])
+ })
+
+ it('FE-ROADTRIP-ROUTES-057: a transfer day with nothing stored drives from one stay to the other', async () => {
+ const days = [day(1, 1), day(2, 2), day(3, 3)]
+ const assignments = { ...map(1, [{ id: 1, at: GETAWAY }, { id: 2, at: LOOKOUT }]) } as AssignmentsMap
+ const stays = [stayOf(1, 1, 1, 2, GETAWAY), stayOf(2, 4, 2, 3, WALLINGA)]
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ const transfer = result.current.days.find(d => d.dayId === 2)!
+ expect(shape(transfer.stops)).toEqual(['morning:1', 'evening:2'])
+ expect(transfer.stops.map(s => s.bookend && [s.bookend.checkingOut, s.bookend.checkingIn])).toEqual([[true, false], [false, true]])
+ expect(asked()).toContainEqual([GETAWAY[0], WALLINGA[0]])
+ })
+
+ it('FE-ROADTRIP-ROUTES-058: switched off, the days are the stored ones, and back on and off again they are again', async () => {
+ switchTo(false)
+ const { days, assignments, stays } = simeon()
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ const stored = result.current.days.map(d => d.stops)
+ expect(stored.flat().some(s => s.bookend)).toBe(false)
+ expect(asked()).toEqual([
+ [HOTEL[0], P[0][0], P[1][0]],
+ [P[2][0], P[3][0]],
+ [P[4][0], P[5][0]],
+ ])
+
+ switchTo(true)
+ await waitFor(() => expect(result.current.days[1]?.stops[0]?.bookend?.phase).toBe('morning'))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ switchTo(false)
+ await waitFor(() => expect(result.current.days.flatMap(d => d.stops).some(s => s.bookend)).toBe(false))
+ expect(result.current.days.map(d => d.stops)).toEqual(stored)
+ })
+
+ it('FE-ROADTRIP-ROUTES-059: linking a booking to the stay changes the hotel row, not the road', async () => {
+ const { days, assignments, stays } = cam()
+ const { result, rerender } = renderHook(
+ ({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, reservations),
+ { initialProps: { reservations: [] as Reservation[] } },
+ )
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(result.current.days[1].stops[0].bookend?.reservationId).toBeNull()
+ const calls = calculateRouteWithLegs.mock.calls.length
+
+ rerender({ reservations: [booking(91, 1), booking(90, 1)] })
+ // The earliest booking of the stay, and the router is not asked again.
+ await waitFor(() => expect(result.current.days[1].stops[0].bookend?.reservationId).toBe(90))
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(calls)
+ })
+})
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index 6d7d79e78..de00aeb8f 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
-import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, isStationaryJoin, mergeRouteSegments, seatCarrierStops, standsAsDay, terminalAssignmentId, viasOnLeg, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
+import { assembleRoadtrip, bookendStaysOf, carrierLegsFor, carrierSeam, foldRouteRun, hotelBookendsOn, isCarrierMode, isStationaryJoin, mergeRouteSegments, seatCarrierStops, seatNightBookends, standsAsDay, terminalAssignmentId, viasOnLeg, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { avoidedClasses, calculateRouteWithLegs, routeEngineFor, RoutingRefusedError, type RouteEngine } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
@@ -328,9 +328,29 @@ export function useRoadtripRoutes(
})
}, [days, assignments, accommodations, rideSeams])
- const plan = useMemo(() => storedDays.filter(d => standsAsDay(d.stops)), [storedDays])
+ /**
+ * Whether a day after a booked night starts at the stay and a day before one ends there.
+ * Off until the trip switches it on, so a trip drives exactly as it did before.
+ */
+ const bookendsOn = useRoadtripSettings(hotelBookendsOn, tripId)
+ // The nights as the rule reads them, with the booking behind each. Read only while the
+ // switch is on, so a new booking on a trip without it touches nothing downstream.
+ const stays = useMemo(
+ () => (bookendsOn ? bookendStaysOf(accommodations, reservations) : []),
+ [bookendsOn, accommodations, reservations],
+ )
+ // The stored days with the night before and the night after seated at their edges, by
+ // the rule the server's calculate_roadtrip runs too. Before a day is asked whether it
+ // stands as one: a day with a bookend always has two stops. A linked booking changes a
+ // bookend's reservation and nothing `planKey` reads, so it asks the router nothing.
+ const seatedDays = useMemo(
+ () => (bookendsOn ? seatNightBookends(storedDays, days, stays) : storedDays),
+ [bookendsOn, storedDays, days, stays],
+ )
- const quietDays = useMemo(() => storedDays.filter(d => !standsAsDay(d.stops)), [storedDays])
+ const plan = useMemo(() => seatedDays.filter(d => standsAsDay(d.stops)), [seatedDays])
+
+ const quietDays = useMemo(() => seatedDays.filter(d => !standsAsDay(d.stops)), [seatedDays])
/** Days apart, for a ride that lands on a later day than it left. */
const dayNumberOf = (dayId: number): number => days.find(d => d.id === dayId)?.day_number ?? 0
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
index 00a343e98..37e5b8e0a 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
@@ -1,11 +1,13 @@
import type { ReactNode } from 'react'
import { Car, Fuel, Zap } from 'lucide-react'
import MSheet from '../../../components/MSheet'
+import MToggle from '../../../components/MToggle'
import type { MTripSheetsProps } from '../MTripShell'
import { useTranslation } from '../../../../i18n'
import { useSettingsStore } from '../../../../store/settingsStore'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useVehicleRange } from '../../../../components/Roadtrip/useVehicleRange'
+import { useHotelBookends } from '../../../../components/Roadtrip/useHotelBookends'
import { blockMask } from '../../../../components/Roadtrip/RangeStrip'
import { dayWindow } from '../../../../components/Roadtrip/dayWindow'
import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip/roadtripModel'
@@ -21,13 +23,21 @@ import type { DistanceUnit } from '../../../../types'
* tank runs dry, and on a phone there was nothing behind those sentences: the numbers they
* are measured against live in a desktop dialog with seven panels and two columns. Without
* them a warning is an opinion. So two of those panels come along, the two that decide what
- * the stage is allowed to say, and nothing else does.
+ * the stage is allowed to say, and of a third only the one switch below.
*
- * Nothing here can be changed. That is the point rather than a gap: driving limits are set
- * once, at a table, before anybody is in the car, and a form in the passenger seat is a way
- * to break a trip while it is happening. So there is no field, no slider and no greyed-out
- * button that looks like one, and the last line says plainly where the settings are and what
- * else that screen can do that this one cannot.
+ * The figures cannot be changed here. That is the point rather than a gap: driving limits
+ * are set once, at a table, before anybody is in the car, and a form in the passenger seat is
+ * a way to break a trip while it is happening. So there is no field, no slider and no
+ * greyed-out button that looks like one, and the last line says plainly where the settings
+ * are and what else that screen can do that this one cannot.
+ *
+ * One switch is the exception: whether a day starts and ends at the stay. It is no figure and
+ * breaks nothing. It moves where a drawn day begins and ends, never a stored stop, and
+ * switched back off it gives exactly the stored plan again. It is also the one a traveller
+ * on the road reaches for, when the day on the phone sets off from its first place instead
+ * of the hotel they woke up in. It writes through the desktop dialog's own save
+ * (`useHotelBookends` over the planner's `saveRoadtripLimit`), and stays disabled for a
+ * reader who may not edit days.
*/
/**
@@ -70,7 +80,8 @@ function InfoCard({ label, children }: { label: string; children: ReactNode }) {
/**
* Driving figures sheet ('rtinfo'): the vehicle's range as the tank it leaves the factory
- * with, and the limits every warning on the stage is measured against. Read-only.
+ * with, and the limits every warning on the stage is measured against. Read-only but for
+ * the switch that starts and ends each day at the stay.
*/
export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
const { t } = useTranslation()
@@ -91,6 +102,7 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
+ const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId)
const electric = vehicleKind === 'electric'
const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
@@ -228,6 +240,24 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
+ {/* ── The one switch: whether a day starts and ends at the stay ── */}
+
+
+
+ {t('roadtrip.line.hotelBookends')}
+ bookends.toggle?.()}
+ ariaLabel={t('roadtrip.line.hotelBookends')}
+ disabled={!bookends.toggle}
+ />
+
+
+ {t('roadtrip.line.hotelBookendsHint')}
+
+
+
+
{t('mobileTrip.rtDesktopNote')}
diff --git a/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx b/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx
index 0c2b3c133..98d31dab9 100644
--- a/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
@@ -7,9 +7,9 @@ import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPrefe
import type { RoadtripPreferences } from '@trek/shared'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
-import { render, screen } from '../../../helpers/render'
+import { fireEvent, render, screen } from '../../../helpers/render'
-// FE-MOB-RTINFO-001 to FE-MOB-RTINFO-015
+// FE-MOB-RTINFO-001 to FE-MOB-RTINFO-018
//
// The sheet renders inside the real TranslationProvider, so the copy is asserted
// in English. Everything it shows comes out of the roadtrip preferences store,
@@ -36,9 +36,13 @@ function seedPreferences(preferences: RoadtripPreferences): void {
useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } })
}
-function renderSheet(preferences: RoadtripPreferences = FULL, shellOverrides: Record = {}) {
+function renderSheet(
+ preferences: RoadtripPreferences = FULL,
+ shellOverrides: Record = {},
+ plannerOverrides: Record = {},
+) {
seedPreferences(preferences)
- const planner = buildPlanner({ tripId: TRIP_ID } as unknown as Partial)
+ const planner = buildPlanner({ tripId: TRIP_ID, ...plannerOverrides } as unknown as Partial)
const shell = buildShell({ sheet: { id: 'rtinfo' }, ...shellOverrides } as unknown as Partial)
render()
return { planner, shell }
@@ -136,13 +140,16 @@ describe('MRtInfoSheet', () => {
expect(limitValue('Avoid where possible')).toBe('off')
})
- it('FE-MOB-RTINFO-012: carries no control of any kind, only the way out', () => {
+ it('FE-MOB-RTINFO-012: carries no control but the stay switch and the way out', () => {
renderSheet()
const dialog = screen.getByRole('dialog', { name: 'Driving figures' })
expect(screen.getAllByRole('button')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
expect(dialog.querySelectorAll('input, select, textarea')).toHaveLength(0)
- for (const role of ['textbox', 'spinbutton', 'slider', 'switch', 'checkbox', 'combobox', 'radio'] as const) {
+ // The figures stay a desktop matter; the one switch here is no figure.
+ expect(screen.getAllByRole('switch')).toHaveLength(1)
+ expect(screen.getByRole('switch', { name: 'Start and end each day at your stay' })).toBeInTheDocument()
+ for (const role of ['textbox', 'spinbutton', 'slider', 'checkbox', 'combobox', 'radio'] as const) {
expect(screen.queryByRole(role)).not.toBeInTheDocument()
}
})
@@ -165,4 +172,34 @@ describe('MRtInfoSheet', () => {
expect(screen.getByRole('img', { name: '2000 km per tank' })).toBeInTheDocument()
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
})
+
+ it('FE-MOB-RTINFO-016: the stay switch reads off when unset and saves on through the desktop dialog’s write', () => {
+ const saveRoadtripLimit = vi.fn()
+ renderSheet(FULL, {}, { saveRoadtripLimit })
+ const toggle = screen.getByRole('switch', { name: 'Start and end each day at your stay' })
+ expect(toggle).toHaveAttribute('aria-checked', 'false')
+ expect(toggle).toBeEnabled()
+ expect(screen.getByText('After a booked night the day starts where you slept, and before one it ends there.')).toBeInTheDocument()
+ fireEvent.click(toggle)
+ expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_hotel_bookends', true)
+ expect(saveRoadtripLimit).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTINFO-017: switched on, it reads on and turns back off', () => {
+ const saveRoadtripLimit = vi.fn()
+ renderSheet({ ...FULL, roadtrip_hotel_bookends: true }, {}, { saveRoadtripLimit })
+ const toggle = screen.getByRole('switch', { name: 'Start and end each day at your stay' })
+ expect(toggle).toHaveAttribute('aria-checked', 'true')
+ fireEvent.click(toggle)
+ expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
+ })
+
+ it('FE-MOB-RTINFO-018: a reader who may not edit days sees the switch, disabled', () => {
+ // The planner hands out no save to a reader without day_edit, or before the trip's
+ // settings are in, and the switch says so rather than flipping and springing back.
+ renderSheet({ ...FULL, roadtrip_hotel_bookends: true }, {}, { saveRoadtripLimit: undefined })
+ const toggle = screen.getByRole('switch', { name: 'Start and end each day at your stay' })
+ expect(toggle).toBeDisabled()
+ expect(toggle).toHaveAttribute('aria-checked', 'true')
+ })
})
diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts
index 710b8547c..a0b589a9f 100644
--- a/server/src/nest/accommodations/accommodations.service.ts
+++ b/server/src/nest/accommodations/accommodations.service.ts
@@ -356,8 +356,10 @@ export class AccommodationsService {
*
* Only the check-in day gets a stop, even for a fortnight's stay: that is the
* day you drive there, and it is exactly what the road-trip side writes for a
- * night it books itself. The later nights ride on the stay row, which is where
- * the rail reads check-out from.
+ * night it books itself. The later nights ride on the stay row: a trip that
+ * starts and ends its days at the stay seats the hotel at the edges of the days
+ * around them from it (`seatNightBookends`), and reads the check-out there as a
+ * label only.
*
* Runs inside the caller's transaction.
*/
diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts
index a96b5284f..f6b979941 100644
--- a/server/src/nest/roadtrip/day-boundaries.mcp.ts
+++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts
@@ -37,7 +37,7 @@ export class DayBoundariesMcp {
@Tool({
name: 'set_day_boundary',
- description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. Pass null to restore the automatic day ending.',
+ description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. With roadtrip_hotel_bookends on, a boundary between two stops a booked night separates is ignored: the night ends the day. Pass null to restore the automatic day ending.',
inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(MAX_TRIP_DAYS), boundary: roadtripDayBoundarySchema.nullable() },
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when,
})
diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts
index d93adb4a6..cecb0d276 100644
--- a/server/src/nest/roadtrip/roadtrip-plan.service.ts
+++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts
@@ -11,6 +11,7 @@ import {
carrierLegsFor,
carrierSeam,
foldRouteRun,
+ hotelBookendsOn,
isStationaryJoin,
splitIntoRuns,
spillChains,
@@ -20,8 +21,10 @@ import {
formatDistance,
formatDurationShort,
seatCarrierStops,
+ seatNightBookends,
undatedRides,
viasOnLeg,
+ type BookendStay,
type CarrierBooking,
type CarrierSeam,
type DistanceUnit,
@@ -79,6 +82,13 @@ interface VisitRow {
stop_type: string | null;
fill_percent: number | null;
}
+/**
+ * A booked night as the road trip reads it: the stay, the days it spans, where it is and
+ * the earliest booking linked to it. The rule reads these for the hotel at the edges of
+ * the days around the night (`seatNightBookends`); get_roadtrip_context reports them as
+ * they are, with the check-in beside the check-out.
+ */
+type StayRow = BookendStay & { check_in: string | null };
const stopKey = (s: RoadtripStop) =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
@@ -110,9 +120,21 @@ export class RoadtripPlanService {
WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`,
tripId,
);
+ // Every stay of the trip in id order, the order the rule reads them in, so a browser
+ // holding them in another order still picks the same hotel where two overlap. The
+ // booking is the earliest linked one, the one the planner opens from the hotel's row.
+ const stays = this.db.all(
+ `SELECT a.id, a.place_id, a.start_day_id, a.end_day_id, a.check_in, a.check_out,
+ p.name AS place_name, p.lat AS place_lat, p.lng AS place_lng,
+ (SELECT MIN(r.id) FROM reservations r WHERE r.accommodation_id = a.id) AS reservation_id
+ FROM day_accommodations a LEFT JOIN places p ON p.id = a.place_id
+ WHERE a.trip_id = ? ORDER BY a.id`,
+ tripId,
+ );
return {
days,
visits,
+ stays,
carriers: this.carriers(tripId),
settings: this.preferences.read(tripId),
profiles: this.router.profiles(),
@@ -200,7 +222,7 @@ export class RoadtripPlanService {
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
const seams = context.carriers.map((booking) => carrierSeam(booking)).filter((seam): seam is CarrierSeam => seam !== null);
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
- const plan: PlanDay[] = context.days.map((day) => {
+ const stored: PlanDay[] = context.days.map((day) => {
const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
const stops = visits.map(
(v, index): RoadtripStop => ({
@@ -239,6 +261,10 @@ export class RoadtripPlanService {
),
};
});
+ // A day after a booked night starts at the stay and a day before one ends there, by
+ // the rule the planner runs in the browser, and only while the trip (or the preview's
+ // own settings) has it switched on. Everything below reads the seated days.
+ const plan = hotelBookendsOn(preferences) ? seatNightBookends(stored, context.days, context.stays) : stored;
const allLegs: Record = {};
const snapByDay: Record> = {};
const missedByDay: Record = {};
diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts
index a85c1b1d7..066477649 100644
--- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts
+++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts
@@ -36,7 +36,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'get_roadtrip_context',
description:
- 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
+ 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. stays lists every booked stay with its check-in and check-out day, its place and the earliest linked reservation (reservation_id). Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -49,7 +49,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'calculate_roadtrip',
description:
- 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
+ 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. With roadtrip_hotel_bookends switched on (missing means off), a day after a booked night starts at that stay and a day before one ends there. Such stops carry `bookend` {phase, accommodationId, reservationId, checkingOut, checkingIn, checkOut}, stopType hotel, the real placeId of the stay and a synthetic negative assignmentId that belongs to no assignment: they cannot be reordered, edited or given via points, and `failures` may name them. The check-out is a label, never a departure time. A day between two nights in the same stay with no visit has no drive. Pass settings {roadtrip_hotel_bookends:true} to preview the drive from and to the stays without saving it. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
inputSchema: roadtripPlanRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -93,7 +93,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'search_roadtrip_corridor',
description:
- 'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.',
+ 'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. With roadtrip_hotel_bookends on, the road of the day includes the drive from the stay slept in and to the stay of that night; a place found there belongs at the start or the end of the stored day. Routing failures refuse the corridor search rather than searching an invented line.',
inputSchema: roadtripCorridorRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts
index 4d1f1e843..b55723337 100644
--- a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts
+++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts
@@ -35,7 +35,7 @@ export class RoadtripPreferencesMcp {
@Tool({
name: 'get_roadtrip_settings',
description:
- 'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
+ 'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Missing roadtrip_hotel_bookends means off. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -49,7 +49,7 @@ export class RoadtripPreferencesMcp {
@Tool({
name: 'update_roadtrip_settings',
description:
- 'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
+ 'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Set roadtrip_hotel_bookends true to start and end each day at the stay: a day after a booked night starts where it was slept, a day before one ends there. False, like missing, drives each day from its first stored stop, as before. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
inputSchema: { tripId: z.number().int().positive(), settings: roadtripPreferencesUpdateSchema },
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'trips', mode: 'write' },
diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts
index fd3324462..65ab782a1 100644
--- a/server/tests/e2e/roadtrip.e2e.test.ts
+++ b/server/tests/e2e/roadtrip.e2e.test.ts
@@ -253,6 +253,42 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
db.prepare('DELETE FROM trip_members WHERE trip_id = 5 AND user_id = 2').run();
}
});
+ it('ROADTRIP-E2E-013: the stay switch is off until it is set, and round-trips as a boolean', async () => {
+ db.prepare("DELETE FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").run();
+ const before = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200);
+ expect(before.body.preferences).not.toHaveProperty('roadtrip_hotel_bookends');
+
+ const saved = await request(server)
+ .put('/api/trips/5/roadtrip/preferences')
+ .set('Cookie', cookie())
+ .send({ roadtrip_hotel_bookends: true })
+ .expect(200);
+ expect(saved.body.preferences.roadtrip_hotel_bookends).toBe(true);
+ const after = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200);
+ expect(after.body.preferences.roadtrip_hotel_bookends).toBe(true);
+
+ await request(server)
+ .put('/api/trips/5/roadtrip/preferences')
+ .set('Cookie', cookie())
+ .send({ roadtrip_hotel_bookends: false })
+ .expect(200);
+ expect(
+ db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").get(),
+ ).toEqual({ value: 'false' });
+ });
+ it('ROADTRIP-E2E-014: a stay switch that is not a boolean is a 400, not a stored row', async () => {
+ db.prepare("DELETE FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").run();
+ for (const value of ['true', 1, null]) {
+ await request(server)
+ .put('/api/trips/5/roadtrip/preferences')
+ .set('Cookie', cookie())
+ .send({ roadtrip_hotel_bookends: value })
+ .expect(400);
+ }
+ expect(
+ db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").get(),
+ ).toBeUndefined();
+ });
it('refuses strangers and invalid daily windows', async () => {
await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404);
await request(server)
diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts
index 4c66b13f3..a9225e39c 100644
--- a/server/tests/helpers/mcp-test-controllers.ts
+++ b/server/tests/helpers/mcp-test-controllers.ts
@@ -83,6 +83,8 @@ import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { RoadtripMcp } from '../../src/nest/roadtrip/roadtrip.mcp';
+import { RoadtripPreferencesMcp } from '../../src/nest/roadtrip/roadtrip-preferences.mcp';
+import { RoadtripPreferencesService } from '../../src/nest/roadtrip/roadtrip-preferences.service';
import { RoadtripService } from '../../src/nest/roadtrip/roadtrip.service';
import { notificationsStub } from './notifications';
import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service';
@@ -223,6 +225,7 @@ export function createMcpTestRegistry(): McpRegistry {
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
new DaysMcp(daysService, authService, guards),
new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService),
+ new RoadtripPreferencesMcp(new RoadtripPreferencesService(dbService, realtimeService), authService, addonsService, dbService, guards),
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards),
new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards),
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
diff --git a/server/tests/unit/mcp/tools-roadtrip.test.ts b/server/tests/unit/mcp/tools-roadtrip.test.ts
index 0c8ed0dbf..2e18795e3 100644
--- a/server/tests/unit/mcp/tools-roadtrip.test.ts
+++ b/server/tests/unit/mcp/tools-roadtrip.test.ts
@@ -1,5 +1,5 @@
/**
- * The five road-trip MCP tools.
+ * The five road-trip MCP tools, and the shared driving settings beside them.
*
* They had no test at all, and the shared harness seeds the addon disabled, so
* `when: roadtripAddonOn` kept them from ever attaching: the handler bodies had
@@ -315,6 +315,47 @@ describe('road-trip MCP tools', () => {
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 });
});
+ it('MCP-ROADTRIP-012: the stay switch is off until it is set, and round-trips through the settings tools', async () => {
+ const { user, trip } = scenario();
+ const stored = () =>
+ testDb.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = ? AND key = 'roadtrip_hotel_bookends'").get(trip.id);
+
+ await withHarness(user.id, async (h) => {
+ const before = parseToolResult(await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } })) as {
+ settings: Record;
+ };
+ // Missing means off: nothing is written for a trip that never touched it.
+ expect(before.settings).not.toHaveProperty('roadtrip_hotel_bookends');
+ expect(stored()).toBeUndefined();
+
+ const saved = await h.client.callTool({
+ name: 'update_roadtrip_settings',
+ arguments: { tripId: trip.id, settings: { roadtrip_hotel_bookends: true } },
+ });
+ expect(saved.isError).toBeFalsy();
+ const after = parseToolResult(await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } })) as {
+ settings: Record;
+ };
+ expect(after.settings.roadtrip_hotel_bookends).toBe(true);
+ });
+ expect(stored()).toEqual({ value: 'true' });
+ });
+
+ it('MCP-ROADTRIP-013: a stay switch that is not a boolean is refused, and nothing is stored', async () => {
+ const { user, trip } = scenario();
+
+ await withHarness(user.id, async (h) => {
+ for (const value of ['yes', 1, null]) {
+ const res = await h.client.callTool({
+ name: 'update_roadtrip_settings',
+ arguments: { tripId: trip.id, settings: { roadtrip_hotel_bookends: value } },
+ });
+ expect(res.isError, String(value)).toBeTruthy();
+ }
+ });
+ expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_preferences WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
+ });
+
it('MCP-ROADTRIP-009: removing one that is not on the day is refused, not silently ignored', async () => {
const { user, trip, day } = scenario();
diff --git a/server/tests/unit/nest/roadtrip-plan.service.test.ts b/server/tests/unit/nest/roadtrip-plan.service.test.ts
index 99e981db8..9f13a5224 100644
--- a/server/tests/unit/nest/roadtrip-plan.service.test.ts
+++ b/server/tests/unit/nest/roadtrip-plan.service.test.ts
@@ -12,6 +12,8 @@ import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.se
import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp';
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
+import type { RoadtripPreferences } from '@trek/shared';
+import { bookendAssignmentId, type RoadtripStop } from '@trek/shared/roadtrip';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -358,3 +360,218 @@ describe('a ferry across the day, and one on no day (#2461)', () => {
expect(body.complete).toBe(true);
});
});
+
+/**
+ * A booked night at both ends of the days around it, read from the planning SELECTs.
+ *
+ * The same two trips as the rule's own cases in shared (nightBookends.spec.ts, ROADTRIP-
+ * BOOKENDS-001..005) with the same expected days, so the server's plan and the browser's
+ * cannot drift apart: the stays come off `context.stays`, the rule is the shared one, and
+ * only the router is a stand-in. Off unless the trip switches it on.
+ */
+describe('a booked night at both ends of its days', () => {
+ const GETAWAY = { lat: -33.71, lng: 150.31 };
+ const WALLINGA = { lat: -34.1, lng: 150.9 };
+ const HOTEL = { lat: 45.07, lng: 7.68 };
+
+ function trip(settings: RoadtripPreferences = { roadtrip_hotel_bookends: true }) {
+ const { user } = createUser(db);
+ const created = createTrip(db, user.id);
+ const router = hourlyRouter();
+ const plans = new RoadtripPlanService(
+ new DatabaseService(db),
+ { getUserSettings: () => ({}) } as never,
+ { read: () => settings } as never,
+ router as never,
+ { listForTrip: () => [], tracksForTrip: () => [] } as never,
+ { list: () => [] } as never,
+ );
+ return { user, trip: created, router, plans };
+ }
+ const visit = (tripId: number, dayId: number, name: string, at: { lat: number; lng: number }) => {
+ const place = createPlace(db, tripId, { name, ...at });
+ db.prepare('UPDATE places SET duration_minutes = 0 WHERE id = ?').run(place.id);
+ return { place, assignment: createDayAssignment(db, dayId, place.id) };
+ };
+ const linkBooking = (tripId: number, stayId: number) =>
+ Number(
+ db
+ .prepare("INSERT INTO reservations (trip_id, title, type, accommodation_id) VALUES (?, 'Motel', 'hotel', ?)")
+ .run(tripId, stayId).lastInsertRowid,
+ );
+ const shape = (stops: RoadtripStop[]) =>
+ stops.map((s) => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name));
+ const lats = (router: ReturnType) =>
+ router.route.mock.calls.map((call) => call[3].map((p: { lat: number }) => p.lat));
+
+ /** A check-in with two places, then a transfer day that is only the next check-in. */
+ function cam(settings?: RoadtripPreferences) {
+ const t = trip(settings);
+ const [d1, d2, d3] = [createDay(db, t.trip.id), createDay(db, t.trip.id), createDay(db, t.trip.id)];
+ const getaway = visit(t.trip.id, d1.id, 'Getaway', GETAWAY);
+ visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 });
+ visit(t.trip.id, d1.id, 'Falls', { lat: -33.65, lng: 150.38 });
+ const wallinga = visit(t.trip.id, d2.id, 'Wallinga', WALLINGA);
+ const stayA = createDayAccommodation(db, t.trip.id, getaway.place.id, d1.id, d2.id, {
+ check_in: '14:00',
+ check_out: '10:00',
+ });
+ const stayB = createDayAccommodation(db, t.trip.id, wallinga.place.id, d2.id, d3.id, { check_in: '15:00' });
+ return { ...t, days: [d1, d2, d3], stayA, stayB };
+ }
+
+ /** Three nights in one hotel with two places on every day before the check-out. */
+ function simeon(settings?: RoadtripPreferences) {
+ const t = trip(settings);
+ const days = [1, 2, 3, 4].map(() => createDay(db, t.trip.id));
+ const h = visit(t.trip.id, days[0].id, 'H', HOTEL);
+ (
+ [
+ [0, 'P1', 45.1],
+ [0, 'P2', 45.2],
+ [1, 'P3', 45.3],
+ [1, 'P4', 45.4],
+ [2, 'P5', 45.5],
+ [2, 'P6', 45.6],
+ ] as const
+ ).forEach(([day, name, lat]) => visit(t.trip.id, days[day].id, name, { lat, lng: 7.5 }));
+ const stay = createDayAccommodation(db, t.trip.id, h.place.id, days[0].id, days[3].id, { check_in: '15:00' });
+ return { ...t, days, stay };
+ }
+
+ it('seats the check-in day back at the stay and starts the transfer day there, as the rule in shared does', async () => {
+ const { user, trip: created, router, plans, days, stayA } = cam();
+ const reservationId = linkBooking(created.id, stayA.id);
+ linkBooking(created.id, stayA.id);
+
+ const { calculated, failures } = await plans.calculate(created.id, user.id);
+
+ expect(calculated.days.map((d) => shape(d.stops))).toEqual([
+ ['Getaway', 'Lookout', 'Falls', `evening:${stayA.id}`],
+ [`morning:${stayA.id}`, 'Wallinga'],
+ ]);
+ const [back, out] = [calculated.days[0].stops[3], calculated.days[1].stops[0]];
+ expect(back.bookend).toEqual({
+ phase: 'evening',
+ accommodationId: stayA.id,
+ reservationId,
+ checkingOut: false,
+ checkingIn: true,
+ checkOut: null,
+ });
+ expect(back).toMatchObject({ name: 'Getaway', lat: GETAWAY.lat, lng: GETAWAY.lng, stopType: 'hotel', time: null });
+ expect(back.assignmentId).toBe(bookendAssignmentId(days[0].id, 'evening'));
+ expect(out.bookend).toMatchObject({ phase: 'morning', checkingOut: true, checkOut: '10:00', reservationId });
+ // The check-out is a label: the drive leaves when it must to make the 15:00 check-in.
+ expect(calculated.days[1].schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
+ ['14:00', '14:00'],
+ ['15:00', expect.any(String)],
+ ]);
+ // One run a day, the hotel legs inside it, and the check-out day is no card.
+ expect(lats(router)).toEqual([
+ [GETAWAY.lat, -33.73, -33.65, GETAWAY.lat],
+ [GETAWAY.lat, WALLINGA.lat],
+ ]);
+ expect(calculated.days.map((d) => d.distance)).toEqual([180_000, 60_000]);
+ expect(calculated.totalStops).toBe(4);
+ expect(failures).toEqual([]);
+
+ // calculate_roadtrip hands the assistant the same stops.
+ const tool = new RoadtripPlanningMcp(plans, {} as never, {} as never);
+ const answer = await tool.calculate({ tripId: created.id, includeGeometry: false }, { userId: user.id } as McpContext);
+ const body = JSON.parse(answer.content[0].text as string);
+ expect(body.days[1].stops[0].bookend).toMatchObject({ phase: 'morning', accommodationId: stayA.id });
+ });
+
+ it('asks the router nothing for a night spent at one hotel when the days are connected', async () => {
+ const { user, trip: created, router, plans } = cam({ roadtrip_hotel_bookends: true, roadtrip_connect_days: true });
+
+ const { calculated } = await plans.calculate(created.id, user.id);
+
+ // The two day runs, and no seam from the Getaway to the Getaway.
+ expect(router.route).toHaveBeenCalledTimes(2);
+ expect(calculated.days[1].arrivingLeg).toBeFalsy();
+ expect(calculated.totalDistance).toBe(240_000);
+ });
+
+ it('starts and ends every day between the nights at the hotel, and leaves the check-out day undriven', async () => {
+ const { user, trip: created, router, plans, days, stay } = simeon();
+
+ const { calculated } = await plans.calculate(created.id, user.id);
+
+ expect(calculated.days.map((d) => shape(d.stops))).toEqual([
+ ['H', 'P1', 'P2', `evening:${stay.id}`],
+ [`morning:${stay.id}`, 'P3', 'P4', `evening:${stay.id}`],
+ [`morning:${stay.id}`, 'P5', 'P6', `evening:${stay.id}`],
+ ]);
+ expect(calculated.days[1].stops[0].bookend).toMatchObject({ checkingOut: false, checkingIn: false, checkOut: null });
+ // The evening keeps the index the next stored stop would have.
+ expect(calculated.days[1].stops[3].ownerIndex).toBe(2);
+ expect(calculated.days.map((d) => d.dayId)).not.toContain(days[3].id);
+ expect(router.route).toHaveBeenCalledTimes(3);
+ expect(lats(router)[1]).toEqual([HOTEL.lat, 45.3, 45.4, HOTEL.lat]);
+ });
+
+ it('drives the stored days while the trip has it off, and a preview can switch it either way', async () => {
+ const off = simeon({});
+ const stored = await off.plans.calculate(off.trip.id, off.user.id);
+ expect(stored.calculated.days.map((d) => shape(d.stops))).toEqual([
+ ['H', 'P1', 'P2'],
+ ['P3', 'P4'],
+ ['P5', 'P6'],
+ ]);
+
+ const preview = await off.plans.calculate(off.trip.id, off.user.id, { roadtrip_hotel_bookends: true });
+ expect(preview.calculated.days[1].stops.map((s) => s.bookend?.phase ?? null)).toEqual(['morning', null, null, 'evening']);
+ expect(preview.preferences.roadtrip_hotel_bookends).toBe(true);
+
+ const on = simeon();
+ const without = await on.plans.calculate(on.trip.id, on.user.id, { roadtrip_hotel_bookends: false });
+ expect(without.calculated.days.flatMap((d) => d.stops).some((s) => s.bookend)).toBe(false);
+ });
+
+ it('reads every stay with its place, its nights and its earliest booking into the context', () => {
+ const { user, trip: created, plans, days, stayA, stayB } = cam();
+ const first = linkBooking(created.id, stayA.id);
+ linkBooking(created.id, stayA.id);
+
+ const context = plans.context(created.id, user.id);
+
+ expect(context.stays).toEqual([
+ {
+ id: stayA.id,
+ place_id: expect.any(Number),
+ start_day_id: days[0].id,
+ end_day_id: days[1].id,
+ check_in: '14:00',
+ check_out: '10:00',
+ place_name: 'Getaway',
+ place_lat: GETAWAY.lat,
+ place_lng: GETAWAY.lng,
+ reservation_id: first,
+ },
+ expect.objectContaining({ id: stayB.id, place_name: 'Wallinga', check_in: '15:00', reservation_id: null }),
+ ]);
+ });
+
+ it('names the hotel in the failures of a run it pushes past the router’s waypoint limit', async () => {
+ const t = trip({});
+ const [d1, d2] = [createDay(db, t.trip.id), createDay(db, t.trip.id)];
+ for (let i = 0; i < 100; i++) visit(t.trip.id, d1.id, `Stop ${i}`, { lat: 40 + i * 0.01, lng: 5 });
+ const hotel = createPlace(db, t.trip.id, { name: 'Tonight', ...HOTEL });
+ createDayAccommodation(db, t.trip.id, hotel.id, d1.id, d2.id);
+
+ // A hundred stored stops are one run the router takes.
+ const stored = await t.plans.calculate(t.trip.id, t.user.id);
+ expect(stored.failures).toEqual([]);
+
+ // Tonight's hotel is the hundred and first waypoint.
+ const seated = await t.plans.calculate(t.trip.id, t.user.id, { roadtrip_hotel_bookends: true });
+ expect(seated.failures).toHaveLength(100);
+ expect(seated.failures[99]).toMatchObject({
+ toAssignmentId: bookendAssignmentId(d1.id, 'evening'),
+ reason: 'More than 100 waypoints in this run.',
+ });
+ expect(seated.failures[99].toAssignmentId).toBeLessThan(-6_000_000_000);
+ });
+});
diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts
index 456bad0c5..bbef58af7 100644
--- a/server/tests/unit/nest/roadtrip-planning.test.ts
+++ b/server/tests/unit/nest/roadtrip-planning.test.ts
@@ -52,9 +52,13 @@ function setup() {
stop_type: null,
fill_percent: null,
}));
+ // The trip's stays, none unless a case books one.
+ const stays: Record[] = [];
const db = {
canAccessTrip: vi.fn(() => true),
- all: vi.fn((sql: string) => (sql.includes('FROM day_assignments') ? visits : days)),
+ all: vi.fn((sql: string) =>
+ sql.includes('FROM day_assignments') ? visits : sql.includes('FROM day_accommodations') ? stays : days,
+ ),
};
const router = {
profiles: () => ['driving'],
@@ -88,7 +92,7 @@ function setup() {
roadtrip as never,
boundaries as never,
);
- return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries };
+ return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries, stays };
}
describe('roadtrip preferences', () => {
@@ -134,6 +138,23 @@ describe('roadtrip preferences', () => {
await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx);
expect(s.preferenceDb.run).not.toHaveBeenCalled();
});
+ it('saves the switch that starts and ends each day at the stay, through the tool too, and reads it back', async () => {
+ const s = setup();
+ expect(s.preferences.read(10).roadtrip_hotel_bookends).toBeUndefined();
+ const mcp = new RoadtripPreferencesMcp(
+ s.preferences,
+ { isDemoUser: () => false } as never,
+ {} as never,
+ s.db as never,
+ { hasTripPermission: () => true } as never,
+ );
+ const res = await mcp.update({ tripId: 10, settings: { roadtrip_hotel_bookends: true } }, ctx);
+ expect(JSON.parse(res.content[0].text as string).settings.roadtrip_hotel_bookends).toBe(true);
+ expect(s.preferences.read(10).roadtrip_hotel_bookends).toBe(true);
+ s.preferences.update(10, { roadtrip_hotel_bookends: false });
+ expect(s.preferences.read(10).roadtrip_hotel_bookends).toBe(false);
+ expect(roadtripPreferencesUpdateSchema.safeParse({ roadtrip_hotel_bookends: 'yes' }).success).toBe(false);
+ });
it('tells the assistant why a window was refused instead of the exception class name', async () => {
// The service refuses an inverted window with the `{ error }` body the route
// sends verbatim; left to the SDK the tool would answer "Http Exception".
@@ -169,6 +190,29 @@ describe('browser-independent roadtrip calculation', () => {
expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00');
expect(s.preferenceDb.run).not.toHaveBeenCalled();
});
+ it('previews a day that ends at tonight’s stay without saving the switch, and leaves it off otherwise', async () => {
+ const s = setup();
+ s.stays.push({
+ id: 7,
+ place_id: 70,
+ start_day_id: 1,
+ end_day_id: 2,
+ check_in: '15:00',
+ check_out: '10:00',
+ place_name: 'Tonight',
+ place_lat: 48.5,
+ place_lng: 4,
+ reservation_id: null,
+ });
+ const stored = await s.plans.calculate(10, 5);
+ expect(stored.calculated.days.flatMap((d) => d.stops).some((stop) => stop.bookend)).toBe(false);
+
+ const preview = await s.plans.calculate(10, 5, { roadtrip_hotel_bookends: true });
+ const hotel = preview.calculated.days.flatMap((d) => d.stops).find((stop) => stop.bookend);
+ expect(hotel).toMatchObject({ name: 'Tonight', bookend: { phase: 'evening', accommodationId: 7, checkingIn: true } });
+ expect(s.preferences.read(10).roadtrip_hotel_bookends).toBeUndefined();
+ expect(s.preferenceDb.run).not.toHaveBeenCalled();
+ });
it('checks trip access before reading or routing', async () => {
const s = setup();
s.db.canAccessTrip.mockReturnValue(false);
@@ -332,6 +376,31 @@ describe('Roadtrip MCP registration and search', () => {
registry.attach(registrar as never, { ...ctx, scopes: null });
expect(names).toEqual([]);
});
+ it('tells the assistant the stay switch is off until it is set, on every tool the switch changes', () => {
+ const s = setup();
+ const registry = createTestRegistry(
+ [
+ new RoadtripPreferencesMcp(s.preferences, {} as never, { isAddonEnabled: () => true } as never, s.db as never, {} as never),
+ new RoadtripPlanningMcp(s.plans, {} as never, { isAddonEnabled: () => true } as never),
+ ],
+ { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
+ );
+ const descriptions: Record = {};
+ registry.attach(
+ {
+ registerTool: (name: string, config: Record) => {
+ descriptions[name] = String(config.description);
+ },
+ } as never,
+ { ...ctx, scopes: null },
+ );
+ expect(descriptions.get_roadtrip_settings).toContain('Missing roadtrip_hotel_bookends means off.');
+ expect(descriptions.update_roadtrip_settings).toContain('Set roadtrip_hotel_bookends true to start and end each day at the stay');
+ expect(descriptions.calculate_roadtrip).toContain('With roadtrip_hotel_bookends switched on (missing means off)');
+ expect(descriptions.calculate_roadtrip).toContain('{roadtrip_hotel_bookends:true}');
+ expect(descriptions.get_roadtrip_context).toContain('stays lists every booked stay');
+ expect(descriptions.search_roadtrip_corridor).toContain('With roadtrip_hotel_bookends on');
+ });
it('returns corridor sources, truncation and matching brands without adding stops', async () => {
const plans = {
calculate: vi.fn(async () => ({
diff --git a/server/tests/unit/nest/roadtrip-read-tools.test.ts b/server/tests/unit/nest/roadtrip-read-tools.test.ts
index 0f8f948b8..baf278282 100644
--- a/server/tests/unit/nest/roadtrip-read-tools.test.ts
+++ b/server/tests/unit/nest/roadtrip-read-tools.test.ts
@@ -5,6 +5,8 @@ import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
import { RoadtripHazardsMcp } from '../../../src/nest/roadtrip/roadtrip-hazards.mcp';
import { GoogleRouteMcp } from '../../../src/nest/roadtrip/google-route.mcp';
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
+import { createTestRegistry } from '../../../src/nest-mcp';
+import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
const ctx = { userId: 5 } as McpContext;
describe('roadtrip read tools', () => {
@@ -69,6 +71,18 @@ describe('roadtrip read tools', () => {
expect(realtime.broadcast).not.toHaveBeenCalled();
});
+ it('tells the assistant that a boundary over a booked night is ignored while the stay switch is on', () => {
+ const tool = new DayBoundariesMcp({} as never, {} as never, {} as never, {} as never, {} as never, { isAddonEnabled: () => true } as never);
+ const registry = createTestRegistry([tool], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess });
+ const descriptions: Record = {};
+ registry.attach(
+ { registerTool: (name: string, config: Record) => { descriptions[name] = String(config.description); } } as never,
+ { ...ctx, scopes: null },
+ );
+ expect(descriptions.set_day_boundary).toContain(
+ 'With roadtrip_hotel_bookends on, a boundary between two stops a booked night separates is ignored: the night ends the day.',
+ );
+ });
it('does not expose manual boundaries to nonmembers', async () => {
const boundaries = { list: vi.fn(() => [{ day_number: 1 }]) };
const db = { canAccessTrip: vi.fn(() => false) };
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index 2a643e5a3..1405f7314 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -114,6 +114,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'ربط الأيام',
'roadtrip.line.dayColors': 'لون لكل يوم',
'roadtrip.line.hint': 'يحسب أيضًا المسافة بين يومين ويحتسبها على يوم الوصول.',
+ 'roadtrip.line.hotelBookends': 'ابدأ كل يوم وأنهِه في مكان إقامتك',
+ 'roadtrip.line.hotelBookendsHint': 'بعد ليلة محجوزة يبدأ اليوم حيث نمت، وقبل ليلة محجوزة ينتهي هناك.',
'roadtrip.avoid.section': 'التجنب قدر الإمكان',
'roadtrip.avoid.toll': 'الطرق ذات الرسوم',
'roadtrip.avoid.motorway': 'الطرق السريعة',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index 9558f0114..b79f927a0 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Conectar os dias',
'roadtrip.line.dayColors': 'Uma cor por dia',
'roadtrip.line.hint': 'Também calcula o trecho entre dois dias e conta no dia de chegada.',
+ 'roadtrip.line.hotelBookends': 'Começar e terminar cada dia na hospedagem',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Depois de uma noite reservada, o dia começa onde você dormiu, e antes de uma ele termina lá.',
'roadtrip.avoid.section': 'Evitar quando possível',
'roadtrip.avoid.toll': 'Pedágios',
'roadtrip.avoid.motorway': 'Rodovias',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index f5a3c0f73..0eab44dee 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Connecta els dies',
'roadtrip.line.dayColors': 'Un color per dia',
'roadtrip.line.hint': 'També calcula el tram entre dos dies i el compta al dia d’arribada.',
+ 'roadtrip.line.hotelBookends': 'Comença i acaba cada dia a l’allotjament',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Després d’una nit reservada, el dia comença on has dormit, i abans d’una acaba allà.',
'roadtrip.avoid.section': 'Evita quan sigui possible',
'roadtrip.avoid.toll': 'Peatges',
'roadtrip.avoid.motorway': 'Autopistes',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 1421f06a3..820932997 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -115,6 +115,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Propojit dny',
'roadtrip.line.dayColors': 'Barva na den',
'roadtrip.line.hint': 'Spočítá i úsek mezi dvěma dny a připočte ho ke dni příjezdu.',
+ 'roadtrip.line.hotelBookends': 'Začínat a končit každý den v ubytování',
+ 'roadtrip.line.hotelBookendsHint': 'Po zarezervované noci začíná den tam, kde jste spali, a před ní tam končí.',
'roadtrip.avoid.section': 'Vyhnout se, pokud to jde',
'roadtrip.avoid.toll': 'Mýtné silnice',
'roadtrip.avoid.motorway': 'Dálnice',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index c86414591..4664d0d08 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -114,6 +114,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Tage verbinden',
'roadtrip.line.dayColors': 'Farbe je Tag',
'roadtrip.line.hint': 'Verbindungsfahrten zählen zum Ankunftstag.',
+ 'roadtrip.line.hotelBookends': 'Jeden Tag an der Unterkunft beginnen und beenden',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Nach einer gebuchten Nacht beginnt der Tag dort, wo du geschlafen hast, und vor einer endet er dort.',
'roadtrip.avoid.section': 'Möglichst meiden',
'roadtrip.avoid.toll': 'Mautstraßen',
'roadtrip.avoid.motorway': 'Autobahnen',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index 2ebef9cae..8bb7e7234 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -114,6 +114,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Connect the days',
'roadtrip.line.dayColors': 'A colour per day',
'roadtrip.line.hint': 'Connecting drives count toward the arrival day.',
+ 'roadtrip.line.hotelBookends': 'Start and end each day at your stay',
+ 'roadtrip.line.hotelBookendsHint':
+ 'After a booked night the day starts where you slept, and before one it ends there.',
'roadtrip.avoid.section': 'Avoid where possible',
'roadtrip.avoid.toll': 'Toll roads',
'roadtrip.avoid.motorway': 'Motorways',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 0a24cba69..5f075795b 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Conectar los días',
'roadtrip.line.dayColors': 'Un color por día',
'roadtrip.line.hint': 'También calcula el tramo entre dos días y lo cuenta en el día de llegada.',
+ 'roadtrip.line.hotelBookends': 'Empezar y terminar cada día en tu alojamiento',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Tras una noche reservada, el día empieza donde dormiste, y antes de una termina allí.',
'roadtrip.avoid.section': 'Evitar si es posible',
'roadtrip.avoid.toll': 'Peajes',
'roadtrip.avoid.motorway': 'Autopistas',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index f9d097afc..589754f4b 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -117,6 +117,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Relier les jours',
'roadtrip.line.dayColors': 'Une couleur par jour',
'roadtrip.line.hint': 'Calcule aussi le trajet entre deux jours et le compte au jour d’arrivée.',
+ 'roadtrip.line.hotelBookends': 'Commencer et finir chaque jour à votre hébergement',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Après une nuit réservée, la journée commence là où vous avez dormi, et avant une nuit réservée, elle s’y termine.',
'roadtrip.avoid.section': 'Éviter si possible',
'roadtrip.avoid.toll': 'Routes à péage',
'roadtrip.avoid.motorway': 'Autoroutes',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index b2dfcc295..48493888a 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -118,6 +118,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Σύνδεση των ημερών',
'roadtrip.line.dayColors': 'Ένα χρώμα ανά ημέρα',
'roadtrip.line.hint': 'Υπολογίζει και το τμήμα ανάμεσα σε δύο ημέρες, στην ημέρα άφιξης.',
+ 'roadtrip.line.hotelBookends': 'Έναρξη και λήξη κάθε ημέρας στο κατάλυμα',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Μετά από μια κρατημένη νύχτα η ημέρα ξεκινά εκεί όπου κοιμηθήκατε, και πριν από μία τελειώνει εκεί.',
'roadtrip.avoid.section': 'Αποφυγή όπου γίνεται',
'roadtrip.avoid.toll': 'Δρόμοι με διόδια',
'roadtrip.avoid.motorway': 'Αυτοκινητόδρομοι',
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index c8fe40a83..44ac8b1b6 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Napok összekötése',
'roadtrip.line.dayColors': 'Naponta egy szín',
'roadtrip.line.hint': 'A két nap közti szakaszt is kiszámolja, és az érkezés napjához adja.',
+ 'roadtrip.line.hotelBookends': 'Minden nap a szálláson kezdődjön és érjen véget',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Egy lefoglalt éjszaka után a nap ott kezdődik, ahol aludtál, egy lefoglalt éjszaka előtt pedig ott ér véget.',
'roadtrip.avoid.section': 'Kerülés, ha lehet',
'roadtrip.avoid.toll': 'Fizetős utak',
'roadtrip.avoid.motorway': 'Autópályák',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index 50abae47b..b2ec227d9 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Hubungkan hari',
'roadtrip.line.dayColors': 'Satu warna per hari',
'roadtrip.line.hint': 'Juga menghitung ruas antara dua hari dan memasukkannya ke hari kedatangan.',
+ 'roadtrip.line.hotelBookends': 'Mulai dan akhiri setiap hari di penginapan Anda',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Setelah malam yang dipesan, hari dimulai di tempat Anda menginap, dan sebelum malam yang dipesan, hari berakhir di sana.',
'roadtrip.avoid.section': 'Hindari bila bisa',
'roadtrip.avoid.toll': 'Jalan tol',
'roadtrip.avoid.motorway': 'Jalan bebas hambatan',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index b5ac61f0f..2f36633d5 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Collega i giorni',
'roadtrip.line.dayColors': 'Un colore per giorno',
'roadtrip.line.hint': 'Calcola anche il tratto tra due giorni e lo conta nel giorno di arrivo.',
+ 'roadtrip.line.hotelBookends': 'Inizia e termina ogni giorno al tuo alloggio',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Dopo una notte prenotata la giornata parte da dove hai dormito, e prima di una finisce lì.',
'roadtrip.avoid.section': 'Evita se possibile',
'roadtrip.avoid.toll': 'Strade a pedaggio',
'roadtrip.avoid.motorway': 'Autostrade',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index a2bb0ad07..bd69f7395 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -116,6 +116,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '日をつなぐ',
'roadtrip.line.dayColors': '日ごとに色を分ける',
'roadtrip.line.hint': '日と日の間の区間も計算し、到着した日に加えます。',
+ 'roadtrip.line.hotelBookends': '毎日を宿泊先で始めて宿泊先で終える',
+ 'roadtrip.line.hotelBookendsHint': '予約した夜の翌日は泊まった宿から始まり、予約した夜の日はその宿で終わります。',
'roadtrip.avoid.section': 'できるだけ避ける',
'roadtrip.avoid.toll': '有料道路',
'roadtrip.avoid.motorway': '高速道路',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index 4f064f3c1..3cfc72264 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '날짜 잇기',
'roadtrip.line.dayColors': '날짜별 색상',
'roadtrip.line.hint': '두 날 사이 구간도 계산해 도착한 날에 더합니다.',
+ 'roadtrip.line.hotelBookends': '매일 숙소에서 시작하고 숙소에서 끝내기',
+ 'roadtrip.line.hotelBookendsHint':
+ '예약한 밤 다음 날은 묵은 곳에서 시작하고, 예약한 밤이 있는 날은 그곳에서 끝납니다.',
'roadtrip.avoid.section': '가능하면 피하기',
'roadtrip.avoid.toll': '유료 도로',
'roadtrip.avoid.motorway': '고속도로',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index cd9e1197d..548d68976 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -117,6 +117,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Dagen verbinden',
'roadtrip.line.dayColors': 'Een kleur per dag',
'roadtrip.line.hint': 'Berekent ook het stuk tussen twee dagen en telt het bij de aankomstdag.',
+ 'roadtrip.line.hotelBookends': 'Elke dag bij je verblijf beginnen en eindigen',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Na een geboekte nacht begint de dag waar je sliep, en vóór een geboekte nacht eindigt hij daar.',
'roadtrip.avoid.section': 'Vermijden waar het kan',
'roadtrip.avoid.toll': 'Tolwegen',
'roadtrip.avoid.motorway': 'Snelwegen',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index 6c6c5c6ff..fe3a3c1e4 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Połącz dni',
'roadtrip.line.dayColors': 'Kolor na dzień',
'roadtrip.line.hint': 'Wyznacza też odcinek między dniami i liczy go do dnia przyjazdu.',
+ 'roadtrip.line.hotelBookends': 'Zaczynaj i kończ każdy dzień w noclegu',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Po zarezerwowanej nocy dzień zaczyna się w miejscu noclegu, a przed taką nocą tam się kończy.',
'roadtrip.avoid.section': 'Omijaj, jeśli się da',
'roadtrip.avoid.toll': 'Drogi płatne',
'roadtrip.avoid.motorway': 'Autostrady',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index 22bf5c26a..3bde81cd4 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Соединить дни',
'roadtrip.line.dayColors': 'Цвет на день',
'roadtrip.line.hint': 'Также считает участок между двумя днями и относит его ко дню прибытия.',
+ 'roadtrip.line.hotelBookends': 'Начинать и заканчивать каждый день в месте проживания',
+ 'roadtrip.line.hotelBookendsHint':
+ 'После забронированной ночи день начинается там, где вы ночевали, а перед ней заканчивается там.',
'roadtrip.avoid.section': 'Избегать по возможности',
'roadtrip.avoid.toll': 'Платные дороги',
'roadtrip.avoid.motorway': 'Автомагистрали',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 19bbde618..d3ac71ac2 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -114,6 +114,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Koppla ihop dagarna',
'roadtrip.line.dayColors': 'En färg per dag',
'roadtrip.line.hint': 'Beräknar även sträckan mellan två dagar och räknar den på ankomstdagen.',
+ 'roadtrip.line.hotelBookends': 'Börja och avsluta varje dag vid ditt boende',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Efter en bokad natt börjar dagen där du sov, och före en bokad natt slutar den där.',
'roadtrip.avoid.section': 'Undvik om det går',
'roadtrip.avoid.toll': 'Avgiftsvägar',
'roadtrip.avoid.motorway': 'Motorvägar',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index 3558b909b..1d5b12df2 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Günleri birleştir',
'roadtrip.line.dayColors': 'Her güne bir renk',
'roadtrip.line.hint': 'İki gün arasındaki yolu da hesaplar ve varış gününe sayar.',
+ 'roadtrip.line.hotelBookends': 'Her güne konaklama yerinde başla ve orada bitir',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Rezerve edilmiş bir geceden sonra gün uyuduğunuz yerde başlar, böyle bir geceden önce de orada biter.',
'roadtrip.avoid.section': 'Mümkünse kaçın',
'roadtrip.avoid.toll': 'Paralı yollar',
'roadtrip.avoid.motorway': 'Otoyollar',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 0019e4f2b..365c6eee8 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'З’єднати дні',
'roadtrip.line.dayColors': 'Колір на день',
'roadtrip.line.hint': 'Також рахує ділянку між двома днями і зараховує її дню прибуття.',
+ 'roadtrip.line.hotelBookends': 'Починати й завершувати кожен день у місці проживання',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Після заброньованої ночі день починається там, де ви ночували, а перед нею там і закінчується.',
'roadtrip.avoid.section': 'Уникати за можливості',
'roadtrip.avoid.toll': 'Платні дороги',
'roadtrip.avoid.motorway': 'Автомагістралі',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index 05a3b7305..3c4c348b2 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Nối các ngày',
'roadtrip.line.dayColors': 'Mỗi ngày một màu',
'roadtrip.line.hint': 'Cũng tính đoạn giữa hai ngày và cộng vào ngày tới nơi.',
+ 'roadtrip.line.hotelBookends': 'Bắt đầu và kết thúc mỗi ngày tại nơi lưu trú',
+ 'roadtrip.line.hotelBookendsHint':
+ 'Sau một đêm đã đặt, ngày bắt đầu ở nơi bạn ngủ, và trước một đêm đã đặt, ngày kết thúc ở đó.',
'roadtrip.avoid.section': 'Tránh nếu có thể',
'roadtrip.avoid.toll': 'Đường thu phí',
'roadtrip.avoid.motorway': 'Đường cao tốc',
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index bb53dc685..544ecdaaf 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -109,6 +109,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '連接各天',
'roadtrip.line.dayColors': '每天一種顏色',
'roadtrip.line.hint': '也會規劃兩天之間的路段,並計入抵達的那一天。',
+ 'roadtrip.line.hotelBookends': '每天從住宿地點出發,並在住宿地點結束',
+ 'roadtrip.line.hotelBookendsHint': '預訂的一晚之後,當天從你住的地方出發;預訂的一晚之前,當天在那裡結束。',
'roadtrip.avoid.section': '盡量避開',
'roadtrip.avoid.toll': '收費公路',
'roadtrip.avoid.motorway': '高速公路',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index fe29c1570..b5eae808a 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -109,6 +109,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '连接各天',
'roadtrip.line.dayColors': '每天一种颜色',
'roadtrip.line.hint': '也会规划两天之间的路段,并计入抵达的那一天。',
+ 'roadtrip.line.hotelBookends': '每天从住处出发,并在住处结束',
+ 'roadtrip.line.hotelBookendsHint': '预订的一晚之后,当天从你住的地方出发;预订的一晚之前,当天在那里结束。',
'roadtrip.avoid.section': '尽量避开',
'roadtrip.avoid.toll': '收费公路',
'roadtrip.avoid.motorway': '高速公路',
diff --git a/shared/src/roadtrip/planning.schema.spec.ts b/shared/src/roadtrip/planning.schema.spec.ts
index 633bb7aea..72872fc44 100644
--- a/shared/src/roadtrip/planning.schema.spec.ts
+++ b/shared/src/roadtrip/planning.schema.spec.ts
@@ -20,6 +20,17 @@ describe('Roadtrip MCP contracts', () => {
])('rejects invalid driving preferences %j', (settings) => {
expect(roadtripPreferencesUpdateSchema.safeParse(settings).success).toBe(false);
});
+ it('takes the switch that starts and ends each day at the stay as a boolean, and nothing else', () => {
+ expect(roadtripPreferencesUpdateSchema.parse({ roadtrip_hotel_bookends: true })).toEqual({
+ roadtrip_hotel_bookends: true,
+ });
+ expect(roadtripPreferencesUpdateSchema.parse({ roadtrip_hotel_bookends: false })).toEqual({
+ roadtrip_hotel_bookends: false,
+ });
+ for (const value of ['true', 1, null, 'on']) {
+ expect(roadtripPreferencesUpdateSchema.safeParse({ roadtrip_hotel_bookends: value }).success).toBe(false);
+ }
+ });
it('allows clearing settings and preserves manual controls', () => {
expect(
roadtripPreferencesUpdateSchema.parse({
diff --git a/shared/src/roadtrip/preferences.schema.ts b/shared/src/roadtrip/preferences.schema.ts
index cfad5b4e7..ab28f97ab 100644
--- a/shared/src/roadtrip/preferences.schema.ts
+++ b/shared/src/roadtrip/preferences.schema.ts
@@ -22,6 +22,10 @@ export const roadtripPreferencesSchema = z
roadtrip_service_stops_in_days: z.boolean().optional(),
roadtrip_day_colors: z.boolean().optional(),
roadtrip_show_hazards: z.boolean().optional(),
+ // Whether a day after a booked night starts at the stay and a day before one ends
+ // there (`hotelBookendsOn`). Missing means off, so a trip drives as it always has
+ // until somebody switches it on.
+ roadtrip_hotel_bookends: z.boolean().optional(),
roadtrip_avoid: z
.string()
.max(32)