From 95c173a8a8241413c2dafbb1dd2e00d302886226 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 4 Sep 2026 22:51:43 +0200 Subject: [PATCH] fix(roadtrip): put a driving finding on the stop it arrives at, not the one it leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a real trip: Hamburg, A1, HEM, Berlin. HEM to Berlin is 244.7 km, and the badge on Berlin said 121 — which is the length of the NEXT leg, Berlin to Sanssouci. The finding was anchored to the stop the leg departs from while its wording ("since the last fill-up") is a running total, so the number never matched what anyone adds up off the drive bands above it. Both findings are true on arrival, so that is where they belong. The same trip now reads 125 at HEM (21.5 + 103.4) and 245 at Berlin, which is exactly what you get counting the bands. A service stop carries them too. A charging halt is a stop like any other as far as the tank is concerned, and HEM was the very stop whose figure went missing. The corridor's empty state gets the mascot the rest of TREK uses: searching, idle, and a confused look when a filter left nothing standing, so the picture says which of the three it is before the sentence is read. --- client/src/components/Map/poiCategories.ts | 8 +- .../Roadtrip/RoadtripCorridorPanel.tsx | 26 ++-- .../components/Roadtrip/RoadtripSidebar.tsx | 12 +- .../components/Roadtrip/RoadtripStopPopup.tsx | 139 +++++++++++++++++- .../components/Roadtrip/roadtripModel.test.ts | 30 +++- .../src/components/Roadtrip/roadtripModel.ts | 14 +- .../src/components/Roadtrip/stopKinds.test.ts | 33 ++++- client/src/components/Roadtrip/stopKinds.ts | 55 ++++++- .../Roadtrip/useRoadtripCorridor.ts | 5 +- client/src/pages/TripPlannerPage.tsx | 3 +- .../src/pages/tripPlanner/useTripPlanner.ts | 91 +++++++++++- 11 files changed, 378 insertions(+), 38 deletions(-) diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts index b72cbee3e..ebf83128b 100644 --- a/client/src/components/Map/poiCategories.ts +++ b/client/src/components/Map/poiCategories.ts @@ -2,7 +2,7 @@ import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fue // The road-trip kinds take their colour from the one place that defines it, so the // search result, the map pin and the road-trip rail cannot drift apart. import { SERVICE_COLORS } from '../Roadtrip/roadtripModel' -import { STOP_KINDS } from '../Roadtrip/stopKinds' +import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds' // The POI categories shown in the map "explore" pill. The `key` is the contract // with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping @@ -19,7 +19,9 @@ export const POI_CATEGORIES: PoiCategory[] = [ { key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' }, { key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' }, { key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' }, - { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' }, + // The colour lives with the road-trip categories, because the corridor search offers + // the same one and two definitions of a blue is how they drift apart. + { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR }, { key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' }, { key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' }, { key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' }, @@ -32,7 +34,7 @@ export const POI_CATEGORIES: PoiCategory[] = [ * lookup below all the same, so a hit drawn on the map gets its own icon and colour * instead of the nameless grey dot an unknown category falls back to. */ -export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = STOP_KINDS.map( +export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map( ({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }), ) diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 01785ae95..3ced31657 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -5,12 +5,13 @@ import { } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' +import EmptyState from '../shared/EmptyState' import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import CustomSelect from '../shared/CustomSelect' import RoadtripCategoryPicker from './RoadtripCategoryPicker' import { serviceColor } from './roadtripModel' -import { STOP_KIND_BY_KEY } from './stopKinds' +import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds' import { FS } from './typeScale' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' import type { CorridorPoi } from './useCorridorPois' @@ -28,7 +29,7 @@ interface RoadtripCorridorPanelProps { } /** Label and icon per category, from the one table every road-trip surface reads. */ -const CATEGORY_META = STOP_KIND_BY_KEY +const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY /** The small capitalised word over a group of controls, matching the rail's own captions. */ const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint' @@ -339,16 +340,23 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
{grouped.length === 0 ? ( -
- -

- {search.loading + // The same empty state the rest of TREK uses, with the mascot acting out the + // scene. `search` while it is running, `idle` before anyone has asked, and a + // confused look when a filter left nothing standing — the picture says which + // of the three it is before the sentence is read. + -

+ : t('roadtrip.poi.empty') + } + /> ) : ( <> {corridor.nameFilter.trim() ? ( diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 4bc6c5a37..8610c8659 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -352,9 +352,12 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { * the trip is actually for. Its own icon on one flat disc: three kinds of pause that all * mean "we are still driving", and the icon is what tells them apart. */ -function ServiceStop({ stop, entry, selected, onSelect, onEditStay }: { +function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditStay }: { stop: RoadtripStop entry: ScheduleEntry | undefined + /** Findings about the drive that ARRIVES here. A charging halt is a stop like any other + * as far as the tank is concerned, so it carries them the same way a numbered one does. */ + driveFindings?: ScheduleWarning[] selected: boolean onSelect?: () => void /** Opens the dialog for how long this pause takes. Absent means the rail is read-only. */ @@ -401,7 +404,11 @@ function ServiceStop({ stop, entry, selected, onSelect, onEditStay }: { > {stop.name} - + + + {spurWorthLabelling(stop.offRoadMeters) ? : null} + {(driveFindings ?? []).map(w => )} + {entry?.arrival ? : null} @@ -789,6 +796,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on w.index === i)} selected={selectedAssignmentId === stop.assignmentId} onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index 92c96ace3..f5742c842 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' -import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Hourglass, AlertTriangle, type LucideIcon } from 'lucide-react' +import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react' import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' import { useTranslation } from '../../i18n/TranslationContext' import { formatDurationShort, SERVICE_COLORS } from './roadtripModel' import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds' @@ -30,6 +31,16 @@ export interface RoadtripStopDraft { /** Where in the day's chain it goes, worked out from how far along the drive it sits. */ position: number dayNumber: number + /** + * Set only for a hit somebody could sleep at, and it is what turns the popup into two + * modes rather than one. Absent, the dialog asks the two questions it always asked. + */ + overnight?: { + /** The trip's days from this one on, in travel order — the check-out options. */ + days: { id: number; number: number; date: string | null }[] + /** The day after this one, or this one when it is the last. */ + defaultEndDayId: number + } } interface RoadtripStopPopupProps { @@ -38,7 +49,11 @@ interface RoadtripStopPopupProps { duplicateName?: string | null onClose: () => void onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise | void - /** Opens the full place form instead, carrying what has been filled in so far. */ + /** + * Books the hit as a night instead of a pause: the place, its day, and a row in + * day_accommodations, in one go. Only ever called when the draft carries `overnight`. + */ + onSaveNight?: (input: { endDayId: number; checkIn: string; checkOut: string }) => Promise | void /** Hands over what has been picked here, so the full form opens on the same answer. */ onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void } @@ -53,22 +68,39 @@ interface RoadtripStopPopupProps { * how long) and says where it will land, with a way out to the full form for the rest. */ export default function RoadtripStopPopup({ - draft, duplicateName, onClose, onSave, onMoreDetails, + draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails, }: RoadtripStopPopupProps): React.ReactElement | null { const { t } = useTranslation() 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) + // 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 [endDayId, setEndDayId] = useState(draft?.overnight?.defaultEndDayId ?? null) + const [checkIn, setCheckIn] = useState('') + const [checkOut, setCheckOut] = useState('') const [saving, setSaving] = useState(false) if (!draft) return null + const overnight = draft.overnight + const asNight = !!overnight && !!onSaveNight && night const kind = STOP_KINDS.find(k => k.key === stopType) const submit = async (): Promise => { setSaving(true) try { - await onSave({ stopType, dwellMinutes: dwell }) + if (asNight) { + await onSaveNight?.({ + endDayId: endDayId ?? overnight!.defaultEndDayId, + checkIn, + checkOut, + }) + } else { + await onSave({ stopType, dwellMinutes: dwell }) + } } finally { setSaving(false) } @@ -103,6 +135,103 @@ export default function RoadtripStopPopup({

) : null} + {/* Only where sleeping is on the table. A petrol station gets no choice, because + there is nothing to choose between. */} + {overnight && onSaveNight ? ( +
+ {[ + { on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass }, + { on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble }, + ].map(({ on, label, Icon }) => ( + + ))} +
+ ) : null} + + {asNight ? ( +
+
+ + {t('roadtrip.stay.checkOutDay')} + +
+ setEndDayId(Number(value))} + options={overnight!.days.map(d => ({ + value: String(d.id), + label: d.date + ? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${d.date}` + : t('roadtrip.stay.dayNumber', { number: d.number }), + }))} + size="sm" + /> +
+
+ {/* Both optional: leaving them empty is normal, and the server stores null. + A hotel found on a map has no idea when its reception opens. */} +
+ + +
+ {/* 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. */} + {draft.poi.website || draft.poi.phone ? ( +
+ {draft.poi.website ? ( + + {t('places.website')} + + ) : null} + {draft.poi.phone ? ( + + {draft.poi.phone} + + ) : null} +
+ ) : null} +
+ ) : ( + <>
{t('roadtrip.stop.kind')} @@ -158,6 +287,8 @@ export default function RoadtripStopPopup({ ))}
+ + )}