diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts index a85f42da6..b72cbee3e 100644 --- a/client/src/components/Map/poiCategories.ts +++ b/client/src/components/Map/poiCategories.ts @@ -2,6 +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' // 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 @@ -31,12 +32,9 @@ 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[] = [ - { key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel }, - { key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging }, - { key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area }, - { key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite }, -] +export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = STOP_KINDS.map( + ({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }), +) export const POI_CATEGORY_BY_KEY: Record = Object.fromEntries( [...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]), diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 5ff773574..5e2b14c26 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -9,6 +9,7 @@ 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 { FS } from './typeScale' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' import type { CorridorPoi } from './useCorridorPois' @@ -25,14 +26,8 @@ interface RoadtripCorridorPanelProps { ) => void } -const CATEGORY_META: Record = { - fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel }, - charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap }, - rest_area: { labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare }, - campsite: { labelKey: 'roadtrip.poi.campsite', Icon: Tent }, - restaurant: { labelKey: 'roadtrip.poi.food', Icon: Utensils }, - sights: { labelKey: 'roadtrip.poi.sights', Icon: Camera }, -} +/** Label and icon per category, from the one table every road-trip surface reads. */ +const CATEGORY_META = STOP_KIND_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' diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 4123d5632..2fd432b1c 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,7 +1,7 @@ import React from 'react' import { MapPin, CarFront, Footprints, Bike, Zap, AlertTriangle, Moon, - Fuel, Coffee, Tent, Utensils, Camera, Shuffle, + ParkingSquare, Shuffle, type LucideIcon, } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' @@ -9,6 +9,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } from '../../utils/formatters' import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' +import { STOP_KIND_BY_KEY } from './stopKinds' import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' import { FS } from './typeScale' import type { RouteSegment } from '../../types' @@ -42,29 +43,8 @@ const MODE_ICON: Record = { cycling: Bike, } -/** What each kind of pause looks like. The icon is what tells the teal discs apart. */ -const SERVICE_ICON: Record = { - fuel: Fuel, - charging: Zap, - rest_area: Coffee, - campsite: Tent, - restaurant: Utensils, - sights: Camera, -} - -/** - * What each kind is called. Spelled out rather than derived from the key, because two of - * them do not match: the corridor calls a rest area `rest_area` but names it under - * `rest`, and a restaurant `restaurant` but names it under `food`. - */ -const SERVICE_LABEL_KEY: Record = { - fuel: 'roadtrip.poi.fuel', - charging: 'roadtrip.poi.charging', - rest_area: 'roadtrip.poi.rest', - campsite: 'roadtrip.poi.campsite', - restaurant: 'roadtrip.poi.food', - sights: 'roadtrip.poi.sights', -} +// Icon and name both come from the one stop-kind table now. This file used to keep its +// own copy of each, and its rest_area icon had drifted away from the popup's. /** The column the markers and the line share, and the gap to the content beside it. */ @@ -346,8 +326,9 @@ function ServiceStop({ stop, entry, selected, onSelect, onEditStay }: { onEditStay?: () => void }): React.ReactElement { const { t } = useTranslation() - const Icon = SERVICE_ICON[stop.stopType ?? ''] ?? Coffee - const label = t(SERVICE_LABEL_KEY[stop.stopType ?? ''] ?? 'roadtrip.poi.rest') + const kind = STOP_KIND_BY_KEY[stop.stopType ?? ''] + const Icon = kind?.Icon ?? ParkingSquare + const label = t(kind?.labelKey ?? 'roadtrip.poi.rest') return (
diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index 97bd76a91..cf80ebd27 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -3,6 +3,7 @@ import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Hourglass, AlertTrian import Modal from '../shared/Modal' import { useTranslation } from '../../i18n/TranslationContext' import { formatDurationShort, SERVICE_COLORS } from './roadtripModel' +import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds' import type { CorridorPoi } from './useCorridorPois' import type { RoadtripStopType } from '@trek/shared' @@ -17,16 +18,8 @@ import type { RoadtripStopType } from '@trek/shared' * The colours are the road-trip palette from `poiCategories.ts` and are the same in both * themes on purpose — a brand-neutral fuel blue reads as fuel either way. */ -const STOP_KINDS: { key: RoadtripStopType; labelKey: string; Icon: LucideIcon; color: string; minutes: number }[] = [ - { key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, minutes: 10 }, - { key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, minutes: 30 }, - { key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, minutes: 20 }, - { key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, minutes: 60 }, - // The corridor can find these two as well, and anything picked off "along the route" - // is a pause on the way rather than a destination — so they get a kind like the rest. - { key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, minutes: 45 }, - { key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, minutes: 30 }, -] +// The kinds, their icons, their colours and how long each one usually takes all come +// from the one table in stopKinds.ts. /** How long to stand still, offered as the few answers anyone actually gives. */ const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] @@ -64,7 +57,7 @@ export default function RoadtripStopPopup({ 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?.minutes ?? 30) + const [dwell, setDwell] = useState(suggested?.defaultMinutes ?? 30) const [saving, setSaving] = useState(false) if (!draft) return null @@ -125,7 +118,7 @@ export default function RoadtripStopPopup({ setStopType(on ? null : 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_KINDS.find(k => k.key === key)!.minutes) + if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes) }} className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${ on diff --git a/client/src/components/Roadtrip/serviceMarker.ts b/client/src/components/Roadtrip/serviceMarker.ts index ebfa04c16..0797e8d6f 100644 --- a/client/src/components/Roadtrip/serviceMarker.ts +++ b/client/src/components/Roadtrip/serviceMarker.ts @@ -1,17 +1,8 @@ import { createElement } from 'react' -import { Fuel, Zap, Coffee, Tent, Utensils, Camera, type LucideIcon } from 'lucide-react' +import { ParkingSquare } from 'lucide-react' import { renderIconMarkup } from '../../utils/iconMarkup' import { isServiceStopType, serviceColor } from './roadtripModel' - -/** The same icons the rail puts on its discs, so one kind of stop looks like itself. */ -const SERVICE_ICON: Record = { - fuel: Fuel, - charging: Zap, - rest_area: Coffee, - campsite: Tent, - restaurant: Utensils, - sights: Camera, -} +import { STOP_KIND_BY_KEY } from './stopKinds' /** * Smaller than a place marker on purpose. @@ -45,7 +36,7 @@ export function serviceMarkerOuter(selected: boolean): number { export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null { if (!isServiceStopType(stopType)) return null const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE - const Icon = SERVICE_ICON[stopType ?? ''] ?? Coffee + const Icon = STOP_KIND_BY_KEY[stopType ?? '']?.Icon ?? ParkingSquare const svg = renderIconMarkup(createElement(Icon, { size: Math.round(size * 0.56), color: '#fff', diff --git a/client/src/components/Roadtrip/stopKinds.test.ts b/client/src/components/Roadtrip/stopKinds.test.ts new file mode 100644 index 000000000..b87138c83 --- /dev/null +++ b/client/src/components/Roadtrip/stopKinds.test.ts @@ -0,0 +1,54 @@ +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' + +/** + * The table exists to stop nine lists from drifting apart, and these are the assertions + * that make that true rather than intended. Each one pins the table against a list that + * cannot import it: the Zod enum lives in `@trek/shared` and must not depend on the + * client, and `SERVICE_STOP_TYPES` lives in the React-free model. + */ +describe('stop kinds', () => { + it('FE-STOPKIND-001: covers exactly the kinds the shared enum allows', () => { + const fromSchema = [...roadtripStopTypeSchema.options].sort() + const fromTable = STOP_KINDS.map(k => k.key).sort() + expect(fromTable).toEqual(fromSchema) + }) + + it('FE-STOPKIND-002: agrees with the model about which kinds interrupt a drive', () => { + expect([...SERVICE_KIND_KEYS].sort()).toEqual([...SERVICE_STOP_TYPES].sort()) + }) + + it('FE-STOPKIND-003: every kind carries the colour the model gives it', () => { + for (const kind of STOP_KINDS) expect(kind.color).toBe(SERVICE_COLORS[kind.key]) + }) + + it('FE-STOPKIND-004: a refuelling kind is a kind, so a range budget cannot reset on a typo', () => { + for (const key of REFUELLING_STOP_TYPES) expect(STOP_KIND_BY_KEY[key]).toBeDefined() + }) + + 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']) + }) + + it('FE-STOPKIND-006: two kinds are named under a key of their own, and that is on purpose', () => { + // Spelled out rather than derived: the corridor calls a rest area rest_area and names + // it under rest, a restaurant restaurant and names it under food. A derived label key + // would silently ask for two translations that do not exist. + 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') { + expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`) + } + } + }) + + it('FE-STOPKIND-007: every kind offers a dwell the popup can pre-fill', () => { + for (const kind of STOP_KINDS) { + expect(kind.defaultMinutes).toBeGreaterThan(0) + expect(Number.isInteger(kind.defaultMinutes)).toBe(true) + } + }) +}) diff --git a/client/src/components/Roadtrip/stopKinds.ts b/client/src/components/Roadtrip/stopKinds.ts new file mode 100644 index 000000000..a41de6764 --- /dev/null +++ b/client/src/components/Roadtrip/stopKinds.ts @@ -0,0 +1,71 @@ +import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, 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. + * + * `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 + /** 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: '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 = 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 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 }