perfect-postcode/frontend/src/hooks/useTravelTime.ts
2026-06-02 13:46:18 +01:00

224 lines
6.9 KiB
TypeScript

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<TransportMode, string> = {
car: 'Car',
bicycle: 'Bicycle',
walking: 'Walking',
transit: 'Public Transport',
};
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
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<TransportMode, ComponentType<{ className?: string }>> = {
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;
/** 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.
*
* Note: the transit-one-change* variants exist server-side but are not reachable
* from the UI toggles (only no-change + no-buses are exposed). They're available
* via direct API access for callers that want them.
*/
export function resolveTransitVariant(entry: TravelTimeEntry): string {
if (entry.mode !== 'transit') return entry.mode;
const nc = entry.noChange ?? false;
const nb = entry.noBuses ?? false;
if (nc && nb) return 'transit-no-change-no-bus';
if (nc) return 'transit-no-change';
if (nb) return 'transit-no-bus';
return '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 (currently: transit-one-change, transit-one-change-no-bus).
* 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; noBuses: boolean } | null {
if (modeStr === 'car' || modeStr === 'bicycle' || modeStr === 'walking') {
return { mode: modeStr, noChange: false, noBuses: false };
}
switch (modeStr) {
case 'transit':
return { mode: 'transit', noChange: false, noBuses: false };
case 'transit-no-bus':
return { mode: 'transit', noChange: false, noBuses: true };
case 'transit-no-change':
return { mode: 'transit', noChange: true, noBuses: false };
case 'transit-no-change-no-bus':
return { mode: 'transit', noChange: 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<TravelTimeEntry[]>(() =>
dedupeTravelTimeEntries(initial?.entries ?? [])
);
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
{
mode,
slug: '',
label: '',
timeRange: null,
useBest: false,
noChange: 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))
)
);
}, []);
const handleToggleNoChange = useCallback((index: number) => {
setEntries((prev) =>
dedupeTravelTimeEntries(
prev.map((entry, i) =>
i === index ? { ...entry, noChange: !(entry.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,
handleToggleNoBuses,
};
}