fix(roadtrip): put a driving finding on the stop it arrives at, not the one it leaves

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.
This commit is contained in:
Maurice
2026-09-04 22:51:43 +02:00
committed by Maurice
parent 282e3c6b13
commit 95c173a8a8
11 changed files with 378 additions and 38 deletions
+5 -3
View File
@@ -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 }),
)
@@ -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
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
{grouped.length === 0 ? (
<div className="px-4 py-10 text-center">
<MapPin size={18} className="mx-auto mb-2 text-content-faint" aria-hidden />
<p className="text-content-muted" style={{ fontSize: FS.meta }}>
{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.
<EmptyState
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
surface="var(--bg-secondary)"
title={
search.loading
? t('roadtrip.poi.searchingHint')
: filteredToNothing
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
: t('roadtrip.poi.empty')}
</p>
</div>
: t('roadtrip.poi.empty')
}
/>
) : (
<>
{corridor.nameFilter.trim() ? (
@@ -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}
</span>
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</button>
@@ -789,6 +796,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
<ServiceStop
stop={stop}
entry={day.schedule.entries[i]}
driveFindings={day.driveWarnings.filter(w => 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}
@@ -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> | 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> | 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<RoadtripStopType | null>(suggested?.key ?? null)
const [dwell, setDwell] = useState<number>(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<boolean>(draft?.poi.category === 'hotel')
const [endDayId, setEndDayId] = useState<number | null>(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<void> => {
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({
</p>
) : null}
{/* Only where sleeping is on the table. A petrol station gets no choice, because
there is nothing to choose between. */}
{overnight && onSaveNight ? (
<div className="flex rounded-lg border border-edge p-0.5">
{[
{ on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass },
{ on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble },
].map(({ on, label, Icon }) => (
<button
key={label}
type="button"
aria-pressed={night === on}
onClick={() => setNight(on)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-caption transition-colors ${
night === on
? 'bg-accent font-semibold text-accent-text'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
<Icon size={13} aria-hidden />
{label}
</button>
))}
</div>
) : null}
{asNight ? (
<div className="flex flex-col gap-3">
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stay.checkOutDay')}
</span>
<div className="mt-1.5">
<CustomSelect
value={String(endDayId ?? overnight!.defaultEndDayId)}
onChange={value => 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"
/>
</div>
</div>
{/* 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. */}
<div className="flex gap-3">
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkIn')}
</span>
<input
type="time"
value={checkIn}
onChange={e => 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"
/>
</label>
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkOut')}
</span>
<input
type="time"
value={checkOut}
onChange={e => setCheckOut(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"
/>
</label>
</div>
{/* 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 ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
{draft.poi.website ? (
<a
href={draft.poi.website}
target="_blank"
rel="noreferrer noopener"
className="text-accent hover:underline"
>
{t('places.website')}
</a>
) : null}
{draft.poi.phone ? (
<a href={`tel:${draft.poi.phone}`} className="text-accent hover:underline">
{draft.poi.phone}
</a>
) : null}
</div>
) : null}
</div>
) : (
<>
<div>
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.kind')}
@@ -158,6 +287,8 @@ export default function RoadtripStopPopup({
))}
</div>
</div>
</>
)}
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
<button
@@ -280,6 +280,23 @@ describe('legIndexForAlong', () => {
})
describe('deriveDriveWarnings', () => {
// Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on
// a stop has to agree with the sum of the drive bands above it, or it looks made up.
it('FE-ROADTRIP-MODEL-069: the figure on a stop is what the bands above it add up to', () => {
const out = deriveDriveWarnings(
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
0,
)
// 21.5 + 103.4 on arrival at HEM, then 244.7 on arrival at Berlin, because the budget
// starts over after each finding.
expect(out.warnings).toEqual([
{ index: 2, code: 'range', sinceKm: 125 },
{ index: 3, code: 'range', sinceKm: 245 },
])
})
const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }
const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 })
@@ -289,14 +306,17 @@ describe('deriveDriveWarnings', () => {
expect(out.day).toBeNull()
})
it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it leaves', () => {
it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => {
// On arrival, not on departure. Both findings are running totals that only become
// true when you get there, and anchored to the departure the number reads as the
// length of the NEXT leg, which is a different figure and the one that looked wrong.
const out = deriveDriveWarnings(
[leg(100, 90), leg(240, 200)],
[false, false, false],
{ ...noLimits, legMinutes: 180 },
0,
)
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 60 }])
expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }])
})
it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => {
@@ -320,7 +340,9 @@ describe('deriveDriveWarnings', () => {
0,
)
expect(out.warnings.map(w => w.code)).toEqual(['range', 'range'])
expect(out.warnings[0]).toEqual({ index: 1, code: 'range', sinceKm: 700 })
// 300 + 400 on arrival at stop 2, which is exactly what adding up the two drive bands
// above it gives.
expect(out.warnings[0]).toEqual({ index: 2, code: 'range', sinceKm: 700 })
})
it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => {
@@ -352,7 +374,7 @@ describe('deriveDriveWarnings', () => {
expect(first.carryKm).toBe(400)
const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm)
expect(second.warnings).toEqual([{ index: 0, code: 'range', sinceKm: 700 }])
expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }])
})
it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => {
@@ -94,8 +94,12 @@ export interface ScheduleWarning {
/**
* Index of the stop the finding belongs to.
*
* For the two leg codes this is the stop the leg LEAVES, which is the same numbering
* `legSeconds[i]` uses and the same row the drive band is already drawn on.
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
* Both findings are a running total that only becomes true on arrival: "125 km since
* the last fill-up" is what the tank reads when you get there, and the figure has to
* agree with the one anybody adds up off the drive bands above it. Anchored to the
* departure it read as the distance of the leg leaving that stop, which is a different
* number entirely and the one that made the badge look wrong.
*/
index: number
code: 'late' | 'overnight' | 'leg' | 'range'
@@ -323,11 +327,13 @@ export function deriveDriveWarnings(
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = 0
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
const seconds = leg?.duration
if (typeof seconds === 'number') {
totalSeconds += seconds
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
warnings.push({ index: i, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
}
}
const metres = leg?.distance
@@ -336,7 +342,7 @@ export function deriveDriveWarnings(
} else if (budget !== null) {
budget += metres / 1000
if (limits.rangeKm && budget > limits.rangeKm) {
warnings.push({ index: i, code: 'range', sinceKm: Math.round(budget) })
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
budget = 0
}
}
@@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest'
import { roadtripStopTypeSchema } from '@trek/shared'
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
import { STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORY_KEYS, SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES } from './stopKinds'
import {
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory,
} from './stopKinds'
/**
* The table exists to stop nine lists from drifting apart, and these are the assertions
@@ -29,7 +32,33 @@ describe('stop kinds', () => {
})
it('FE-STOPKIND-005: the corridor offers every category in a stable order', () => {
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'])
// Sleeping sits next to sleeping: hotel follows campsite rather than landing wherever
// the stop-kind table happens to end.
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-009: a night can be booked at a campsite too, so the popup asks', () => {
expect(isOvernightCategory('hotel')).toBe(true)
expect(isOvernightCategory('campsite')).toBe(true)
expect(isOvernightCategory('fuel')).toBe(false)
expect(isOvernightCategory(null)).toBe(false)
})
it('FE-STOPKIND-010: every category that is a stop kind names the kind it creates', () => {
for (const category of CORRIDOR_CATEGORIES) {
if (category.stopKind === null) continue
expect(category.stopKind).toBe(category.key)
expect(STOP_KIND_BY_KEY[category.key]).toBeDefined()
}
})
it('FE-STOPKIND-006: two kinds are named under a key of their own, and that is on purpose', () => {
+52 -3
View File
@@ -1,4 +1,4 @@
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, type LucideIcon } from 'lucide-react'
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, BedDouble, type LucideIcon } from 'lucide-react'
import { SERVICE_COLORS, SERVICE_STOP_TYPES } from './roadtripModel'
import type { RoadtripStopType } from '@trek/shared'
@@ -52,8 +52,57 @@ export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
STOP_KINDS.map(k => [k.key, k]),
)
/** The categories the corridor search offers, in the order the panel shows them. */
export const CORRIDOR_CATEGORY_KEYS = STOP_KINDS.filter(k => k.isCorridorCategory).map(k => k.key)
/**
* 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
Icon: LucideIcon
color: string
/** The kind a hit of this category becomes, or null when it becomes something else. */
stopKind: RoadtripStopType | null
}
/** Somewhere to sleep. Blue rather than the campsite's green: both are a night, but one
* of them has a roof, and telling the two apart on a map matters more than grouping them.
* theme-lint-disable — the road-signage palette, shared with the general place search so
* a hotel found along a route looks like the hotels found anywhere else. */
export const HOTEL_COLOR = '#2563EB'
/**
* The categories, in the order the panel shows them.
*
* Written out rather than derived, because the order is a judgement: sleeping sits next
* to sleeping, so `hotel` follows `campsite` instead of landing wherever the stop-kind
* table happens to end.
*/
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
...STOP_KINDS.filter(k => k.isCorridorCategory && 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 })),
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
...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 })),
]
export const CORRIDOR_CATEGORY_BY_KEY: Record<string, CorridorCategory> = Object.fromEntries(
CORRIDOR_CATEGORIES.map(c => [c.key, c]),
)
/** The keys the corridor search offers, in the order the panel shows them. */
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
/** Categories whose hit becomes a night rather than a pause. */
export function isOvernightCategory(category: string | null | undefined): boolean {
// Campsite is both: it is a service stop today and stays one, but a night can be
// booked there just as well, so the popup offers the choice rather than deciding.
return category === 'hotel' || category === 'campsite'
}
/**
* A guard against the drift this file exists to end.
@@ -4,8 +4,9 @@ import { insertIndexForAlong } from './roadtripModel'
import { projectOntoRoute, type LatLng } from './corridor'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
/** The road-trip categories, in the order the panel shows them. */
export const CORRIDOR_CATEGORY_KEYS = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
// The categories come from the one table now, which is also the only place that knows
// a hotel is a category without being a stop kind.
export { CORRIDOR_CATEGORY_KEYS } from './stopKinds'
/** Corridor widths offered, in kilometres. */
export const CORRIDOR_WIDTHS_KM = [2, 5, 10]
+2 -1
View File
@@ -260,7 +260,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
@@ -953,6 +953,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
duplicateName={stopDraftDuplicate}
onClose={() => setStopDraft(null)}
onSave={saveStopDraft}
onSaveNight={saveStopDraftAsNight}
onMoreDetails={stopDraftToForm}
/>
</LazyPanel>
+87 -4
View File
@@ -8,7 +8,9 @@ import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi } from '../../api/client'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { getDayOrder } from '../../utils/dayOrder'
import { isOvernightCategory } from '../../components/Roadtrip/stopKinds'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
import { accommodationRepo } from '../../repo/accommodationRepo'
@@ -665,17 +667,45 @@ export function useTripPlanner() {
const roadtripDayId = roadtripCorridor.day?.dayId ?? null
const { insertIndexFor: roadtripInsertIndexFor } = roadtripCorridor
const roadtripDayNumber = roadtripCorridor.day?.dayNumber ?? 0
/**
* The check-out days a night started on `dayId` can end on.
*
* Ordered by the trip's own day order rather than by array position, the same rule the
* day detail panel follows: a day list can be sorted by anything, and a hotel booked
* out on "the next day" has to mean the next day of the trip.
*/
const overnightOptions = useCallback((dayId: number) => {
const ordered = [...days].sort((a, b) => getDayOrder(a, days) - getDayOrder(b, days))
const from = ordered.findIndex(d => d.id === dayId)
const rest = from < 0 ? ordered : ordered.slice(from)
return {
days: rest.map(d => ({ id: d.id, number: d.day_number ?? 0, date: d.date ?? null })),
// The day after, or this one when it is the last: a night on the final day of a
// trip has nowhere else to end.
defaultEndDayId: rest[1]?.id ?? rest[0]?.id ?? dayId,
}
}, [days])
const handlePoiClick = useCallback((poi: Parameters<typeof openAddPlaceFromPoi>[0]) => {
if (!can('place_edit', trip)) return
// A corridor hit knows how far along the drive it sits, so it can go straight into
// the chain in driving order instead of being dragged there afterwards.
const hit = roadtripActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
if (hit && roadtripDayId != null) {
setStopDraft({ poi: hit, dayId: roadtripDayId, position: roadtripInsertIndexFor(hit), dayNumber: roadtripDayNumber })
setStopDraft({
poi: hit,
dayId: roadtripDayId,
position: roadtripInsertIndexFor(hit),
dayNumber: roadtripDayNumber,
// Only for a hit somebody could sleep at, and it is what gives the popup its
// second mode. The check-out options are the days from this one on in travel
// order; the default is the next one, which is what a night usually means.
...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(roadtripDayId) } : {}),
})
return
}
openAddPlaceFromPoi(poi, roadtripActive ? roadtripDayId : undefined)
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, can, trip])
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, overnightOptions, can, trip])
/**
* The stops of a day as the road trip counts them, in the order it drives them.
@@ -776,6 +806,59 @@ export function useTripPlanner() {
}
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore])
/**
* 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
checkOut: string
}) => {
if (!stopDraft) return
const { poi, dayId, position } = stopDraft
try {
const place = await tripActions.addPlace(tripId, {
name: poi.name,
lat: poi.lat,
lng: poi.lng,
address: poi.address || null,
website: poi.website || undefined,
phone: poi.phone || undefined,
osm_id: poi.osm_id,
})
if (place?.id) {
const plan = reanchorAfterInsert(
roadtripVias.byDay[dayId] ?? [],
position,
viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }),
)
await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
await accommodationsApi.create(tripId, {
place_id: place.id,
start_day_id: dayId,
end_day_id: endDayId,
...(checkIn ? { check_in: checkIn } : {}),
...(checkOut ? { check_out: checkOut } : {}),
})
await loadAccommodations()
await roadtripVias.reanchor(dayId, plan)
updateRouteForDay(dayId)
}
setStopDraft(null)
toast.success(t('roadtrip.stay.nightAdded'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations])
/**
* Moves a stop within its day, from the road trip rail.
*
@@ -1625,7 +1708,7 @@ export function useTripPlanner() {
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,