import { useState, useEffect } from 'react'; import type { JourneyLeg } from '../../types'; import type { TravelTimeEntry } from '../../hooks/useTravelTime'; import { apiUrl, logNonAbortError } from '../../lib/api'; import { WalkingIcon } from '../ui/icons/WalkingIcon'; import { BicycleIcon } from '../ui/icons/BicycleIcon'; interface JourneyInstructionsProps { postcode: string; entries: TravelTimeEntry[]; /** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */ label?: string; } interface JourneyData { slug: string; label: string; legs: JourneyLeg[] | null; /** Median (50th percentile) total travel time from R5, including waiting. */ minutes: number | null; /** Best-case (5th percentile) total travel time from R5. */ bestMinutes: number | null; loading: boolean; } // Official TfL line colors + other known London transit const ROUTE_COLORS: Record = { Bakerloo: { color: '#B36305' }, Central: { color: '#E32017' }, Circle: { color: '#FFD300', darkText: true }, District: { color: '#00782A' }, 'Elizabeth line': { color: '#6950A1' }, Elizabeth: { color: '#6950A1' }, 'Hammersmith & City': { color: '#F3A9BB', darkText: true }, 'Hammersmith and City': { color: '#F3A9BB', darkText: true }, Jubilee: { color: '#A0A5A9', darkText: true }, Metropolitan: { color: '#9B0056' }, Northern: { color: '#333333' }, Piccadilly: { color: '#003688' }, Victoria: { color: '#0098D4' }, 'Waterloo & City': { color: '#95CDBA', darkText: true }, 'Waterloo and City': { color: '#95CDBA', darkText: true }, DLR: { color: '#00A4A7' }, 'London Overground': { color: '#EE7C0E' }, }; const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']); /** Strip trailing parenthesized GTFS route IDs and NaPTAN stop codes (e.g. "(6757261)", "(9400ZZLUCGT1)") */ function stripId(label: string): string { return label.replace(/\s+\([A-Za-z0-9]+\)$/, ''); } function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } { const clean = stripId(mode); const known = ROUTE_COLORS[clean]; if (known) { const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`; return { label, color: known.color, darkText: !!known.darkText }; } if (/^\d+[A-Za-z]?$/.test(clean.trim())) { return { label: `Bus ${clean}`, color: '#0d9488', darkText: false }; } return { label: clean, color: '#6b7280', darkText: false }; } /** Returns a Unix timestamp for the next Monday at 07:30 local time. */ function nextMondayAt730(): number { const now = new Date(); const day = now.getDay(); // 0=Sun … 6=Sat const daysUntil = day === 0 ? 1 : day === 1 ? 7 : 8 - day; const monday = new Date(now); monday.setDate(now.getDate() + daysUntil); monday.setHours(7, 30, 0, 0); return Math.floor(monday.getTime() / 1000); } function googleMapsUrl(postcode: string, destination: string): string { const params = new URLSearchParams({ api: '1', origin: postcode, destination, travelmode: 'transit', }); return `https://www.google.com/maps/dir/?${params}&departure_time=${nextMondayAt730()}`; } function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { return [...legs] .reverse() .map((leg) => (leg.from && leg.to ? { ...leg, from: leg.to, to: leg.from } : leg)); } function RouteBadge({ mode }: { mode: string }) { const { label, color, darkText } = getRouteDisplay(mode); return ( {label} ); } function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) { const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle'; if (isAccess) { return (
{!isLast && (
)}
{leg.mode === 'walk' ? ( ) : ( )} {leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
); } const { color } = getRouteDisplay(leg.mode); return (
{!isLast &&
}
{leg.minutes} min
{leg.from && leg.to && (
{stripId(leg.from)} → {stripId(leg.to)}
)}
); } export default function JourneyInstructions({ postcode, entries, label, }: JourneyInstructionsProps) { const [journeys, setJourneys] = useState([]); // Only transit entries with a destination set const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== ''); useEffect(() => { if (transitEntries.length === 0) { setJourneys([]); return; } const controller = new AbortController(); const results: JourneyData[] = transitEntries.map((e) => ({ slug: e.slug, label: e.label, legs: null, minutes: null, bestMinutes: null, loading: true, })); setJourneys([...results]); transitEntries.forEach((entry, idx) => { const params = new URLSearchParams({ postcode, mode: 'transit', slug: entry.slug, }); fetch(apiUrl('journey', params), { signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then( (data: { journey: JourneyLeg[] | null; minutes: number | null; best_minutes: number | null; }) => { setJourneys((prev) => prev.map((j, i) => i === idx ? { ...j, legs: data.journey, minutes: data.minutes, bestMinutes: data.best_minutes, loading: false, } : j ) ); } ) .catch((err) => { logNonAbortError('journey', err); setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))); }); }); return () => controller.abort(); }, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps if (transitEntries.length === 0) return null; return (
{label && (
Journeys from {label}
)} {journeys.map((j) => { const displayLegs = j.legs ? invertLegs(j.legs) : null; const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0; const totalMin = j.minutes ?? legSum; return (
To {j.label || j.slug} {displayLegs && displayLegs.length > 0 && ( {totalMin} min )}
{j.loading ? (
Loading...
) : displayLegs && displayLegs.length > 0 ? (
{displayLegs.map((leg, i) => ( ))} View on Google Maps
) : ( No journey data available )}
); })}
); }