From b10b02c685199581a273ed857f4088bccfb1c9a1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 15:37:09 +0200 Subject: [PATCH] finish overnight stops and cross-day range warnings --- client/public/images/portals/check24.png | Bin 0 -> 3214 bytes client/public/images/portals/pincamp.png | Bin 0 -> 7688 bytes client/public/images/portals/pitchup.ico | Bin 0 -> 15086 bytes client/public/images/portals/trivago.png | Bin 0 -> 4409 bytes client/src/components/Map/MapView.tsx | 2 +- client/src/components/Map/MapViewAuto.tsx | 27 +---- client/src/components/Map/MapViewGL.tsx | 3 +- .../components/Roadtrip/RoadtripSidebar.tsx | 42 +++++--- .../Roadtrip/RoadtripStopPopup.test.tsx | 31 +++++- .../components/Roadtrip/RoadtripStopPopup.tsx | 47 +++++---- .../src/components/Roadtrip/StayPortals.tsx | 53 ++++++++++ .../src/components/Roadtrip/VisitControls.tsx | 2 +- .../components/Roadtrip/stayPortalLinks.ts | 33 ++++++ client/src/components/Roadtrip/stopArrival.ts | 14 +++ .../src/components/Roadtrip/stopKinds.test.ts | 13 +-- client/src/components/Roadtrip/stopKinds.ts | 17 +--- .../Roadtrip/useRoadtripRoutes.test.ts | 6 ++ .../components/Roadtrip/useRoadtripRoutes.ts | 23 +++-- client/src/pages/TripPlannerPage.tsx | 4 +- .../src/pages/tripPlanner/useTripPlanner.ts | 87 ++++++++++++---- server/src/nest/places/places.mcp.ts | 8 +- .../nest/roadtrip/roadtrip-plan.service.ts | 11 +- shared/src/i18n/ar/roadtrip.ts | 2 + shared/src/i18n/br/roadtrip.ts | 15 +-- shared/src/i18n/ca/roadtrip.ts | 2 + shared/src/i18n/cs/roadtrip.ts | 2 + shared/src/i18n/de/roadtrip.ts | 2 + shared/src/i18n/en/roadtrip.ts | 2 + shared/src/i18n/es/roadtrip.ts | 3 + shared/src/i18n/fr/roadtrip.ts | 3 + shared/src/i18n/gr/roadtrip.ts | 3 + shared/src/i18n/hu/roadtrip.ts | 3 + shared/src/i18n/id/roadtrip.ts | 2 + shared/src/i18n/it/roadtrip.ts | 2 + shared/src/i18n/ja/roadtrip.ts | 3 + shared/src/i18n/ko/roadtrip.ts | 2 + shared/src/i18n/nl/roadtrip.ts | 2 + shared/src/i18n/pl/roadtrip.ts | 2 + shared/src/i18n/ru/roadtrip.ts | 2 + shared/src/i18n/sv/roadtrip.ts | 2 + shared/src/i18n/tr/roadtrip.ts | 3 + shared/src/i18n/uk/roadtrip.ts | 2 + shared/src/i18n/vi/roadtrip.ts | 2 + shared/src/i18n/zh-TW/roadtrip.ts | 2 + shared/src/i18n/zh/roadtrip.ts | 2 + shared/src/place/place.schema.ts | 37 ++++--- shared/src/roadtrip/assemble.ts | 96 +++++++++++------- shared/src/roadtrip/continueSchedule.spec.ts | 31 ++++++ shared/src/roadtrip/continueSchedule.ts | 36 +++++++ shared/src/roadtrip/dayWindow.spec.ts | 25 +++++ shared/src/roadtrip/dayWindow.ts | 21 ++++ shared/src/roadtrip/nightSpill.ts | 8 +- shared/src/roadtrip/planning-types.ts | 1 + shared/src/roadtrip/roadtripModel.spec.ts | 31 ++++++ shared/src/roadtrip/roadtripModel.ts | 40 +++++++- .../src/roadtrip/splitScheduledDays.spec.ts | 57 +++++++++++ shared/src/roadtrip/splitScheduledDays.ts | 75 ++++++++++++++ wiki/Trip-Planner-Overview.md | 16 +++ 58 files changed, 777 insertions(+), 185 deletions(-) create mode 100644 client/public/images/portals/check24.png create mode 100644 client/public/images/portals/pincamp.png create mode 100644 client/public/images/portals/pitchup.ico create mode 100644 client/public/images/portals/trivago.png create mode 100644 client/src/components/Roadtrip/StayPortals.tsx create mode 100644 client/src/components/Roadtrip/stayPortalLinks.ts create mode 100644 client/src/components/Roadtrip/stopArrival.ts create mode 100644 shared/src/roadtrip/continueSchedule.spec.ts create mode 100644 shared/src/roadtrip/continueSchedule.ts create mode 100644 shared/src/roadtrip/splitScheduledDays.spec.ts create mode 100644 shared/src/roadtrip/splitScheduledDays.ts diff --git a/client/public/images/portals/check24.png b/client/public/images/portals/check24.png new file mode 100644 index 0000000000000000000000000000000000000000..ef931c5da1389c6b79bc3666167b8b194212f3c5 GIT binary patch literal 3214 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 literal 0 HcmV?d00001 diff --git a/client/public/images/portals/trivago.png b/client/public/images/portals/trivago.png new file mode 100644 index 0000000000000000000000000000000000000000..1eb2abb5074ebde6c1d59d1a74d1d59664671459 GIT binary patch literal 4409 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 +
diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index f5548d965..572c4012d 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -21,27 +21,6 @@ export function MapViewAuto(props: any) { const { t } = useTranslation() const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely) const mapProps = { ...props, hazards: hazards.feed?.hazards } - const status = hazards.enabled ?
- {hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <> - - - {t('roadtrip.hazards.current')} - - - - {new Date(hazards.feed!.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - - {hazards.feed!.sources.map(source => - - {source.source} - {source.status !== 'ok' && } - - )} - - } -
: null const provider = useSettingsStore(s => s.settings.map_provider) const token = useSettingsStore(s => s.settings.mapbox_access_token) // Fall back to Leaflet when Mapbox is selected but no token is set, @@ -61,12 +40,12 @@ export function MapViewAuto(props: any) { // to Leaflet keeps a usable map instead of an error card. // resetKeys: with two engine chunks, a failure under one provider must not // keep showing Leaflet after the user switches to the other. - <>}> + }> }> - {status} + ) } - return <>{status} + return } diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index e4dcf25bf..1295c4e22 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -2299,7 +2299,8 @@ export function MapViewGL({ does, which is why the lift is only there. */}
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index b8bdf0ef6..aee4d1067 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -837,7 +837,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content-secondary" style={{ fontSize: FS.name }} > - {stop.name}{stop.stopType === 'charging' && } + {stop.name}{stop.stopType === 'charging' && } @@ -959,6 +959,17 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea * the state the whole view gets opened for. It is also the only filled accent left in the * rail: selection moved to the row's own background, so nothing else competes with it. */ +function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: ScheduleEntry }): React.ReactElement | null { + const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + if (stop.checkoutAt === undefined || !entry?.departure) return null + return + + {formatClockTime(entry.departure, is12h)} + + +} + function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement { const { t } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' @@ -1094,7 +1105,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }} > - {stop.name}{stop.stopType === 'charging' && } + {stop.name}{stop.stopType === 'charging' && } {/* Two halves under one border: the word says what the number means, so the number needs no unit of explanation beside it. */} @@ -1257,7 +1268,7 @@ function SpillBlock({ spill, children }: { The stop it leaves from is not named — it is drawn on yesterday's card directly above, and a second row for it would read as a stop made twice. */} - {departure ? ( + {departure && spill.fromStop?.checkoutAt === undefined ? ( !!s.roadtrip_day_colors) const tint = dayColorsOn ? dayColor(day.dayNumber).line : null const last = day.stops.length - 1 - /** - * The findings a stop wears, minus the one the refuel band already says better. - * - * The range warning marks the stop somebody FINDS OUT at and counts the whole tank; - * the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both - * at once says the same thing twice, in the wrong order — the answer above the problem - * — so the warning stands down wherever the band is showing. - */ - const showingBand = !!refuel && !loading const findingsFor = (i: number) => - day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range')) + day.driveWarnings.filter(w => w.index === i && w.code !== 'range') // The running number a stop wears, with the service stops passed over — so a day with a // charger halfway through still counts one, two, three the way its map pins do. let counted = 0 @@ -1353,7 +1355,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on * `stop.ownerIndex` instead, because a stop driven onto this date through the night is * still stored on the day it set off from (`nightSpill.ts`). */ - const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => { + const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => { if (stop.automaticNight) return (
  • @@ -1482,6 +1484,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on * Built rather than mapped straight through, because a spill is drawn inside a block of * its own and a block cannot be opened halfway down a `map`. */ + const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => ( + + {refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => ( +
  • + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} /> +
  • + )) : null} + {renderStopContent(stop, i)} + + ) const runs: { spill: SpillMark | null; from: number; to: number }[] = [] { let at = 0 diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx index 07b35b3ae..5f61bdaae 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx @@ -90,13 +90,13 @@ describe('RoadtripStopPopup', () => { await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 })) }) - it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => { + it('FE-ROADTRIP-STOPPOPUP-007: clicking the selected kind keeps a service stop', async () => { const onSave = vi.fn() wrap() fireEvent.click(screen.getByRole('button', { name: /Fuel/ })) fireEvent.click(screen.getByRole('button', { name: 'Add' })) - await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 })) + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 })) }) /** @@ -275,3 +275,30 @@ describe('RoadtripStopPopup', () => { expect(screen.getByText('Kind of stop')).toBeInTheDocument() }) }) + + +describe('editing a roadtrip stop', () => { + it('loads the saved duration and saves without adding another stop', async () => { + const onSave = vi.fn() + wrap() + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 })) + }) + it('loads and preserves an existing overnight booking', async () => { + const onSaveNight = vi.fn() + wrap() + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:00', checkOut: '10:00' })) + }) +}) + + +it('prefills check-in from arrival while preserving a saved manual time', () => { + const onSaveNight = vi.fn() + wrap() + expect(screen.getByRole('textbox', { name: 'Check-in' })).toHaveValue('15:45') + fireEvent.change(screen.getByRole('textbox', { name: 'Check-in' }), { target: { value: '16:30' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:30', checkOut: '' }) +}) diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index fb7ab506c..c8b29cbb1 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -2,10 +2,12 @@ import React, { useState } from 'react' import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' +import CustomTimePicker from '../shared/CustomTimePicker' +import StayPortals from './StayPortals' import { useTranslation } from '../../i18n/TranslationContext' import { safeExternalHref } from '../../utils/safeUrl' import { formatDate } from '../../utils/formatters' -import { formatDurationShort, SERVICE_COLORS } from './roadtripModel' +import { formatDurationShort } from './roadtripModel' import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds' import type { CorridorPoi } from './useCorridorPois' import type { RoadtripStopType } from '@trek/shared' @@ -28,6 +30,8 @@ import type { RoadtripStopType } from '@trek/shared' const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] export interface RoadtripStopDraft { + editing?: { placeId: number; dwellMinutes: number; stopType: RoadtripStopType | null; accommodationId?: number; checkIn?: string; checkOut?: string } + arrivalTime?: string | null poi: CorridorPoi dayId: number /** Where in the day's chain it goes, worked out from how far along the drive it sits. */ @@ -81,15 +85,15 @@ export default function RoadtripStopPopup({ // scheme for a bare host and drops anything that is not http(s). const websiteHref = safeExternalHref(draft?.poi.website) const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category) - const [stopType, setStopType] = useState(suggested?.key ?? null) - const [dwell, setDwell] = useState(suggested?.defaultMinutes ?? 30) + const [stopType, setStopType] = useState(draft?.editing ? draft.editing.stopType : suggested?.key ?? null) + const [dwell, setDwell] = useState(draft?.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30) // A hotel is a night by default and a campsite a pause, which is what each already // means everywhere else — but both offer the other, because a campsite is somewhere // people sleep and a hotel is somewhere people stop for lunch. - const [night, setNight] = useState(draft?.poi.category === 'hotel') + const [night, setNight] = useState(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel') const [endDayId, setEndDayId] = useState(draft?.overnight?.defaultEndDayId ?? null) - const [checkIn, setCheckIn] = useState('') - const [checkOut, setCheckOut] = useState('') + const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '') + const [checkOut, setCheckOut] = useState(draft?.editing?.checkOut ?? '') const [saving, setSaving] = useState(false) if (!draft) return null @@ -116,8 +120,10 @@ export default function RoadtripStopPopup({ } return ( - +
    +
    +
    {t('day.checkIn')} - setCheckIn(e.target.value)} - className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none" - /> +
    - {/* What OSM knows about the house, which is the whole of what we offer: no - price, no availability, no link into a booking portal. TREK links to maps - and to the place's own site, never to a vendor. */} {websiteHref || draft.poi.phone ? (
    {websiteHref ? ( @@ -258,7 +251,7 @@ export default function RoadtripStopPopup({ type="button" aria-pressed={on} onClick={() => { - setStopType(on ? null : key) + setStopType(key) // Picking a kind is also picking how long it takes, until the user // says otherwise — a charge is not a fuel stop. if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes) @@ -303,6 +296,12 @@ export default function RoadtripStopPopup({ )} +
    + {overnight ? day.id === draft.dayId)?.date} + departure={overnight.days.find(day => day.id === (endDayId ?? overnight.defaultEndDayId))?.date} /> : null} +
    +
    diff --git a/client/src/components/Roadtrip/StayPortals.tsx b/client/src/components/Roadtrip/StayPortals.tsx new file mode 100644 index 000000000..0d1547ca6 --- /dev/null +++ b/client/src/components/Roadtrip/StayPortals.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react' +import { Check, Copy, ExternalLink } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { trivagoSearchHref, campingSearchHref } from './stayPortalLinks' +import { copyText } from '../../utils/clipboard' + +const HOTEL_PORTALS = [ + { name: 'trivago', icon: '/images/portals/trivago.png', href: 'https://www.trivago.com/' }, + { name: 'CHECK24', icon: '/images/portals/check24.png', href: 'https://hotel.check24.de/' }, +] +const CAMPING_PORTALS = [ + { name: 'PiNCAMP', icon: '/images/portals/pincamp.png', href: 'https://www.pincamp.com/' }, + { name: 'Pitchup', icon: '/images/portals/pitchup.ico', href: 'https://www.pitchup.com/' }, +] + +export default function StayPortals({ name, camping, lat, lng, arrival, departure }: { name: string; camping: boolean; lat: number; lng: number; arrival?: string | null; departure?: string | null }): React.ReactElement { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + const [copyFailed, setCopyFailed] = useState(false) + + const copy = async (): Promise => { + const didCopy = await copyText(name) + setCopied(didCopy) + setCopyFailed(!didCopy) + } + + return ( + + ) +} diff --git a/client/src/components/Roadtrip/VisitControls.tsx b/client/src/components/Roadtrip/VisitControls.tsx index de7177383..2f16cc924 100644 --- a/client/src/components/Roadtrip/VisitControls.tsx +++ b/client/src/components/Roadtrip/VisitControls.tsx @@ -17,7 +17,7 @@ export default function VisitControls({ endDay, stay }: { endDay?: EndDayControl className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default"> {t('roadtrip.stop.stayShort')} {' '} - {formatDurationShort((stay.minutes ?? 0) * 60)}{stay.onEdit && } + {stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && } }
    diff --git a/client/src/components/Roadtrip/stayPortalLinks.ts b/client/src/components/Roadtrip/stayPortalLinks.ts new file mode 100644 index 000000000..64b920853 --- /dev/null +++ b/client/src/components/Roadtrip/stayPortalLinks.ts @@ -0,0 +1,33 @@ +export function trivagoSearchHref(name: string, arrival?: string | null, departure?: string | null): string { + const datePattern = /^\d{4}-\d{2}-\d{2}$/ + const dates = arrival && departure && datePattern.test(arrival) && datePattern.test(departure) && departure > arrival + ? 'dr-' + arrival.replace(/-/g, '') + '-' + departure.replace(/-/g, '') + ';' + : '' + return 'https://www.trivago.com/de/lm/hotels-weltweit?search=' + dates + 'qs-' + encodeURIComponent(name).replace(/;/g, '%3B') +} + + +export function campingSearchHref(portal: 'PiNCAMP' | 'Pitchup', name: string, lat: number, lng: number, arrival?: string | null, departure?: string | null): string { + const query = new URLSearchParams() + const dated = arrival && departure && /^\d{4}-\d{2}-\d{2}$/.test(arrival) && /^\d{4}-\d{2}-\d{2}$/.test(departure) && departure > arrival + if (portal === 'Pitchup') { + query.set('lat', String(lat)) + query.set('lng', String(lng)) + query.set('placename', name) + if (dated) { + query.set('arrive', arrival) + query.set('depart', departure) + } + return 'https://www.pitchup.com/campsites/?' + query + } + const latitudeSpan = 20 / 111.32 + const longitudeSpan = Math.min(180, latitudeSpan / Math.max(0.001, Math.cos(lat * Math.PI / 180))) + query.set('viewport', [Math.max(-90, lat - latitudeSpan), Math.max(-180, lng - longitudeSpan), Math.min(90, lat + latitudeSpan), Math.min(180, lng + longitudeSpan)].map(n => n.toFixed(6)).join(',')) + query.set('title', name) + if (dated) { + query.set('date_from', arrival) + query.set('date_to', departure) + query.set('flex', 'false') + } + return 'https://www.pincamp.com/search?' + query +} diff --git a/client/src/components/Roadtrip/stopArrival.ts b/client/src/components/Roadtrip/stopArrival.ts new file mode 100644 index 000000000..a2755ccb9 --- /dev/null +++ b/client/src/components/Roadtrip/stopArrival.ts @@ -0,0 +1,14 @@ +import { formatClock, parseClock } from './roadtripModel' +import type { RoadtripDay } from '@trek/shared/roadtrip' + +export function stopArrival(day: RoadtripDay, index: number, alongKm: number): string | null { + if (index === 0) return day.schedule.entries[0]?.arrival ?? null + const departure = parseClock(day.schedule.entries[index - 1]?.departure) + const leg = day.legs[index - 1] + if (departure === null || !leg) return null + const before = day.legs.slice(0, index - 1) + if (before.some(leg => !leg)) return null + const distanceBefore = before.reduce((total, leg) => total + leg!.distance, 0) + const fraction = leg.distance > 0 ? Math.max(0, Math.min(1, (alongKm * 1000 - distanceBefore) / leg.distance)) : 0 + return formatClock(Math.round(departure + leg.duration / 60 * fraction)) +} diff --git a/client/src/components/Roadtrip/stopKinds.test.ts b/client/src/components/Roadtrip/stopKinds.test.ts index 688cc7e4e..f8dd1f596 100644 --- a/client/src/components/Roadtrip/stopKinds.test.ts +++ b/client/src/components/Roadtrip/stopKinds.test.ts @@ -37,13 +37,10 @@ describe('stop kinds', () => { expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights']) }) - it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => { - // The whole reason the two lists are separate. Sleeping somewhere ends the day rather - // than interrupting the drive, so a hotel keeps its number in the chain and never - // becomes a stop_type. - expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull() - expect(STOP_KIND_BY_KEY.hotel).toBeUndefined() - expect(SERVICE_KIND_KEYS).not.toContain('hotel') + it('FE-STOPKIND-008: accommodation is a service stop as well as an overnight category', () => { + expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBe('hotel') + expect(STOP_KIND_BY_KEY.hotel).toBeDefined() + expect(SERVICE_KIND_KEYS).toContain('hotel') }) it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => { @@ -68,7 +65,7 @@ describe('stop kinds', () => { expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest') expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food') for (const kind of STOP_KINDS) { - if (kind.key !== 'rest_area' && kind.key !== 'restaurant') { + if (kind.key !== 'rest_area' && kind.key !== 'restaurant' && kind.key !== 'hotel') { expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`) } } diff --git a/client/src/components/Roadtrip/stopKinds.ts b/client/src/components/Roadtrip/stopKinds.ts index 982ce74da..b1f3259ab 100644 --- a/client/src/components/Roadtrip/stopKinds.ts +++ b/client/src/components/Roadtrip/stopKinds.ts @@ -19,10 +19,6 @@ import type { RoadtripStopType } from '@trek/shared' * and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its * own. The colours and the service list stay there and are read from here. * - * `isService` and `isCorridorCategory` are not the same question, and a coming lodging - * kind is why they are separate: the corridor can find a hotel, but sleeping somewhere - * ends the day rather than interrupting the drive, so it would be a category without - * being a service stop. */ export interface StopKind { key: RoadtripStopType @@ -39,6 +35,7 @@ export interface StopKind { } export const STOP_KINDS: StopKind[] = [ + { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: SERVICE_COLORS.hotel, defaultMinutes: 30, isService: true, isCorridorCategory: true }, { key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true }, { key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true }, { key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true }, @@ -52,14 +49,6 @@ export const STOP_KIND_BY_KEY: Record = Object.fromEntries( STOP_KINDS.map(k => [k.key, k]), ) -/** - * A category the corridor search offers, which is not the same question as a stop kind. - * - * Every stop kind is a category, but not every category is a stop kind: somewhere to - * sleep is something the search can find, and staying there ends the day rather than - * interrupting the drive. A hotel therefore keeps its number in the chain and gets a row - * in `day_accommodations`; it never becomes a `stop_type`. - */ export interface CorridorCategory { key: string labelKey: string @@ -83,9 +72,9 @@ export const HOTEL_COLOR = '#2563EB' * table happens to end. */ export const CORRIDOR_CATEGORIES: CorridorCategory[] = [ - ...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights') + ...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights' && k.key !== 'hotel') .map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })), - { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null }, + { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: 'hotel' }, ...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights') .map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })), ] diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 7f5e11da5..ed676cc62 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -718,6 +718,12 @@ describe('useRoadtripRoutes', () => { // Walked along the roads this day drives, not along the drawn line: 50 km down that // one is still on the join, north of Berlin, a whole leg from where the tank empties. expect(dry.lat).toBeLessThan(BERLIN[0]) + setting({ roadtrip_range_km: 150 }) + await waitFor(() => expect(result.current.days[1].dryPoints![0].legIndex).toBe(-1)) + const inboundDry = result.current.days[1].dryPoints![0] + expect(inboundDry).toMatchObject({ intoLegKm: 50, drivenMeters: 50000, inboundLine: [LUENEBURG, BERLIN] }) + expect(inboundDry.lat).toBeGreaterThan(BERLIN[0]) + expect(inboundDry.lat).toBeLessThan(LUENEBURG[0]) }) }) }) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index d4b6a5a07..e01333d0c 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -3,11 +3,11 @@ import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' -import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel' +import { splitIntoRuns, parseClock, parseAvoid, type DriveLimits } from './roadtripModel' import { spillChains } from './nightSpill' import { useSettingsStore } from '../../store/settingsStore' import { useVehicleRange } from './useVehicleRange' -import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, SnappedWaypoint } from '../../types' +import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types' import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared' import { dayWindow } from './dayWindow' import { useTranslation } from '../../i18n/TranslationContext' @@ -69,10 +69,16 @@ const seamShape = (from: RoadtripStop, viasByDay: Record) /** The drive from one stop to the next, identified the same way `planKey` identifies them. */ const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}` -const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => { +const EMPTY_ACCOMMODATIONS: Accommodation[] = [] + +const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[], days: Day[]): RoadtripStop | null => { const p = a.place if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null + const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId) + const checkoutDay = days.find(day => day.id === stay?.end_day_id)?.day_number + const checkoutTime = parseClock(stay?.check_out) return { + ...(checkoutDay != null && checkoutTime !== null ? { checkoutAt: checkoutDay * 1440 + checkoutTime } : {}), assignmentId: a.id, ownerDayId, ownerIndex, @@ -80,7 +86,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip name: p.name, lat: p.lat, lng: p.lng, - time: a.assignment_time ?? p.place_time ?? null, + time: a.assignment_time ?? p.place_time ?? 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, @@ -115,6 +121,7 @@ export function useRoadtripRoutes( */ viasByDay: Record = {}, boundaries: RoadtripDayBoundary[] = [], + accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS, ): RoadtripRoutes { const { t } = useTranslation() const routeProfile = fallbackProfile || 'driving' @@ -197,7 +204,7 @@ export function useRoadtripRoutes( const stops = (assignments[String(d.id)] ?? []) .slice() .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map(a => asStop(a, d.id, 0)) + .map(a => asStop(a, d.id, 0, accommodations, days)) .filter((s): s is RoadtripStop => s !== null) // The index is filled in after the drop, because it is the index into THIS // list: an assignment whose place has no coordinates never becomes a stop, and @@ -212,7 +219,7 @@ export function useRoadtripRoutes( } }) .filter(d => d.stops.length > 1) - }, [days, assignments]) + }, [days, assignments, accommodations]) const quietDays = useMemo(() => { return [...days] @@ -225,12 +232,12 @@ export function useRoadtripRoutes( stops: (assignments[String(d.id)] ?? []) .slice() .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map(a => asStop(a, d.id, 0)) + .map(a => asStop(a, d.id, 0, accommodations, days)) .filter((s): s is RoadtripStop => s !== null) .map((s, i) => ({ ...s, ownerIndex: i })), })) .filter(d => d.stops.length < 2) - }, [days, assignments]) + }, [days, assignments, accommodations]) // Only the geometry decides whether legs have to be re-fetched: renaming a place or // editing its notes must not fire a routing round. diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index ee15503c5..1ee132c84 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -269,7 +269,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { saveRoadtripLimit, roadtripSettingsLoading, storedAssignments, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries, routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls, - stayDraft, setStayDraft, setRoadtripStay, roadtripEndDay, roadtripStay, + stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, dropPoiOnRoute, @@ -551,7 +551,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined} onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined} openAlternatives={routeAlternatives.open} - onEditStay={can('place_edit', trip) ? setStayDraft : undefined} + onEditStay={can('place_edit', trip) ? editRoadtripStay : undefined} onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined} onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined} onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 428a24369..baafa87d8 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -37,6 +37,7 @@ import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack' import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives' import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays' +import { stopArrival } from '../../components/Roadtrip/stopArrival' import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor' import { @@ -573,6 +574,7 @@ export function useTripPlanner() { routeProfile, roadtripVias.byDay, dayBoundaries.boundaries, + tripAccommodations, ) // Lives here rather than in the panel because the map draws what it finds. const roadtripCorridor = useRoadtripCorridor(roadtripRoutes) @@ -737,6 +739,7 @@ export function useTripPlanner() { if (!insert) return setStopDraft({ poi: hit, + arrivalTime: displayed ? stopArrival(displayed, roadtripInsertIndexFor(hit), hit.alongKm) : null, ...insert, dayNumber: roadtripDayNumber, // Only for a hit somebody could sleep at, and it is what gives the popup its @@ -895,6 +898,16 @@ export function useTripPlanner() { if (!stopDraft) return const { poi, dayId, position } = stopDraft try { + if (stopDraft.editing) { + await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes }) + if (stopDraft.editing.accommodationId) { + await accommodationsApi.delete(tripId, stopDraft.editing.accommodationId) + await loadAccommodations() + } + updateRouteForDay(dayId) + setStopDraft(null) + return + } const place = await tripActions.addPlace(tripId, { name: poi.name, lat: poi.lat, @@ -927,19 +940,8 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore]) + }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations]) - /** - * Saves a corridor hit as somewhere to sleep rather than as a pause on the drive. - * - * Three writes and deliberately not one transaction: the place, its day, and the row in - * day_accommodations. A hotel keeps its number in the chain — it is where the day ends, - * not something the drive passes through — so it gets no `stop_type` and no dwell. - * - * The check-out day is the traveller's answer, defaulting to the day after. Both times - * are optional and go through as empty when nobody filled them in: a hotel found on a - * map has no idea when its reception opens, and the server stores null. - */ const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: { endDayId: number checkIn: string @@ -948,6 +950,16 @@ export function useTripPlanner() { if (!stopDraft) return const { poi, dayId, position } = stopDraft try { + if (stopDraft.editing) { + const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null } + if (stopDraft.editing.accommodationId) await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking) + else await accommodationsApi.create(tripId, booking) + await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' }) + await loadAccommodations() + updateRouteForDay(dayId) + setStopDraft(null) + return + } const place = await tripActions.addPlace(tripId, { name: poi.name, lat: poi.lat, @@ -956,6 +968,7 @@ export function useTripPlanner() { website: poi.website || undefined, phone: poi.phone || undefined, osm_id: poi.osm_id, + stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel', }) if (place?.id) { const plan = reanchorAfterInsert( @@ -1435,7 +1448,7 @@ export function useTripPlanner() { const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => { const day = roadtripRoutes.days.find(d => d.dayId === dayId) if (!day) return - const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng })) + const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng })) if (line.length < 2) return const vehicle = roadtripSettings.roadtrip_vehicle const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null) @@ -1478,7 +1491,7 @@ export function useTripPlanner() { drivenKm += (leg.distance ?? 0) / 1000 return poi.alongKm <= drivenKm }) - const at = Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1) + const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1) // That index counts along the CARD, and after a night drive a card is not one stored // day: its first stops belong to yesterday. The new stop goes in front of the one it // was measured against, so it is that stop's own day and position that place it — @@ -1534,11 +1547,20 @@ export function useTripPlanner() { if (!stopDraft) return const { poi, dayId, position } = stopDraft setStopDraft(null) + if (stopDraft.editing) { + const place = places.find(place => place.id === stopDraft.editing?.placeId) + if (place) { + setEditingPlace(place) + setEditingAssignmentId(resolvePoolAssignmentId(assignments, place.id)) + setShowPlaceForm(true) + } + return + } // Carries the kind and the dwell the popup had already worked out. Leaving them // behind is what turned a fuel stop into a numbered destination on the way to the // full form, silently and in every total. openAddPlaceFromPoi(poi, dayId, position, stop ?? null) - }, [stopDraft, openAddPlaceFromPoi]) + }, [stopDraft, openAddPlaceFromPoi, places, assignments]) /** * A place on this trip that came from the same OSM object. @@ -1547,7 +1569,7 @@ export function useTripPlanner() { * would be the quickest way to add one petrol station twice. */ const stopDraftDuplicate = useMemo(() => { - if (!stopDraft?.poi.osm_id) return null + if (stopDraft?.editing || !stopDraft?.poi.osm_id) return null return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null }, [stopDraft, places]) @@ -1623,11 +1645,38 @@ export function useTripPlanner() { // place's lone assignment to hydrate & persist its times; with 0 or 2+ // assignments the time is ambiguous and the modal hides the fields (#1247). const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => { + if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') { + const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id) + const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId)) + if (entry) { + const dayId = Number(entry[0]) + const visit = entry[1].find(visit => visit.id === visitId)! + const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId) + const category = place.stop_type ?? (stay ? 'hotel' : '') + const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId)) + const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null + setStopDraft({ + poi: { osm_id: place.osm_id ?? '', name: place.name, lat: place.lat, lng: place.lng, category, poi_type: category, address: place.address ?? null, website: place.website ?? null, phone: place.phone ?? null, opening_hours: null, cuisine: null, source: 'trek', offRouteKm: 0, alongKm: 0 }, + arrivalTime, dayId, dayNumber: days.find(day => day.id === dayId)?.day_number ?? 0, position: visit.order_index ?? 0, + editing: { placeId: place.id, stopType: place.stop_type ?? (stay ? 'hotel' : null), dwellMinutes: place.duration_minutes ?? 30, accommodationId: stay?.id, checkIn: stay?.check_in ?? '', checkOut: stay?.check_out ?? '' }, + ...(isOvernightCategory(category) ? { overnight: { ...overnightOptions(dayId), ...(stay ? { defaultEndDayId: stay.end_day_id } : {}) } } : {}), + }) + return + } + } setEditingPlace(place) setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)) setPlaceFormDayId(null) setShowPlaceForm(true) - }, [assignments]) + }, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days]) + + const editRoadtripStay = useCallback((draft: NonNullable) => { + const stay = tripAccommodations.find(stay => stay.place_id === draft.placeId && stay.check_out) + const place = places.find(place => place.id === draft.placeId) + const visit = stay && assignments[String(stay.start_day_id)]?.find(visit => visit.place_id === draft.placeId) + if (stay && place && visit) openPlaceEditor(place, visit.id) + else setStayDraft(draft) + }, [tripAccommodations, places, assignments, openPlaceEditor]) const handleDeletePlace = useCallback((placeId) => { setDeletePlaceId(placeId) @@ -2065,7 +2114,7 @@ export function useTripPlanner() { ? { active: !!endDayStop.endDay || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === endDayStop.assignmentId), onToggle: () => setRoadtripEndDay(endDayStop) } : undefined const roadtripStay = roadtripActive && selectedPlace - ? { minutes: selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => setStayDraft({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined } + ? { minutes: endDayStop ? endDayStop.dwellMinutes : selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => editRoadtripStay({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined } : undefined // Build placeId → order-number map from the selected day's assignments @@ -2128,7 +2177,7 @@ export function useTripPlanner() { roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries, refuel, askRefuel, acceptRefuel, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, - stayDraft, setStayDraft, setRoadtripStay, roadtripEndDay, roadtripStay, + stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, dropPoiOnRoute, diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 9356a5888..bf3dbc4c8 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -79,7 +79,7 @@ export class PlacesMcp { image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'), price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'), currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), - stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant or sights. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), + stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant, sights or hotel. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'places', mode: 'write' }, @@ -123,7 +123,7 @@ export class PlacesMcp { assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'), price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'), currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), - stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant or sights. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), + stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant, sights or hotel. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'places', mode: 'write' }, @@ -181,7 +181,7 @@ export class PlacesMcp { osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'), - stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'), + stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'), fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -452,7 +452,7 @@ export class PlacesMcp { phone: z.string().max(50).optional(), image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for every listed place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to strip the pictures off a batch at once'), description: z.string().max(2000).optional(), - stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'), + stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'), fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, annotations: TOOL_ANNOTATIONS_WRITE, diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index e16a93834..42647b470 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -12,6 +12,7 @@ import { splitIntoRuns, spillChains, dayWindow, + parseClock, effectiveRangeKm, parseAvoid, type RoadtripStop, @@ -29,6 +30,9 @@ interface StoredDay { default_transport_mode: string | null; } interface VisitRow { + check_in: string | null; + check_out: string | null; + checkout_day: number | null; id: number; day_id: number; place_id: number; @@ -66,8 +70,10 @@ export class RoadtripPlanService { const visits = this.db.all( `SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng, COALESCE(a.assignment_time, p.place_time) AS time, p.duration_minutes, a.end_day, - a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent + a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id + LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) + LEFT JOIN days checkout ON checkout.id = stay.end_day_id WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`, tripId, ); @@ -107,7 +113,8 @@ export class RoadtripPlanService { name: v.name, lat: v.lat!, lng: v.lng!, - time: v.time, + time: v.time ?? v.check_in ?? null, + ...(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, legMode: v.leg_transport_mode, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index bee84d9c4..8ac3a19e0 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'العثور على إقامة', + 'roadtrip.stay.portalHint': 'تحقق من الوجهة والتواريخ والضيوف في الموقع. ابحث يدوياً في CHECK24.', 'roadtrip.charging.available': 'متاح', 'roadtrip.charging.unknown': 'لا تتوفر بيانات موثوقة', 'roadtrip.charging.stale': 'الحالة قديمة', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 48f361950..ae6888f9d 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -1,12 +1,15 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { - 'roadtrip.charging.available': "disponíveis", - 'roadtrip.charging.unknown': "Sem dados confiáveis", - 'roadtrip.charging.stale': "Status desatualizado", - 'roadtrip.charging.prices': "Tarifas publicadas", - 'roadtrip.charging.note': "Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.", - 'roadtrip.charging.coverage': "Dados abertos; cobertura varia por região e operador.", + 'roadtrip.stay.portals': 'Encontrar hospedagem', + 'roadtrip.stay.portalHint': 'Confira destino, datas e hóspedes no portal. No CHECK24, pesquise manualmente.', + 'roadtrip.charging.available': 'disponíveis', + 'roadtrip.charging.unknown': 'Sem dados confiáveis', + 'roadtrip.charging.stale': 'Status desatualizado', + 'roadtrip.charging.prices': 'Tarifas publicadas', + 'roadtrip.charging.note': + 'Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.', + 'roadtrip.charging.coverage': 'Dados abertos; cobertura varia por região e operador.', 'roadtrip.import.title': 'Importar rota do Google Maps', 'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.', 'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 0f4bed6dd..50cec5f3a 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Troba allotjament', + 'roadtrip.stay.portalHint': 'Comprova la destinació, les dates i els hostes al portal. A CHECK24, cerca manualment.', 'roadtrip.charging.available': 'disponibles', 'roadtrip.charging.unknown': 'No hi ha dades fiables', 'roadtrip.charging.stale': 'Estat desactualitzat', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index b4ed5e584..6d9146fee 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Najít ubytování', + 'roadtrip.stay.portalHint': 'Na portálu ověřte cíl, termín a hosty. Na CHECK24 hledejte ručně.', 'roadtrip.charging.available': 'volné', 'roadtrip.charging.unknown': 'Spolehlivá data nejsou dostupná', 'roadtrip.charging.stale': 'Zastaralý stav', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index f0988408f..eaf440e12 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Unterkunft finden', + 'roadtrip.stay.portalHint': 'Ziel, Daten und Gäste im Portal prüfen. Bei CHECK24 manuell suchen.', 'roadtrip.charging.available': 'frei', 'roadtrip.charging.unknown': 'Keine verlässlichen Daten verfügbar', 'roadtrip.charging.stale': 'Status veraltet', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 99be52bf4..0a369764f 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Find accommodation', + 'roadtrip.stay.portalHint': 'Check destination, dates and guests on the portal. CHECK24 requires a manual search.', 'roadtrip.charging.available': 'available', 'roadtrip.charging.unknown': 'No reliable data available', 'roadtrip.charging.stale': 'Status outdated', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index e904449c6..5678884b2 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -1,6 +1,9 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Buscar alojamiento', + 'roadtrip.stay.portalHint': + 'Comprueba el destino, las fechas y los huéspedes en el portal. En CHECK24, busca manualmente.', 'roadtrip.charging.available': 'disponibles', 'roadtrip.charging.unknown': 'No hay datos fiables', 'roadtrip.charging.stale': 'Estado desactualizado', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 12244e110..a60999a0f 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -1,6 +1,9 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Trouver un hébergement', + 'roadtrip.stay.portalHint': + 'Vérifiez la destination, les dates et les voyageurs sur le portail. Recherche manuelle sur CHECK24.', 'roadtrip.charging.available': 'disponibles', 'roadtrip.charging.unknown': 'Aucune donnée fiable disponible', 'roadtrip.charging.stale': 'État périmé', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index ba61a048b..a92bf714f 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -1,6 +1,9 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Βρείτε κατάλυμα', + 'roadtrip.stay.portalHint': + 'Ελέγξτε προορισμό, ημερομηνίες και επισκέπτες στην πύλη. Στο CHECK24 αναζητήστε χειροκίνητα.', 'roadtrip.charging.available': 'διαθέσιμα', 'roadtrip.charging.unknown': 'Δεν υπάρχουν αξιόπιστα δεδομένα', 'roadtrip.charging.stale': 'Παρωχημένη κατάσταση', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 91599bead..25f768069 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -1,6 +1,9 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Szállás keresése', + 'roadtrip.stay.portalHint': + 'Ellenőrizd az úti célt, dátumokat és vendégeket a portálon. A CHECK24 oldalon keress kézzel.', 'roadtrip.charging.available': 'szabad', 'roadtrip.charging.unknown': 'Nincs megbízható adat', 'roadtrip.charging.stale': 'Elavult állapot', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index b1927eb72..e44a1f254 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Cari penginapan', + 'roadtrip.stay.portalHint': 'Periksa tujuan, tanggal, dan tamu di portal. Cari secara manual di CHECK24.', 'roadtrip.charging.available': 'tersedia', 'roadtrip.charging.unknown': 'Tidak ada data terpercaya', 'roadtrip.charging.stale': 'Status kedaluwarsa', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 4819d9909..1b8f41cdc 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Trova un alloggio', + 'roadtrip.stay.portalHint': 'Verifica destinazione, date e ospiti sul portale. Su CHECK24 cerca manualmente.', 'roadtrip.charging.available': 'disponibili', 'roadtrip.charging.unknown': 'Nessun dato affidabile', 'roadtrip.charging.stale': 'Stato non aggiornato', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 2842e56e7..bf47acb55 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -1,6 +1,9 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '宿泊施設を探す', + 'roadtrip.stay.portalHint': + '予約サイトで目的地、日付、宿泊人数を確認してください。CHECK24では手動で検索してください。', 'roadtrip.charging.available': '空き', 'roadtrip.charging.unknown': '信頼できるデータがありません', 'roadtrip.charging.stale': '古い状態情報', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 148f45ebc..ff6409177 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '숙소 찾기', + 'roadtrip.stay.portalHint': '포털에서 목적지, 날짜, 인원을 확인하세요. CHECK24에서는 직접 검색하세요.', 'roadtrip.charging.available': '이용 가능', 'roadtrip.charging.unknown': '신뢰할 수 있는 데이터 없음', 'roadtrip.charging.stale': '오래된 상태', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 5ee32c8e0..fd3cb7e97 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Accommodatie zoeken', + 'roadtrip.stay.portalHint': 'Controleer bestemming, datums en gasten op het portaal. Zoek handmatig op CHECK24.', 'roadtrip.charging.available': 'beschikbaar', 'roadtrip.charging.unknown': 'Geen betrouwbare gegevens', 'roadtrip.charging.stale': 'Status verouderd', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index c8a831b12..e543abdbf 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Znajdź nocleg', + 'roadtrip.stay.portalHint': 'Sprawdź cel, daty i gości na portalu. W CHECK24 wyszukaj ręcznie.', 'roadtrip.charging.available': 'wolne', 'roadtrip.charging.unknown': 'Brak wiarygodnych danych', 'roadtrip.charging.stale': 'Nieaktualny status', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index a08eb021c..a559c5703 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Найти жильё', + 'roadtrip.stay.portalHint': 'Проверьте место, даты и гостей на портале. В CHECK24 ищите вручную.', 'roadtrip.charging.available': 'свободно', 'roadtrip.charging.unknown': 'Нет надёжных данных', 'roadtrip.charging.stale': 'Статус устарел', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 0c4e167ad..b17fcf481 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Hitta boende', + 'roadtrip.stay.portalHint': 'Kontrollera resmål, datum och gäster på portalen. Sök manuellt på CHECK24.', 'roadtrip.charging.available': 'lediga', 'roadtrip.charging.unknown': 'Inga tillförlitliga uppgifter', 'roadtrip.charging.stale': 'Inaktuell status', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index b3006fac8..ee5fc1608 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -1,6 +1,9 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Konaklama bul', + 'roadtrip.stay.portalHint': + 'Portalda varış yerini, tarihleri ve konukları kontrol edin. CHECK24 üzerinde elle arayın.', 'roadtrip.charging.available': 'boş', 'roadtrip.charging.unknown': 'Güvenilir veri yok', 'roadtrip.charging.stale': 'Durum güncel değil', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index bf378a718..2e9dcf94b 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Знайти житло', + 'roadtrip.stay.portalHint': 'Перевірте місце, дати та гостей на порталі. У CHECK24 шукайте вручну.', 'roadtrip.charging.available': 'вільні', 'roadtrip.charging.unknown': 'Немає надійних даних', 'roadtrip.charging.stale': 'Статус застарів', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index b4ba82180..f0eec0a96 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Tìm chỗ ở', + 'roadtrip.stay.portalHint': 'Kiểm tra điểm đến, ngày và khách trên cổng đặt chỗ. Tìm thủ công trên CHECK24.', 'roadtrip.charging.available': 'còn trống', 'roadtrip.charging.unknown': 'Không có dữ liệu đáng tin cậy', 'roadtrip.charging.stale': 'Trạng thái đã cũ', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 6b776c5b2..90a4c6aa1 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '尋找住宿', + 'roadtrip.stay.portalHint': '請在平台核對目的地、日期和入住人數。CHECK24需手動搜尋。', 'roadtrip.charging.available': '可用', 'roadtrip.charging.unknown': '暫無可靠資料', 'roadtrip.charging.stale': '狀態已過時', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index a5e02d93f..1cc59fb0d 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -1,6 +1,8 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '查找住宿', + 'roadtrip.stay.portalHint': '请在平台核对目的地、日期和入住人数。CHECK24需手动搜索。', 'roadtrip.charging.available': '可用', 'roadtrip.charging.unknown': '暂无可靠数据', 'roadtrip.charging.stale': '状态已过时', diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index f01e90484..08f5bd5b2 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -29,23 +29,26 @@ export const hexColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6} * against — the escaping in the marker builders stops being the only thing * standing between the database and the DOM. */ -export const placeImageUrlSchema = z.string().max(2048).refine( - v => - v.startsWith('/uploads/') - || v.startsWith('/api/maps/place-photo/') - || /^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v) - || /^https:\/\//i.test(v), - { message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' }, -); +export const placeImageUrlSchema = z + .string() + .max(2048) + .refine( + (v) => + v.startsWith('/uploads/') || + v.startsWith('/api/maps/place-photo/') || + /^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v) || + /^https:\/\//i.test(v), + { message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' }, + ); /** * A place's homepage. It reaches window.open() on the client, where a * javascript: value would run in this origin rather than opening a page. */ -export const placeWebsiteSchema = z.string().max(500).refine( - v => /^https?:\/\//i.test(v), - { message: 'must be an http or https URL' }, -); +export const placeWebsiteSchema = z + .string() + .max(500) + .refine((v) => /^https?:\/\//i.test(v), { message: 'must be an http or https URL' }); /** * Embedded category as returned on a place — a trimmed projection of the @@ -91,7 +94,15 @@ export type PlaceRatingVote = z.infer; * to the column, where `isServiceStopType` then read it as false and the stop quietly * counted as a destination. */ -export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']); +export const roadtripStopTypeSchema = z.enum([ + 'fuel', + 'charging', + 'rest_area', + 'campsite', + 'restaurant', + 'sights', + 'hotel', +]); export type RoadtripStopType = z.infer; /** diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index fa4b9bae9..24615ba05 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -17,6 +17,7 @@ import type { } from './planning-types'; import { computeSchedule, + parseClock, deriveDriveWarnings, refuelsRange, isServiceStopType, @@ -24,6 +25,7 @@ import { type DriveLimits, type VehicleKind, } from './roadtripModel'; +import { splitScheduledDays } from './splitScheduledDays'; const stopKey = (s: RoadtripStop): string => `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`; @@ -85,11 +87,15 @@ export function assembleRoadtrip({ ...d, spills: [], schedule: computeSchedule( - d.stops.map((s) => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), + d.stops.map((s) => ({ + anchor: s.time, + dwellMinutes: s.dwellMinutes, + departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, + })), d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration), ), })) - : chains; + : splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor); const legFor = automaticSchedule ? timed.legFor : storedLegFor; const lines: [number, number][][] = []; @@ -150,42 +156,62 @@ export function assembleRoadtrip({ legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0) + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0); const schedule = chain.schedule; const legVias = routed.map((l) => l?.vias ?? []); - const stops = chain.stops.map((s) => { + const stops = chain.stops.map((s, index) => { const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)]; const line = spurFor(snap); if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) }); - return { ...s, offRoadMeters: line ? snap!.meters : null }; + const entry = schedule.entries[index]; + const arrival = parseClock(entry?.arrival); + const dwellMinutes = + s.checkoutAt !== undefined && arrival !== null + ? Math.max(0, s.checkoutAt - ((chain.dayNumber + (entry?.dayOffset ?? 0)) * 1440 + arrival)) + : s.checkoutAt !== undefined + ? null + : s.dwellMinutes; + return { ...s, dwellMinutes, offRoadMeters: line ? snap!.meters : null }; }); - const inboundKm = - inbound - .filter((l) => l && (l.mode === undefined || l.mode === 'driving')) - .reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000; - const startKm = carryKm === null ? null : carryKm + inboundKm; - const drive = deriveDriveWarnings( - legs, - - stops.map((s) => refuelsRange(s.stopType, vehicleKind)), - limits, - startKm, - - stops.map((s) => s.fillPercent), - ); - carryKm = drive.carryKm; - - const drivingLine = routed - .filter((l) => l && l.seg.mode !== undefined && l.seg.mode === 'driving') - .flatMap((l) => l?.line ?? []); - const dryPoints = drive.emptyAt - .map((dry) => { - const at = pointAtMeters( - drivingLine.map(([lat, lng]) => ({ lat, lng })), - dry.drivenMeters, - ); - return at ? { ...dry, lat: at.lat, lng: at.lng } : null; - }) - .filter((d): d is DryPoint & { lat: number; lng: number } => d !== null); - + const drive = { warnings: [] as ReturnType['warnings'], day: null as ReturnType['day'] }; + const dryPoints: (DryPoint & { lat: number; lng: number })[] = []; + const drivingLine: [number, number][] = []; + let drivenMeters = 0; + let drivingSeconds = 0; + for (let i = 0; i < stops.length; i++) { + const incoming = inboundAt.get(i); + if (incoming?.seg) { + const incomingDrive = deriveDriveWarnings([incoming.seg], [false, false], limits, carryKm); + carryKm = incomingDrive.carryKm; + drive.warnings.push(...incomingDrive.warnings.map(w => ({ ...w, index: i }))); + for (const dry of incomingDrive.emptyAt) { + const at = pointAtMeters(incoming.line.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters); + if (at) dryPoints.push({ ...dry, legIndex: -i - 1, inboundLine: incoming.line, lat: at.lat, lng: at.lng }); + } + if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0; + } + const leg = routed[i]; + const outgoing = deriveDriveWarnings( + i < stops.length - 1 ? [leg?.seg] : [], + [refuelsRange(stops[i]!.stopType, vehicleKind), false], + limits, + carryKm, + [stops[i]!.fillPercent], + ); + carryKm = outgoing.carryKm; + drive.warnings.push(...outgoing.warnings.map(w => ({ ...w, index: i + 1 }))); + for (const dry of outgoing.emptyAt) { + const at = pointAtMeters((leg?.line ?? []).map(([lat, lng]) => ({ lat, lng })), dry.intoLegKm * 1000); + if (at) dryPoints.push({ ...dry, legIndex: i, drivenMeters: drivenMeters + dry.intoLegKm * 1000, lat: at.lat, lng: at.lng }); + } + if (leg && (!leg.seg.mode || leg.seg.mode === 'driving')) { + drivingLine.push(...leg.line); + drivenMeters += leg.seg.distance ?? 0; + drivingSeconds += leg.seg.duration ?? 0; + } + } + const drivingMinutes = Math.round(drivingSeconds / 60); + if (limits.dayMinutes && drivingMinutes > limits.dayMinutes) { + drive.day = { code: 'dayDriving', minutes: drivingMinutes, limitMinutes: limits.dayMinutes }; + } const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine; out.push({ automaticSchedule, @@ -208,7 +234,7 @@ export function assembleRoadtrip({ dayWarning: drive.day, }); } - const drives = out.filter((d) => d.stops.length > 1 || d.stops.some((s) => s.automaticNight)); + const drives = out.filter((d) => d.stops.length > 1 || !!d.spills?.length || d.stops.some((s) => s.automaticNight)); const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops); const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { const to = originalStops[position + 1]!; @@ -246,7 +272,7 @@ export function assembleRoadtrip({ ), quietDays: out - .filter((d) => d.stops.length < 2 && !d.stops.some((s) => s.automaticNight)) + .filter((d) => d.stops.length < 2 && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) .map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), loading, }; diff --git a/shared/src/roadtrip/continueSchedule.spec.ts b/shared/src/roadtrip/continueSchedule.spec.ts new file mode 100644 index 000000000..8adfc6f52 --- /dev/null +++ b/shared/src/roadtrip/continueSchedule.spec.ts @@ -0,0 +1,31 @@ +import { continueSchedule } from './continueSchedule'; +import type { RoadtripStop } from './planning-types'; +import type { Schedule } from './roadtripModel'; + +import { describe, expect, it } from 'vitest'; + +const stops = [0, 60, 60, 60].map((dwellMinutes) => ({ dwellMinutes }) as RoadtripStop); +const schedule = (): Schedule => ({ + entries: [ + { arrival: '18:15', departure: '18:15', dayOffset: 0, anchored: false }, + ...[1, 2, 3].map(() => ({ arrival: null, departure: null, dayOffset: 0, anchored: false })), + ], + warnings: [], +}); +describe('continuing after a moved day group', () => { + it('carries Munich departure into the original day stops and over midnight', () => { + const original = schedule(); + const completed = continueSchedule(stops, original, [237 * 60, 198 * 60, 206 * 60], 2); + expect(completed.entries.map((entry) => entry.arrival)).toEqual(['18:15', '22:12', '02:30', '06:56']); + expect(completed.entries[2]!.dayOffset).toBe(1); + expect(original.entries[1]!.arrival).toBeNull(); + }); + it('keeps manual times and stops at an unknown route duration', () => { + const original = schedule(); + original.entries[2] = { arrival: '07:00', departure: '08:00', dayOffset: 1, anchored: true }; + const completed = continueSchedule(stops, original, [undefined, 3600, 3600], 2); + expect(completed.entries[1]!.arrival).toBeNull(); + expect(completed.entries[2]).toEqual(original.entries[2]); + expect(completed.entries[3]!.arrival).toBe('09:00'); + }); +}); diff --git a/shared/src/roadtrip/continueSchedule.ts b/shared/src/roadtrip/continueSchedule.ts new file mode 100644 index 000000000..c9cda06ac --- /dev/null +++ b/shared/src/roadtrip/continueSchedule.ts @@ -0,0 +1,36 @@ +import type { RoadtripStop } from './planning-types'; +import { formatClock, parseClock, type Schedule } from './roadtripModel'; + +export function continueSchedule( + stops: RoadtripStop[], + schedule: Schedule, + legs: (number | undefined)[], + dayNumber: number, +): Schedule { + const entries = schedule.entries.map((entry) => ({ ...entry })); + const warnings = [...schedule.warnings]; + for (let i = 1; i < stops.length; i++) { + const entry = entries[i]!; + if (entry.arrival !== null) continue; + const previous = entries[i - 1]!; + const departure = parseClock(previous.departure); + const duration = legs[i - 1]; + if (departure === null || duration === undefined) continue; + const previousArrival = parseClock(previous.arrival); + const departureAt = + stops[i - 1]!.checkoutAt !== undefined + ? stops[i - 1]!.checkoutAt! - dayNumber * 1440 + : previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0); + const arrivalAt = departureAt + Math.round(duration / 60); + const stop = stops[i]!; + const leaveAt = + stop.checkoutAt === undefined + ? arrivalAt + (stop.dwellMinutes ?? 0) + : Math.max(arrivalAt, stop.checkoutAt - dayNumber * 1440); + entry.arrival = formatClock(arrivalAt); + entry.departure = formatClock(leaveAt); + entry.dayOffset = Math.floor(arrivalAt / 1440); + if (entry.dayOffset > previous.dayOffset) warnings.push({ index: i, code: 'overnight' }); + } + return { entries, warnings }; +} diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts index 8199bd0f2..1cc422e76 100644 --- a/shared/src/roadtrip/dayWindow.spec.ts +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -365,3 +365,28 @@ describe('daily travel window', () => { expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('10:00'); }); }); + +describe('booked checkout', () => { + it('waits at the hotel until noon on the checkout day', () => { + const hotel = stop(2, { checkoutAt: 2 * 1440 + 720, dwellMinutes: 30 }); + const stops = [stop(1), hotel, stop(3)]; + const plan = calculate(stops, [60, 60]); + expect(plan.issue).toBeNull(); + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00'); + expect(plan.chains[0]!.schedule.entries[1]!.departure).toBe('12:00'); + expect(plan.chains[1]!.schedule.entries[0]!.arrival).toBe('12:00'); + expect(plan.chains[1]!.schedule.entries[1]!.arrival).toBe('13:00'); + }); + it('honours an early checkout and multiple nights', () => { + const stops = [stop(1, { checkoutAt: 3 * 1440 + 420 }), stop(2)]; + const plan = calculate(stops, [60], [day(2), day(3)]); + expect(plan.issue).toBeNull(); + expect(plan.chains[2]!.schedule.entries[0]!.arrival).toBe('07:00'); + expect(plan.chains[2]!.schedule.entries[1]!.arrival).toBe('08:00'); + }); + it('does not move time backwards when arrival is after checkout', () => { + const stops = [stop(1, { time: '13:00', checkoutAt: 1440 + 720 }), stop(2)]; + const plan = calculate(stops, [60]); + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('14:00'); + }); +}); diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index f9c94bf25..046ad25b3 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -181,6 +181,7 @@ export function planDayWindow( name: labels.end, time: null, dwellMinutes: 0, + checkoutAt: undefined, stopType: null, fillPercent: null, automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) }, @@ -275,6 +276,26 @@ export function planDayWindow( append(stop, clock); previous = stop; + if (stop.checkoutAt !== undefined) { + const checkoutDay = Math.floor(stop.checkoutAt / 1440); + const checkoutTime = stop.checkoutAt % 1440; + const arrivalAt = number * 1440 + clock; + if (stop.checkoutAt > arrivalAt) { + const entries = chainAt(number).schedule.entries; + entries[entries.length - 1]!.departure = formatClock(checkoutTime); + while (number < checkoutDay) { + previous = night(previous); + if (issue) return failed(issue); + } + clock = checkoutTime; + if (previous.automaticNight?.phase === 'start') { + const entries = chainAt(number).schedule.entries; + entries[entries.length - 1]!.arrival = formatClock(clock); + entries[entries.length - 1]!.departure = formatClock(clock); + } + } + continue; + } let dwell = Math.max(0, stop.dwellMinutes ?? 0); while (dwell > 0) { const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock); diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts index 7dc01438d..b1d0175f1 100644 --- a/shared/src/roadtrip/nightSpill.ts +++ b/shared/src/roadtrip/nightSpill.ts @@ -73,7 +73,11 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]!)); const legs = routed.map((l) => l?.seg); const schedule = computeSchedule( - d.stops.map((s) => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), + d.stops.map((s) => ({ + anchor: s.time, + dwellMinutes: s.dwellMinutes, + departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, + })), legs.map((l) => l?.duration), ); @@ -86,7 +90,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL if (previous !== null && clock < previous) day += 1; previous = clock; } - const offset = day; + const offset = d.stops.some((s) => s.checkoutAt !== undefined) ? (entry?.dayOffset ?? day) : day; const marks = schedule.warnings.filter((w) => w.index === i); const reachable = offset > 0 && numbers.has(d.dayNumber + offset); diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index b91818210..75e4e3736 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -18,6 +18,7 @@ export interface RoadtripStop { time: string | null; dwellMinutes: number | null; + checkoutAt?: number; endDay?: boolean; legMode: string | null; diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index b207ca236..f0a1db319 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -791,3 +791,34 @@ describe('deriveDriveWarnings — filling only part way', () => { expect(out.emptyAt).toEqual([]); }); }); + +describe('checkout without daily travel times', () => { + it('keeps the car at the hotel until the next day', () => { + const schedule = computeSchedule( + [ + { anchor: '16:00', dwellMinutes: 30, departureAt: 1440 + 720 }, + { anchor: null, dwellMinutes: 0 }, + ], + [3600], + ); + expect(schedule.entries[0]!.departure).toBe('12:00'); + expect(schedule.entries[1]!.arrival).toBe('13:00'); + expect(schedule.entries[1]!.dayOffset).toBe(1); + }); +}); + +it('uses checkout as departure even without an arrival or daily start', () => { + const schedule = computeSchedule( + [ + { anchor: null, dwellMinutes: 60 }, + { anchor: null, dwellMinutes: 60, departureAt: 1440 + 480 }, + { anchor: null, dwellMinutes: 0 }, + ], + [3600, 7200], + ); + expect(schedule.entries[0]!.arrival).toBeNull(); + expect(schedule.entries[1]!.arrival).toBeNull(); + expect(schedule.entries[1]!.departure).toBe('08:00'); + expect(schedule.entries[2]!.arrival).toBe('10:00'); + expect(schedule.entries[2]!.dayOffset).toBe(1); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index e4cd13a25..4dc3fd878 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -1,6 +1,14 @@ import type { RouteAvoidClass } from './planning-types'; -export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const; +export const SERVICE_STOP_TYPES = [ + 'fuel', + 'charging', + 'rest_area', + 'campsite', + 'restaurant', + 'sights', + 'hotel', +] as const; export const SERVICE_COLORS: Record = { fuel: '#E8590C', @@ -9,6 +17,7 @@ export const SERVICE_COLORS: Record = { campsite: '#16A34A', restaurant: '#EF4444', sights: '#EC4899', + hotel: '#2563EB', }; export function serviceColor(stopType: string | null | undefined): string { @@ -48,6 +57,7 @@ export function formatClock(minutes: number): string { } export interface ScheduleStop { + departureAt?: number; anchor: string | null; dwellMinutes: number | null; @@ -143,7 +153,11 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset); if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }); - if (arrival === null) continue; + if (arrival === null) { + const leg = legSeconds[i]; + if (stop.departureAt !== undefined && leg !== undefined) cursor = stop.departureAt + Math.round(leg / 60); + continue; + } const offset = Math.floor(arrival / DAY_MINUTES); if (offset > dayOffset) dayOffset = offset; @@ -152,11 +166,16 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und anchored[i]! = anchor !== null; const leg = legSeconds[i]; - cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60); + cursor = + leg === undefined + ? null + : (stop.departureAt === undefined ? arrival + (stop.dwellMinutes ?? 0) : Math.max(arrival, stop.departureAt)) + + Math.round(leg / 60); } const firstKnown = arrivals.findIndex((a) => a !== null); for (let i = firstKnown - 1; i >= 0; i--) { + if (stops[i]!.departureAt !== undefined) break; const leg = legSeconds[i]; const next = arrivals[i + 1]!; if (leg === undefined || next === null) break; @@ -171,7 +190,12 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und for (let i = 0; i < stops.length; i++) { const raw = arrivals[i]!; if (raw === null) { - entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 }); + entries.push({ + arrival: null, + departure: stops[i]!.departureAt === undefined ? null : formatClock(stops[i]!.departureAt!), + anchored: false, + dayOffset: 0, + }); continue; } const arrival = raw + shift; @@ -181,7 +205,11 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und lastOffset = offset; entries.push({ arrival: formatClock(arrival), - departure: formatClock(arrival + (stops[i]!.dwellMinutes ?? 0)), + departure: formatClock( + stops[i]!.departureAt === undefined + ? arrival + (stops[i]!.dwellMinutes ?? 0) + : Math.max(arrival, stops[i]!.departureAt! + shift), + ), anchored: anchored[i]!, dayOffset: offset, }); @@ -217,6 +245,8 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number { } export interface DryPoint { + inboundLine?: [number, number][]; + legIndex: number; intoLegKm: number; diff --git a/shared/src/roadtrip/splitScheduledDays.spec.ts b/shared/src/roadtrip/splitScheduledDays.spec.ts new file mode 100644 index 000000000..f317feae7 --- /dev/null +++ b/shared/src/roadtrip/splitScheduledDays.spec.ts @@ -0,0 +1,57 @@ +import type { SpillChain } from './nightSpill'; +import type { RoadtripStop, RoutedLeg } from './planning-types'; +import { splitScheduledDays } from './splitScheduledDays'; + +import { describe, expect, it } from 'vitest'; + +const chain = (): SpillChain => ({ + dayId: 2, + dayNumber: 2, + date: '2026-09-12', + title: null, + stops: [0, 1, 2].map((i) => ({ placeId: i + 1, dwellMinutes: 60, time: null }) as RoadtripStop), + schedule: { + entries: [ + { arrival: '22:12', departure: '23:12', anchored: false, dayOffset: 0 }, + ...[1, 2].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), + ], + warnings: [], + }, + spills: [], +}); +const lookup = (): RoutedLeg => + ({ + seg: { duration: 198 * 60 }, + line: [ + [0, 0], + [1, 1], + ], + vias: [], + }) as RoutedLeg; +describe('scheduled day overflow', () => { + it('creates a display day for the stops after midnight', () => { + const source = chain(); + const days = splitScheduledDays([source], [source], lookup); + expect(days.map((day) => day.dayNumber)).toEqual([2, 3]); + expect(days[1]!.dayId).toBe(-1000000003); + expect(days[1]!.date).toBe('2026-09-13'); + expect(days[1]!.schedule.entries.map((entry) => entry.arrival)).toEqual(['02:30', '06:48']); + expect(days[1]!.schedule.entries.every((entry) => entry.dayOffset === 0)).toBe(true); + expect(days[1]!.spills[0]).toMatchObject({ fromDayNumber: 2, at: 0, count: 2, departure: '23:12' }); + expect(source.stops).toHaveLength(3); + }); + it('uses an existing empty day and keeps an earlier spill group', () => { + const source = chain(); + source.spills = [ + { at: 0, count: 1, fromDayNumber: 1, departure: '18:00', leg: undefined, line: [], fromStop: undefined }, + ]; + const days = splitScheduledDays( + [source], + [source, { dayId: 99, dayNumber: 3, date: '2026-09-13', title: 'Rome', stops: [] }], + lookup, + ); + expect(days[1]!.dayId).toBe(99); + expect(days[0]!.spills[0]!.fromDayNumber).toBe(1); + expect(days[1]!.spills[0]!.fromDayNumber).toBe(2); + }); +}); diff --git a/shared/src/roadtrip/splitScheduledDays.ts b/shared/src/roadtrip/splitScheduledDays.ts new file mode 100644 index 000000000..fa3e1d6c3 --- /dev/null +++ b/shared/src/roadtrip/splitScheduledDays.ts @@ -0,0 +1,75 @@ +import { continueSchedule } from './continueSchedule'; +import type { SpillChain, SpillMark } from './nightSpill'; +import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; + +export function splitScheduledDays( + chains: SpillChain[], + days: PlanDay[], + legFor: (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined, +): SpillChain[] { + const grouped = new Map(); + for (const chain of chains) { + const schedule = continueSchedule( + chain.stops, + chain.schedule, + chain.stops.slice(0, -1).map((stop, i) => legFor(stop, chain.stops[i + 1]!)?.seg.duration), + chain.dayNumber, + ); + chain.stops.forEach((stop, i) => { + const entry = schedule.entries[i]!; + const offset = Math.max(0, entry.dayOffset); + const number = chain.dayNumber + offset; + let target = grouped.get(number); + if (!target) { + const stored = days.find((day) => day.dayNumber === number); + let date = stored?.date ?? null; + if (!date && chain.date) { + const shifted = new Date(chain.date + 'T12:00:00Z'); + shifted.setUTCDate(shifted.getUTCDate() + offset); + date = shifted.toISOString().slice(0, 10); + } + target = { + dayId: stored?.dayId ?? -1000000000 - number, + dayNumber: number, + date, + title: stored?.title ?? null, + stops: [], + schedule: { entries: [], warnings: [] }, + spills: [], + }; + grouped.set(number, target); + } + const index = target.stops.length; + target.stops.push(stop); + target.schedule.entries.push({ ...entry, dayOffset: 0 }); + target.schedule.warnings.push( + ...schedule.warnings + .filter((warning) => warning.index === i && !(offset && warning.code === 'overnight')) + .map((warning) => ({ ...warning, index })), + ); + const inherited = chain.spills.find((spill) => i >= spill.at && i < spill.at + spill.count); + const fromDayNumber = offset ? chain.dayNumber : inherited?.fromDayNumber; + if (fromDayNumber !== undefined) { + const last = target.spills[target.spills.length - 1]; + if (last && last.fromDayNumber === fromDayNumber && last.at + last.count === index) last.count += 1; + else { + const from = i > 0 ? chain.stops[i - 1] : inherited?.fromStop; + const leg = from ? legFor(from, stop) : undefined; + const mark: SpillMark = offset + ? { + at: index, + count: 1, + fromDayNumber, + departure: schedule.entries[i - 1]?.departure ?? inherited?.departure ?? null, + fromStop: from, + leg: leg?.seg, + line: leg?.line ?? [], + } + : { ...inherited!, at: index, count: 1 }; + target.spills.push(mark); + } + } + }); + } + return [...grouped.values()].sort((a, b) => a.dayNumber - b.dayNumber); +} diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 981f2347d..f70b928c6 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -119,3 +119,19 @@ Roadtrip charging stops show compact availability and published energy-price bad > **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop. GDACS warning popups show the current episode score when supplied, falling back to the overall event score. The compact scale indicates GDACS humanitarian impact, not whether roads are passable. + +### Accommodation portals in Roadtrip + +The Add as a stop dialog offers trivago and CHECK24 for hotels, or PiNCAMP and Pitchup for campsites. The portal opens in a new tab without adding a stop or making a booking. trivago receives the search text; PiNCAMP receives a geographic area extending roughly 20 km from the stop; Pitchup receives the stop coordinates. All three receive the trip dates when both days have dates and departure is after arrival. Review the destination, dates and guest count on the portal. CHECK24 requires a manual search. Check-in and check-out use the shared TREK time picker. + +All Looking for categories, including accommodation, are added as Roadtrip service stops. In Roadtrip mode, Edit in a planned place's details opens the stop dialog with its saved duration and overnight dates and times. Saving updates the existing stop; More details still opens the full place editor. The shared stop-type contract also accepts hotel through MCP. + +A booked check-out time controls departure from an overnight stop on its check-out day. STAY is calculated from arrival until check-out, including multiple nights, and updates with the route. Explicit arrival times remain authoritative. Clicking STAY for a booking with check-out opens the overnight editor. Browser planning and MCP use the same scheduling logic. + +The overnight dialog prefills check-in from the calculated arrival when available; existing manual check-in values take priority. Before adding a corridor result, the suggested arrival is estimated along the current routed leg. Without any known arrival, checkout still anchors the onward drive, but STAY remains unknown instead of displaying the old default duration. + +Ordinary places retain the full place editor in Roadtrip mode. The compact stop editor is used for service stops and accommodation. When visits move into another day, their calculated departure continues into that day's following stops, including midnight offsets. + +Stops reached after midnight move to the next calculated Roadtrip day with a FROM DAY label. This also creates a display day when no saved day exists yet and retains arrival times for a day containing only one moved stop. + +Range warnings also locate an empty battery or tank on connections between days. The warning offers reachable stations on that connection and inserts a selected stop before its destination.