diff --git a/AGENTS.md b/AGENTS.md
index e56d1ef..7fca9a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -338,6 +338,22 @@ session with **no other memory of this project**. So:
**< 2 min old**; the blocking "Finding your location…" overlay shows only when
there is no seed (with a seed it refines silently via a corner chip). The map
"Near Me" button also flips the sort to `proximity`.
+ - **Transit detail (2026-10-01):** Transitous returns **more itineraries than
+ requested** (`numItineraries: 4` → we saw 9), so `fetchTransitRoute` now maps
+ them ALL into `RouteResult.transitOptions` (`transit` = index 0). `RouteDialog`
+ shows alternatives (tap to switch — a separate effect redraws the map, **no
+ re-query**) and a **leg-by-leg itinerary**: coloured line badge, headsign
+ (`→ S Strausberg Nord`), board/alight stop + clock times, platform `track`
+ when present, `N stops` (`intermediateStops.length + 1`), operator
+ (`agencyName`), and a **live** badge only when `realTime` is true. Mode →
+ icon/colour is MOTIS's own `mode` (`BUS` 🚌, `TRAM` 🚊, `METRO`/`SUBWAY` 🚇,
+ `RAIL*`/`REGIONAL_RAIL`… 🚆, `FERRY` ⛴️). ⚠️ MOTIS labels a German S-Bahn
+ `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.
- ⚠️ **`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/CoordinatorSettingsCard.tsx b/src/components/CoordinatorSettingsCard.tsx
index 80ca7bc..7df1e62 100644
--- a/src/components/CoordinatorSettingsCard.tsx
+++ b/src/components/CoordinatorSettingsCard.tsx
@@ -1,18 +1,14 @@
import { useEffect, useState } from 'react';
import {
- Activity,
AlertTriangle,
- CheckCircle2,
Copy,
ExternalLink,
- Loader2,
QrCode,
RefreshCw,
ScanLine,
Server,
Smartphone,
Trash2,
- XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -102,16 +98,6 @@ export function CoordinatorSettingsCard() {
};
const meta = STATUS_META[status];
- const StatusIcon =
- status === 'checking'
- ? Loader2
- : status === 'online'
- ? CheckCircle2
- : status === 'stale'
- ? AlertTriangle
- : status === 'idle'
- ? Activity
- : XCircle;
return (
diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx
index eb433ee..e727d1f 100644
--- a/src/components/RouteDialog.tsx
+++ b/src/components/RouteDialog.tsx
@@ -35,6 +35,7 @@ import {
skateTime,
type TravelMode,
type RouteResult,
+ type TransitItinerary,
} from '@/lib/routing';
// ─── Transit presentation helpers ────────────────────────────────────────────
@@ -79,6 +80,19 @@ function transitModeEmoji(mode: string): string {
}
}
+/** 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;
+}
+
interface RouteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -228,12 +242,29 @@ export function RouteDialog({
const [error, setError] = useState(null);
const [locError, setLocError] = useState(null);
+ // Which transit alternative is selected (index into route.transitOptions).
+ // Reset to 0 whenever a new transit route is fetched.
+ const [transitIndex, setTransitIndex] = useState(0);
+ const selectedTransit = useMemo(() => {
+ if (mode !== 'transit') return null;
+ const opts = route?.transitOptions;
+ if (opts && opts.length > 0) return opts[Math.min(transitIndex, opts.length - 1)] ?? null;
+ return route?.transit ?? null;
+ }, [mode, route, transitIndex]);
+
+ // All alternatives (falling back to a single option for older results).
+ const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []);
+
/** Re-fit the map to the whole route (after tapping a step pans it away). */
const fitRoute = useCallback(() => {
const map = mapRef.current;
- if (!map || !route || route.coordinates.length < 2) return;
- map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 });
- }, [route]);
+ if (!map) return;
+ const coords = selectedTransit
+ ? selectedTransit.legs.flatMap((l) => l.coordinates)
+ : route?.coordinates;
+ if (!coords || coords.length < 2) return;
+ map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16 });
+ }, [route, selectedTransit]);
// A new route (mode/origin change) invalidates the tapped-step focus and the
// once-only voice/haptic announcements.
@@ -611,6 +642,7 @@ export function RouteDialog({
if (cancelled) return;
setCachedAge(null);
setRoute(result);
+ setTransitIndex(0);
if (mode !== 'transit') saveRoute(dest, mode, result);
const map = mapRef.current;
@@ -619,24 +651,10 @@ export function RouteDialog({
routeLayersRef.current.remove();
routeLayersRef.current = null;
}
- const group = L.layerGroup().addTo(map);
-
- if (result.transit) {
- // Public transport: draw each leg — walking dashed grey, transit
- // legs solid and coloured by mode.
- for (const leg of result.transit.legs) {
- if (leg.coordinates.length < 2) continue;
- const isWalk = leg.mode === 'WALK';
- L.polyline(leg.coordinates, {
- color: isWalk ? '#9ca3af' : transitLegColor(leg.mode),
- weight: isWalk ? 3 : 5,
- opacity: isWalk ? 0.7 : 0.9,
- dashArray: isWalk ? '4 6' : undefined,
- lineJoin: 'round',
- lineCap: 'round',
- }).addTo(group);
- }
- } else {
+ // Transit legs are drawn by the effect just below, so switching
+ // between alternatives doesn't re-query the transit service.
+ if (!result.transit) {
+ const group = L.layerGroup().addTo(map);
L.polyline(result.coordinates, {
color: '#ccff00',
weight: 5,
@@ -644,11 +662,10 @@ export function RouteDialog({
lineJoin: 'round',
lineCap: 'round',
}).addTo(group);
- }
-
- routeLayersRef.current = group;
- if (result.coordinates.length > 0) {
- map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
+ routeLayersRef.current = group;
+ if (result.coordinates.length > 0) {
+ map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
+ }
}
}
} catch (err) {
@@ -705,6 +722,35 @@ export function RouteDialog({
};
}, [open, effectiveOrigin, dest, mode]);
+ // ─── Draw the selected transit itinerary (redraws when an alternative is
+ // chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map || !selectedTransit) return;
+ if (routeLayersRef.current) {
+ routeLayersRef.current.remove();
+ routeLayersRef.current = null;
+ }
+ const group = L.layerGroup().addTo(map);
+ for (const leg of selectedTransit.legs) {
+ if (leg.coordinates.length < 2) continue;
+ const isWalk = leg.mode === 'WALK';
+ L.polyline(leg.coordinates, {
+ color: isWalk ? '#9ca3af' : transitLegColor(leg.mode),
+ weight: isWalk ? 3 : 5,
+ opacity: isWalk ? 0.7 : 0.9,
+ dashArray: isWalk ? '4 6' : undefined,
+ lineJoin: 'round',
+ lineCap: 'round',
+ }).addTo(group);
+ }
+ routeLayersRef.current = group;
+ const coords = selectedTransit.legs.flatMap((l) => l.coordinates);
+ if (coords.length > 0) {
+ map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16, animate: false });
+ }
+ }, [selectedTransit]);
+
return (