import { useState, useCallback, useMemo } from 'react'; import type { ComponentType } from 'react'; import { useTranslation } from 'react-i18next'; import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons'; import { dedupeTravelTimeEntries } from '../lib/travel-params'; export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit'; export const TRANSPORT_MODES: TransportMode[] = ['transit', 'car', 'bicycle', 'walking']; export const MODE_LABELS: Record = { car: 'Car', bicycle: 'Bicycle', walking: 'Walking', transit: 'Public Transport', }; export const MODE_DESCRIPTIONS: Record = { car: 'Drive time via the fastest road route', bicycle: 'Cycling time using bike-friendly routes', walking: 'Walking time along pedestrian paths and pavements', transit: 'Journey time by train, tube, and bus', }; export const MODE_ICONS: Record> = { car: CarIcon, bicycle: BicycleIcon, walking: WalkingIcon, transit: TransitIcon, }; /** * Hook returning translated mode labels and descriptions. */ export function useTranslatedModes() { const { t } = useTranslation(); const label = useCallback( (mode: TransportMode): string => ({ car: t('travel.modeCar'), bicycle: t('travel.modeBicycle'), walking: t('travel.modeWalking'), transit: t('travel.modeTransit'), })[mode], [t] ); const desc = useCallback( (mode: TransportMode): string => ({ car: t('travel.modeCarDesc'), bicycle: t('travel.modeBicycleDesc'), walking: t('travel.modeWalkingDesc'), transit: t('travel.modeTransitDesc'), })[mode], [t] ); return { label, desc }; } export interface TravelTimeEntry { mode: TransportMode; slug: string; label: string; timeRange: [number, number] | null; /** Use best-case (5th percentile) travel time instead of median. Transit only. */ useBest: boolean; /** Restrict transit to walk-transit-walk (0 changes). Optional; defaults to false. */ noChange?: boolean; /** Restrict transit to at most one change. Optional; defaults to false. * Mutually exclusive with noChange (the UI never sets both). */ oneChange?: boolean; /** Drop buses from the allowed transit modes. Optional; defaults to false. */ noBuses?: boolean; } /** * The Java pipeline emits 6 transit variants as separate parquet directories. * The UI represents transit with two toggle booleans; this maps the toggle * state back to the directory/mode name the server expects. * * For non-transit modes the entry.mode passes through unchanged. * * noChange and oneChange are mutually exclusive (the UI never sets both); if * they ever were, noChange wins here. */ export function resolveTransitVariant(entry: TravelTimeEntry): string { if (entry.mode !== 'transit') return entry.mode; const nc = entry.noChange ?? false; const oc = entry.oneChange ?? false; const nb = entry.noBuses ?? false; if (nc) return nb ? 'transit-no-change-no-bus' : 'transit-no-change'; if (oc) return nb ? 'transit-one-change-no-bus' : 'transit-one-change'; return nb ? 'transit-no-bus' : 'transit'; } /** * Parse a server-side mode string (incl. transit variants) back into a base * TransportMode + UI toggle booleans. Returns null for mode strings the UI * cannot represent. Callers should skip entries that parse to null rather than * silently normalising to a different variant. */ export function parseServerMode( modeStr: string ): { mode: TransportMode; noChange: boolean; oneChange: boolean; noBuses: boolean } | null { if (modeStr === 'car' || modeStr === 'bicycle' || modeStr === 'walking') { return { mode: modeStr, noChange: false, oneChange: false, noBuses: false }; } switch (modeStr) { case 'transit': return { mode: 'transit', noChange: false, oneChange: false, noBuses: false }; case 'transit-no-bus': return { mode: 'transit', noChange: false, oneChange: false, noBuses: true }; case 'transit-no-change': return { mode: 'transit', noChange: true, oneChange: false, noBuses: false }; case 'transit-no-change-no-bus': return { mode: 'transit', noChange: true, oneChange: false, noBuses: true }; case 'transit-one-change': return { mode: 'transit', noChange: false, oneChange: true, noBuses: false }; case 'transit-one-change-no-bus': return { mode: 'transit', noChange: false, oneChange: true, noBuses: true }; default: return null; } } /** Field key matching the backend response: tt_{server-mode}_{slug} */ export function travelFieldKey(entry: TravelTimeEntry): string { return `tt_${resolveTransitVariant(entry)}_${entry.slug}`; } /** Slider/data ceiling (minutes). Mirrors MAX_TRIP_DURATION_MINUTES in the R5 pipeline. */ export const MAX_TRAVEL_MINUTES = 90; export interface TravelTimeInitial { entries?: TravelTimeEntry[]; } export function useTravelTime(initial?: TravelTimeInitial) { const [entries, setEntries] = useState(() => dedupeTravelTimeEntries(initial?.entries ?? []) ); const handleAddEntry = useCallback((mode: TransportMode) => { setEntries((prev) => [ ...prev, { mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, oneChange: false, noBuses: false, }, ]); }, []); const handleRemoveEntry = useCallback((index: number) => { setEntries((prev) => prev.filter((_, i) => i !== index)); }, []); const handleSetDestination = useCallback((index: number, slug: string, label: string) => { setEntries((prev) => dedupeTravelTimeEntries( prev.map((entry, i) => i === index ? { ...entry, slug, label, timeRange: slug ? [0, MAX_TRAVEL_MINUTES] : null } : entry ) ) ); }, []); const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => { setEntries((prev) => dedupeTravelTimeEntries( prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry)) ) ); }, []); const handleToggleBest = useCallback((index: number) => { setEntries((prev) => dedupeTravelTimeEntries( prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry)) ) ); }, []); // noChange and oneChange are mutually exclusive: enabling one clears the // other so the resolved server variant is never ambiguous. const handleToggleNoChange = useCallback((index: number) => { setEntries((prev) => dedupeTravelTimeEntries( prev.map((entry, i) => i === index ? { ...entry, noChange: !(entry.noChange ?? false), oneChange: false } : entry ) ) ); }, []); const handleToggleOneChange = useCallback((index: number) => { setEntries((prev) => dedupeTravelTimeEntries( prev.map((entry, i) => i === index ? { ...entry, oneChange: !(entry.oneChange ?? false), noChange: false } : entry ) ) ); }, []); const handleToggleNoBuses = useCallback((index: number) => { setEntries((prev) => dedupeTravelTimeEntries( prev.map((entry, i) => i === index ? { ...entry, noBuses: !(entry.noBuses ?? false) } : entry ) ) ); }, []); const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => { setEntries(dedupeTravelTimeEntries(newEntries)); }, []); /** Entries that have a destination selected (slug is set) */ const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]); return { entries, activeEntries, handleAddEntry, handleRemoveEntry, handleSetDestination, handleSetEntries, handleTimeRangeChange, handleToggleBest, handleToggleNoChange, handleToggleOneChange, handleToggleNoBuses, }; }