mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
refactor(roadtrip): one table for the six stop kinds, not nine lists
The kinds were written out in nine places: the enum in @trek/shared, the service list and the colours in the model, the corridor's category keys, the map's road-trip categories, the popup's own table, two lookups in the rail, one in the map markers and one in the corridor panel. Each carried a comment asking the next person to keep the others in step, and it came apart anyway. 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. The parking sign wins: the colour has been documented since it was written as "the blue every parking sign in Europe is printed in". stopKinds.ts holds one row per kind and everything else reads from it. It sits beside the model rather than inside it because an icon is a React component and roadtripModel.ts is deliberately React-free so the arithmetic can be tested on its own; the colours and the service list stay there and are read from here. Two flags rather than one, because they are not the same question: a hotel is something the corridor can find, but sleeping somewhere ends the day rather than interrupting the drive. Seven tests pin the table against the two lists that cannot import it, so the next kind added in one place and forgotten in another fails a test instead of quietly counting as a destination.
This commit is contained in:
@@ -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<string, PoiCategory> = Object.fromEntries(
|
||||
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
|
||||
|
||||
@@ -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<string, { labelKey: string; Icon: LucideIcon }> = {
|
||||
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'
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
cycling: Bike,
|
||||
}
|
||||
|
||||
/** What each kind of pause looks like. The icon is what tells the teal discs apart. */
|
||||
const SERVICE_ICON: Record<string, LucideIcon> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="grid items-stretch" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center">
|
||||
|
||||
@@ -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<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.minutes ?? 30)
|
||||
const [dwell, setDwell] = useState<number>(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
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<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 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 }
|
||||
Reference in New Issue
Block a user