Files
TREK/client/src/components/Roadtrip/stopKinds.ts
T
Maurice 5ef8bf31f4 fix(roadtrip): a stop added by hand opens on the kind being looked for
The button carried nothing and the form hard-wired the kind, so the categories
sitting directly above it had no way through and every manual stop started life
as a fuel stop with ten minutes on it. The click now carries what the corridor
is looking for, dwell included. The picker is multi-select, so the first kind
the panel lists wins rather than a fallback nobody switched on; with nothing
selected the form falls back on its own, because a stop without a kind is a
numbered destination that counts in every total.

This also closes the case it was noticed on: an electric car seeds the panel to
charging, so the picker said Charging over a form that said Fuel.
2026-09-18 13:17:59 +02:00

130 lines
6.9 KiB
TypeScript

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'
/**
* One row per kind of stop a drive can have, and the only place a new one is added.
*
* The six kinds used to be written out in nine separate lists: the enum in `@trek/shared`,
* `SERVICE_STOP_TYPES` and `SERVICE_COLORS` here in the model, the corridor's category
* keys, the map's `ROADTRIP_POI_CATEGORIES`, the popup's own `STOP_KINDS`, two lookups in
* the rail, one in the map markers and one in the corridor panel. Every one of those
* carried a comment telling the next person to keep the others in step, and it still came
* apart: a rest area was a parking sign in the popup, on the map pill and in the panel,
* and a coffee cup on the rail disc and the map marker, for the same stop. That is fixed
* here in favour of the parking sign, because the colour is already documented as "the
* blue every parking sign in Europe is printed in".
*
* The table lives beside the model rather than in it because an icon is a React component
* 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.
*
*/
export interface StopKind {
key: RoadtripStopType
/** Two of the six do not match their key: `rest_area` is named under `rest`, `restaurant` under `food`. */
labelKey: string
Icon: LucideIcon
color: string
/** What the popup offers before anyone changes it — how long this kind usually takes. */
defaultMinutes: number
/** Interrupts the drive: drawn on the dashed line, no number, left out of every stop count. */
isService: boolean
/** Offered as a category in the corridor search. */
isCorridorCategory: boolean
}
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 },
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, defaultMinutes: 60, isService: true, isCorridorCategory: true },
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, defaultMinutes: 45, isService: true, isCorridorCategory: true },
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, defaultMinutes: 30, isService: true, isCorridorCategory: true },
]
/** By key, for the lookups the rail, the markers and the panel used to keep themselves. */
export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
STOP_KINDS.map(k => [k.key, k]),
)
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' && 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: '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 })),
]
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)
/**
* The kind a stop added by hand opens on, read off what the corridor is looking for.
*
* The categories are what the traveller has just told the search to find, so they are
* also the best reading of what they are about to add themselves, and on an electric
* car the panel seeds itself to charging, which is precisely the case where a form
* opening on a fuel pump read as a fault.
*
* The picker is multi-select, so the first category the panel LISTS wins. Falling back
* instead would open on a kind that is not among the ones switched on, which is a worse
* answer than any of them. Null only when nothing is selected at all; the caller decides
* what that means, because a form must never open on no kind (see `DEFAULT_SERVICE_KIND`).
*/
export function manualStopKindFor(categories: readonly string[]): RoadtripStopType | null {
for (const category of CORRIDOR_CATEGORIES) {
if (categories.includes(category.key) && category.stopKind) return category.stopKind
}
return null
}
/** 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.
*
* `SERVICE_STOP_TYPES` is the model's own list and stays there, because the arithmetic
* must not depend on anything that imports React. The two are checked against each other
* in `stopKinds.test.ts` rather than derived, so a kind added to one and forgotten in the
* other fails a test instead of quietly counting as a destination.
*/
export const SERVICE_KIND_KEYS = STOP_KINDS.filter(k => k.isService).map(k => k.key)
/** The kinds where a tank or a battery is filled, so a range budget starts over. */
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
export { SERVICE_STOP_TYPES }