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 (
+ <>