From 5fff51c814c4ac0ee13f93b0e6b57e3f233aebfa Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 11:14:34 +0700 Subject: [PATCH] feat(transit): depart/arrive time picker, mode filters, and stop boards Builds out the rest of the transit UX from data Transitous actually provides. - Time picker: Leave now / Depart at / Arrive by (MOTIS time + arriveBy). - Mode filter: Bus/Tram/Metro/Train/Ferry groups -> MOTIS transitModes. - Stop departure boards: tap a stop name -> MOTIS stoptimes, with live and cancelled badges and a feed-provided platform hint. - Fares: MOTIS carries NO prices (verified live: debugOutput.fares=0, agencyFareUrl empty), so a 'tickets' link is shown ONLY when the feed supplies an operator fare URL - never an invented price. New src/lib/transitUi.ts holds the mode vocabulary (emoji/colour/groups) so the itinerary and the board cannot drift; StopDepartures.tsx is its own dialog. Docs: AGENTS.md transit note. --- AGENTS.md | 20 ++- src/components/RouteDialog.tsx | 229 ++++++++++++++++++++++-------- src/components/StopDepartures.tsx | 142 ++++++++++++++++++ src/lib/routing.ts | 84 ++++++++++- src/lib/transitUi.ts | 95 +++++++++++++ 5 files changed, 503 insertions(+), 67 deletions(-) create mode 100644 src/components/StopDepartures.tsx create mode 100644 src/lib/transitUi.ts diff --git a/AGENTS.md b/AGENTS.md index 7fca9a9..ee4e301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,9 +351,23 @@ session with **no other memory of this project**. So: `METRO` (verified live) — that's the feed's own classification; don't "correct" it heuristically. ⚠️ Transitous **403s a generic User-Agent** ("Generic user-agent headers are not allowed") — the browser sends its own so - the app is fine, but a Node/curl probe must set a `User-Agent`. Still absent: - depart/arrive-by time picker, per-mode filters (bus/train), stop departure - boards, fares. + the app is fine, but a Node/curl probe must set a `User-Agent`. + **Time picker + mode filter + stop boards (2026-10-01):** `fetchTransitRoute` + takes `{ time, arriveBy, transitModes }` (MOTIS `time`/`arriveBy`/`transitModes`); + the dialog exposes **Leave now · Depart at · Arrive by** (a `datetime-local` + input) and an **Only** row of mode toggles (nothing selected = all modes). + Mode groups live in **`src/lib/transitUi.ts`** (`TRANSIT_MODE_GROUPS`) with the + shared mode→emoji/colour/label helpers — same module the board uses, so the + vocabulary can't drift. Tapping a **stop name** in the itinerary opens + **`StopDepartures.tsx`** (`fetchStopDepartures` → MOTIS `/api/v1/stoptimes`, + `n=10`), showing clock, mode, line, headsign, a feed-provided platform hint + (`place.description`, e.g. "S-Bahnsteig Gleis 3"), and **live/cancelled** + badges. It is rendered *outside* the route dialog (a nested focus trap would + fight). ⚠️ **Fares do not exist in this data — do NOT invent prices:** + verified live that the plan response has no fare field (`debugOutput.fares: 0`, + `agencyFareUrl: ""`). The only honest fare UI is a `tickets ↗` link to + `agencyFareUrl` **when the feed supplies one** (usually empty). Still absent: + nothing planned. - ⚠️ **`RouteDialog` had a total-failure bug (fixed 2026-09-27), don't regress:** (1) its Leaflet map is inside Radix Dialog content, which mounts AFTER the component's first effect pass, so a `mapDivRef.current` guard was null, diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index e727d1f..acea553 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -35,62 +35,37 @@ import { skateTime, type TravelMode, type RouteResult, - type TransitItinerary, + type TransitPlanOptions, } from '@/lib/routing'; +import { + TRANSIT_MODE_GROUPS, + legDurationS, + transitLegColor, + transitModeEmoji, + transitPrimaryEmoji, +} from '@/lib/transitUi'; +import { StopDepartures } from '@/components/StopDepartures'; -// ─── Transit presentation helpers ──────────────────────────────────────────── - -/** Line colour for a transit leg, keyed by the MOTIS mode. */ -function transitLegColor(mode: string): string { - switch (mode) { - case 'BUS': return '#22c55e'; - case 'TRAM': return '#eab308'; - case 'SUBWAY': - case 'METRO': return '#3b82f6'; - case 'RAIL': - case 'REGIONAL_RAIL': - case 'REGIONAL_FAST_RAIL': - case 'LONG_DISTANCE': - case 'HIGHSPEED_RAIL': - case 'NIGHT_RAIL': - case 'SUBURBAN': return '#ef4444'; - case 'FERRY': return '#06b6d4'; - default: return '#ccff00'; - } -} - -/** Emoji for a transit leg's mode. */ -function transitModeEmoji(mode: string): string { - switch (mode) { - case 'BUS': return '🚌'; - case 'TRAM': return '🚊'; - case 'SUBWAY': - case 'METRO': return '🚇'; - case 'RAIL': - case 'REGIONAL_RAIL': - case 'REGIONAL_FAST_RAIL': - case 'LONG_DISTANCE': - case 'HIGHSPEED_RAIL': - case 'NIGHT_RAIL': - case 'SUBURBAN': return '🚆'; - case 'FERRY': return '⛴️'; - case 'WALK': return '🚶'; - case 'BIKE': return '🚲'; - default: return '🚇'; - } -} - -/** Leading emoji for a whole itinerary — its first vehicle leg, else walking. */ -function transitPrimaryEmoji(it: TransitItinerary): string { - const vehicle = it.legs.find((l) => l.mode !== 'WALK' && l.mode !== 'BIKE'); - return transitModeEmoji(vehicle?.mode ?? 'WALK'); -} - -/** Seconds between a leg's start and end (0 when either time is missing). */ -function legDurationS(leg: TransitItinerary['legs'][number]): number { - if (!leg.startISO || !leg.endISO) return 0; - const ms = Date.parse(leg.endISO) - Date.parse(leg.startISO); - return Number.isFinite(ms) && ms > 0 ? Math.round(ms / 1000) : 0; +/** A stop name that opens the departure board, when the feed gave a stop id. */ +function StopButton({ + id, + name, + onOpen, +}: { + id?: string; + name?: string; + onOpen: (id: string, name: string) => void; +}) { + if (!id || !name) return <>{name ?? ''}; + return ( + + ); } interface RouteDialogProps { @@ -255,6 +230,21 @@ export function RouteDialog({ // All alternatives (falling back to a single option for older results). const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []); + // Transit plan controls: leave-now vs depart-at/arrive-by, and a mode filter. + const [transitTimeMode, setTransitTimeMode] = useState<'now' | 'depart' | 'arrive'>('now'); + const [transitTimeValue, setTransitTimeValue] = useState(''); + const [transitModeFilter, setTransitModeFilter] = useState([]); + const [stopBoard, setStopBoard] = useState<{ id: string; name: string } | null>(null); + + const selectedTransitModes = useMemo( + () => + TRANSIT_MODE_GROUPS.filter((g) => transitModeFilter.includes(g.key)).flatMap((g) => [...g.modes]), + [transitModeFilter], + ); + + const toggleModeGroup = (key: string) => + setTransitModeFilter((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + /** Re-fit the map to the whole route (after tapping a step pans it away). */ const fitRoute = useCallback(() => { const map = mapRef.current; @@ -636,8 +626,19 @@ export function RouteDialog({ setRouting(true); setError(null); try { + const planOpts: TransitPlanOptions = {}; + if (mode === 'transit') { + if (transitTimeMode !== 'now' && transitTimeValue) { + const when = new Date(transitTimeValue); + if (!Number.isNaN(when.getTime())) { + planOpts.time = when.toISOString(); + planOpts.arriveBy = transitTimeMode === 'arrive'; + } + } + if (selectedTransitModes.length > 0) planOpts.transitModes = selectedTransitModes; + } const result = mode === 'transit' - ? await fetchTransitRoute(effectiveOrigin, dest, controller.signal) + ? await fetchTransitRoute(effectiveOrigin, dest, controller.signal, planOpts) : await fetchRoute(effectiveOrigin, dest, mode, controller.signal); if (cancelled) return; setCachedAge(null); @@ -675,7 +676,9 @@ export function RouteDialog({ if (mode === 'transit') { setError( msg === 'No transit route found' - ? 'No public-transport route found here — this area may not publish open timetables.' + ? selectedTransitModes.length > 0 || transitTimeMode !== 'now' + ? 'No route for that time or transport mode — try another time, or include more modes.' + : 'No public-transport route found here — this area may not publish open timetables.' : "Couldn't reach the transit service. Check your connection and try again.", ); } else if (msg === ROUTE_ERR_BUSY) { @@ -720,7 +723,7 @@ export function RouteDialog({ cancelled = true; controller.abort(); }; - }, [open, effectiveOrigin, dest, mode]); + }, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes]); // ─── Draw the selected transit itinerary (redraws when an alternative is // chosen — no re-query). Walking legs dashed grey, vehicles solid. ────── @@ -752,6 +755,7 @@ export function RouteDialog({ }, [selectedTransit]); return ( + <> {/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM order: the map sits directly under the header (bigger — it's the point @@ -1113,6 +1117,69 @@ export function RouteDialog({ from the same MOTIS response (nothing extra is fetched). */} {!following && mode === 'transit' && selectedTransit && (
+ {/* When: leave now · depart at · arrive by */} +
+ {(['now', 'depart', 'arrive'] as const).map((m) => ( + + ))} + {transitTimeMode !== 'now' && ( + setTransitTimeValue(e.target.value)} + aria-label={transitTimeMode === 'depart' ? 'Departure time' : 'Arrival time'} + className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white" + /> + )} +
+ + {/* Only: filter the modes (nothing selected = all) */} +
+ Only + {TRANSIT_MODE_GROUPS.map((g) => { + const on = transitModeFilter.includes(g.key); + return ( + + ); + })} + {transitModeFilter.length > 0 && ( + + )} +
+ {options.length > 1 && (
{options.map((opt, i) => { @@ -1200,18 +1267,50 @@ export function RouteDialog({
{isFoot ? ( <> - {leg.toName ? `to ${leg.toName}` : leg.mode === 'BIKE' ? 'ride' : 'walk'} + {leg.toName ? ( + <> + to{' '} + setStopBoard({ id, name })} + /> + + ) : leg.mode === 'BIKE' ? 'ride' : 'walk'} {leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''} ) : ( <> - {leg.fromName}{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock} + setStopBoard({ id, name })} + /> + {leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock} {' → '} - {leg.toName}{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock} + setStopBoard({ id, name })} + /> + {leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock} {typeof leg.stops === 'number' && leg.stops > 0 ? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}` : ''} {leg.agencyName ? ` · ${leg.agencyName}` : ''} + {leg.agencyFareUrl ? ( + <> + {' · '} + + tickets ↗ + + + ) : ''} )}
@@ -1285,5 +1384,15 @@ export function RouteDialog({
+ + {/* Departure board — its own dialog, kept OUTSIDE the route dialog so the + two focus traps don't fight. */} + { if (!o) setStopBoard(null); }} + stopId={stopBoard?.id ?? null} + stopName={stopBoard?.name ?? ''} + /> + ); } diff --git a/src/components/StopDepartures.tsx b/src/components/StopDepartures.tsx new file mode 100644 index 0000000..e4af9d1 --- /dev/null +++ b/src/components/StopDepartures.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + fetchStopDepartures, + formatClock, + TRANSIT_ATTRIBUTION, + type StopDeparture, +} from '@/lib/routing'; +import { transitLegColor, transitModeEmoji } from '@/lib/transitUi'; +import { cn } from '@/lib/utils'; + +interface StopDeparturesProps { + open: boolean; + onOpenChange: (open: boolean) => void; + stopId: string | null; + stopName: string; +} + +/** + * Next departures from a stop — the board behind a tap on a stop in the + * itinerary. Live data (MOTIS `realTime`) is badged; the board is real + * schedule data, never invented, and says so when nothing comes back. + */ +export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDeparturesProps) { + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + if (!open || !stopId) return; + const controller = new AbortController(); + let cancelled = false; + setRows(null); + setError(null); + + (async () => { + try { + const departures = await fetchStopDepartures(stopId, { n: 10 }, controller.signal); + if (!cancelled) setRows(departures); + } catch (err) { + if (cancelled) return; + if (err instanceof DOMException && err.name === 'AbortError') return; + setError("Couldn't reach the transit service. Check your connection and try again."); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [open, stopId, reloadKey]); + + return ( + + + + + + Departures + + {stopName} + + + Upcoming departures from {stopName} + + + +
+ {error ? ( +
{error}
+ ) : !rows ? ( +
+ Loading departures… +
+ ) : rows.length === 0 ? ( +
+ No upcoming departures found at this stop. +
+ ) : ( +
    + {rows.map((d, i) => ( +
  • + + {d.departISO ? formatClock(d.departISO) : '—'} + + {transitModeEmoji(d.mode)} + + {d.line ?? d.mode} + + + + {d.headsign ?? '—'} + + {d.platform && ( + {d.platform} + )} + + {d.realTime && !d.cancelled && ( + + live + + )} + {d.cancelled && ( + + cancelled + + )} +
  • + ))} +
+ )} +
+ +
+

{TRANSIT_ATTRIBUTION}

+ +
+
+
+ ); +} diff --git a/src/lib/routing.ts b/src/lib/routing.ts index dd634f1..8b65a9f 100644 --- a/src/lib/routing.ts +++ b/src/lib/routing.ts @@ -82,11 +82,17 @@ export interface TransitLeg { headsign?: string; /** Operator name (e.g. "BVG"), when the feed provides it. */ agencyName?: string; + /** Operator ticket/fare page, when the feed provides one. Often absent — + * MOTIS carries no prices, so we never invent one (see below). */ + agencyFareUrl?: string; fromName?: string; toName?: string; /** Boarding / alighting platform or track, when the feed provides it. */ fromTrack?: string; toTrack?: string; + /** Exact MOTIS stop ids — used to open the departure board for a stop. */ + fromStopId?: string; + toStopId?: string; /** Rough "N stops" cue (intermediate stops + the alighting stop). */ stops?: number; /** True when the leg carries live/realtime data (vs. a pure timetable). */ @@ -108,6 +114,28 @@ export interface TransitItinerary { legs: TransitLeg[]; } +/** Options for a transit plan (time picker + mode filter). */ +export interface TransitPlanOptions { + /** ISO departure time — or the latest arrival time when `arriveBy`. */ + time?: string; + /** When true, `time` is the latest acceptable arrival instead of departure. */ + arriveBy?: boolean; + /** MOTIS mode whitelist, e.g. `['BUS','TRAM']`. Empty = all modes. */ + transitModes?: string[]; +} + +/** One upcoming departure at a stop (the stop departure board). */ +export interface StopDeparture { + mode: string; + line?: string; + headsign?: string; + /** Feed-provided platform hint (e.g. "S-Bahnsteig Gleis 3"), if any. */ + platform?: string; + departISO?: string; + realTime?: boolean; + cancelled?: boolean; +} + /** Attribution required by Transitous / OpenStreetMap. */ export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors'; @@ -304,7 +332,7 @@ export async function fetchRoute( // ─── Public transport (Transitous / MOTIS) ─────────────────────────────────── const TRANSITOUS_PLAN = 'https://api.transitous.org/api/v1/plan'; - +const TRANSITOUS_STOPTIMES = 'https://api.transitous.org/api/v1/stoptimes'; /** * Decode an encoded polyline (Google/OSRM format) into [lat, lng] pairs. * MOTIS emits these with `precision: 7` (1e7). Standard decoder — kept local @@ -346,16 +374,26 @@ interface MotisLeg { routeShortName?: string; routeLongName?: string; agencyName?: string; + agencyFareUrl?: string; headsign?: string; realTime?: boolean; distance?: number; startTime?: string; endTime?: string; - from?: { name?: string; track?: string }; - to?: { name?: string; track?: string }; + from?: { name?: string; track?: string; stopId?: string }; + to?: { name?: string; track?: string; stopId?: string }; intermediateStops?: unknown[]; legGeometry?: { points?: string; precision?: number }; } +interface MotisStopTime { + mode?: string; + routeShortName?: string; + routeLongName?: string; + headsign?: string; + realTime?: boolean; + cancelled?: boolean; + place?: { departure?: string; cancelled?: boolean; description?: string }; +} interface MotisItinerary { startTime?: string; endTime?: string; @@ -373,14 +411,19 @@ export async function fetchTransitRoute( from: { lat: number; lng: number }, to: { lat: number; lng: number }, signal?: AbortSignal, + opts: TransitPlanOptions = {}, ): Promise { const params = new URLSearchParams({ fromPlace: `${from.lat},${from.lng}`, toPlace: `${to.lat},${to.lng}`, numItineraries: '4', // ISO in UTC (JS always emits the Gregorian calendar — unlike the shell). - time: new Date().toISOString(), + time: opts.time ?? new Date().toISOString(), }); + if (opts.arriveBy) params.set('arriveBy', 'true'); + if (opts.transitModes && opts.transitModes.length > 0) { + params.set('transitModes', opts.transitModes.join(',')); + } const res = await fetch(`${TRANSITOUS_PLAN}?${params.toString()}`, { signal }); if (!res.ok) throw new Error(`Transit service returned ${res.status}`); @@ -394,10 +437,13 @@ export async function fetchTransitRoute( line: leg.routeShortName || leg.routeLongName, headsign: leg.headsign, agencyName: leg.agencyName, + agencyFareUrl: leg.agencyFareUrl, fromName: leg.from?.name, toName: leg.to?.name, fromTrack: leg.from?.track, toTrack: leg.to?.track, + fromStopId: leg.from?.stopId, + toStopId: leg.to?.stopId, stops: Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0 ? leg.intermediateStops.length + 1 @@ -433,6 +479,36 @@ export async function fetchTransitRoute( }; } +/** + * Upcoming departures from a stop (Transitous/MOTIS `stoptimes`) — the + * departure board behind a tap on a stop in the itinerary. Throws on a non-OK + * response so the caller can show an honest "can't reach transit" state. + */ +export async function fetchStopDepartures( + stopId: string, + opts: { n?: number; time?: string } = {}, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + stopId, + n: String(opts.n ?? 8), + time: opts.time ?? new Date().toISOString(), + }); + const res = await fetch(`${TRANSITOUS_STOPTIMES}?${params.toString()}`, { signal }); + if (!res.ok) throw new Error(`Transit service returned ${res.status}`); + + const data = (await res.json()) as { stopTimes?: MotisStopTime[] }; + return (data.stopTimes ?? []).map((s) => ({ + mode: (s.mode ?? '').toUpperCase(), + line: s.routeShortName || s.routeLongName, + headsign: s.headsign, + platform: s.place?.description, + departISO: s.place?.departure, + realTime: s.realTime === true, + cancelled: s.cancelled === true || s.place?.cancelled === true, + })); +} + /** Format an ISO timestamp as a local clock time, e.g. "08:39". */ export function formatClock(iso: string): string { const d = new Date(iso); diff --git a/src/lib/transitUi.ts b/src/lib/transitUi.ts new file mode 100644 index 0000000..1dbb88b --- /dev/null +++ b/src/lib/transitUi.ts @@ -0,0 +1,95 @@ +/** + * Transit presentation helpers + * ───────────────────────────────────────────────────────────────────────────── + * Shared by RouteDialog (the itinerary) and StopDepartures (the departure + * board), so the mode → icon/colour vocabulary lives in exactly one place. + * Modes are MOTIS's own `mode` values. + */ + +import type { TransitItinerary, TransitLeg } from './routing'; + +/** Line colour for a transit leg, keyed by the MOTIS mode. */ +export function transitLegColor(mode: string): string { + switch (mode) { + case 'BUS': + case 'COACH': return '#22c55e'; + case 'TRAM': return '#eab308'; + case 'SUBWAY': + case 'METRO': return '#3b82f6'; + case 'RAIL': + case 'REGIONAL_RAIL': + case 'REGIONAL_FAST_RAIL': + case 'LONG_DISTANCE': + case 'HIGHSPEED_RAIL': + case 'NIGHT_RAIL': + case 'SUBURBAN': return '#ef4444'; + case 'FERRY': return '#06b6d4'; + default: return '#ccff00'; + } +} + +/** Emoji for a transit leg's mode. */ +export function transitModeEmoji(mode: string): string { + switch (mode) { + case 'BUS': + case 'COACH': return '🚌'; + case 'TRAM': return '🚊'; + case 'SUBWAY': + case 'METRO': return '🚇'; + case 'RAIL': + case 'REGIONAL_RAIL': + case 'REGIONAL_FAST_RAIL': + case 'LONG_DISTANCE': + case 'HIGHSPEED_RAIL': + case 'NIGHT_RAIL': + case 'SUBURBAN': return '🚆'; + case 'FERRY': return '⛴️'; + case 'WALK': return '🚶'; + case 'BIKE': return '🚲'; + default: return '🚇'; + } +} + +/** Leading emoji for a whole itinerary — its first vehicle leg, else walking. */ +export function transitPrimaryEmoji(it: TransitItinerary): string { + const vehicle = it.legs.find((l) => l.mode !== 'WALK' && l.mode !== 'BIKE'); + return transitModeEmoji(vehicle?.mode ?? 'WALK'); +} + +/** Seconds between a leg's start and end (0 when either time is missing). */ +export function legDurationS(leg: TransitLeg): number { + if (!leg.startISO || !leg.endISO) return 0; + const ms = Date.parse(leg.endISO) - Date.parse(leg.startISO); + return Number.isFinite(ms) && ms > 0 ? Math.round(ms / 1000) : 0; +} + +/** + * The transit mode picker's groups, each mapping to MOTIS `transitModes` + * values. ⚠️ MOTIS classifies a German S-Bahn as `METRO` (verified live), so it + * lands under "Metro", not "Train" — that is the feed's own classification. + */ +export const TRANSIT_MODE_GROUPS: ReadonlyArray<{ + key: string; + label: string; + emoji: string; + modes: readonly string[]; +}> = [ + { key: 'bus', label: 'Bus', emoji: '🚌', modes: ['BUS', 'COACH'] }, + { key: 'tram', label: 'Tram', emoji: '🚊', modes: ['TRAM'] }, + { key: 'metro', label: 'Metro', emoji: '🚇', modes: ['SUBWAY', 'METRO'] }, + { + key: 'train', + label: 'Train', + emoji: '🚆', + modes: [ + 'RAIL', + 'REGIONAL_RAIL', + 'REGIONAL_FAST_RAIL', + 'HIGHSPEED_RAIL', + 'LONG_DISTANCE', + 'NIGHT_RAIL', + 'SUBURBAN', + ], + }, + { key: 'ferry', label: 'Ferry', emoji: '⛴️', modes: ['FERRY'] }, +];