feat(transit): real itineraries — alternatives, leg-by-leg, mode badges

Transit showed one cryptic summary line and threw away 3 of 4 itineraries.
Surface what the MOTIS/Transitous response already contains.

routing.ts: TransitLeg gains headsign, agencyName, from/to track, stop count,
realTime and distance; RouteResult gains transitOptions (all itineraries,
fastest first). fetchTransitRoute maps every itinerary (the API returns more
than numItineraries).

RouteDialog:
- Alternatives row — tap to switch; a dedicated effect redraws the map from the
  selected itinerary, so switching never re-queries the service.
- Leg-by-leg itinerary: coloured line badge, headsign, board/alight stop + clock
  times, platform track, N stops, operator, and a 'live' badge only when the
  feed is realtime.
- Summary now reflects the selected option and shows the option count.
- 'Whole route' re-fit follows the selection.

Also clears the 2 pre-existing eslint warnings (unused StatusIcon/nip19) so the
documented zero-warning baseline holds.

Docs: AGENTS.md transit-detail note (incl. Transitous 403s a generic UA).
This commit is contained in:
SkateSpots 2026-10-01 10:58:27 +07:00
parent b6cc7797e6
commit 52ed2f89bd
5 changed files with 274 additions and 74 deletions

View file

@ -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 **< 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 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`. "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:** - ⚠️ **`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 (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, component's first effect pass, so a `mapDivRef.current` guard was null,

View file

@ -1,18 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Activity,
AlertTriangle, AlertTriangle,
CheckCircle2,
Copy, Copy,
ExternalLink, ExternalLink,
Loader2,
QrCode, QrCode,
RefreshCw, RefreshCw,
ScanLine, ScanLine,
Server, Server,
Smartphone, Smartphone,
Trash2, Trash2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -102,16 +98,6 @@ export function CoordinatorSettingsCard() {
}; };
const meta = STATUS_META[status]; const meta = STATUS_META[status];
const StatusIcon =
status === 'checking'
? Loader2
: status === 'online'
? CheckCircle2
: status === 'stale'
? AlertTriangle
: status === 'idle'
? Activity
: XCircle;
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View file

@ -35,6 +35,7 @@ import {
skateTime, skateTime,
type TravelMode, type TravelMode,
type RouteResult, type RouteResult,
type TransitItinerary,
} from '@/lib/routing'; } from '@/lib/routing';
// ─── Transit presentation helpers ──────────────────────────────────────────── // ─── 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 { interface RouteDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -228,12 +242,29 @@ export function RouteDialog({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [locError, setLocError] = useState<string | null>(null); const [locError, setLocError] = useState<string | null>(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). */ /** Re-fit the map to the whole route (after tapping a step pans it away). */
const fitRoute = useCallback(() => { const fitRoute = useCallback(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || !route || route.coordinates.length < 2) return; if (!map) return;
map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 }); const coords = selectedTransit
}, [route]); ? 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 // A new route (mode/origin change) invalidates the tapped-step focus and the
// once-only voice/haptic announcements. // once-only voice/haptic announcements.
@ -611,6 +642,7 @@ export function RouteDialog({
if (cancelled) return; if (cancelled) return;
setCachedAge(null); setCachedAge(null);
setRoute(result); setRoute(result);
setTransitIndex(0);
if (mode !== 'transit') saveRoute(dest, mode, result); if (mode !== 'transit') saveRoute(dest, mode, result);
const map = mapRef.current; const map = mapRef.current;
@ -619,24 +651,10 @@ export function RouteDialog({
routeLayersRef.current.remove(); routeLayersRef.current.remove();
routeLayersRef.current = null; routeLayersRef.current = null;
} }
// 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); 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 {
L.polyline(result.coordinates, { L.polyline(result.coordinates, {
color: '#ccff00', color: '#ccff00',
weight: 5, weight: 5,
@ -644,13 +662,12 @@ export function RouteDialog({
lineJoin: 'round', lineJoin: 'round',
lineCap: 'round', lineCap: 'round',
}).addTo(group); }).addTo(group);
}
routeLayersRef.current = group; routeLayersRef.current = group;
if (result.coordinates.length > 0) { if (result.coordinates.length > 0) {
map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false }); map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
} }
} }
}
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
if (err instanceof DOMException && err.name === 'AbortError') return; if (err instanceof DOMException && err.name === 'AbortError') return;
@ -705,6 +722,35 @@ export function RouteDialog({
}; };
}, [open, effectiveOrigin, dest, mode]); }, [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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
{/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM {/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM
@ -921,26 +967,27 @@ export function RouteDialog({
<AlertTriangle className="h-4 w-4 shrink-0" /> <AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span> <span>{error}</span>
</div> </div>
) : route?.transit ? ( ) : mode === 'transit' && selectedTransit ? (
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-2xl leading-none">🚇</div> <div className="text-2xl leading-none">{transitPrimaryEmoji(selectedTransit)}</div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-lg font-black tabular-nums"> <div className="text-lg font-black tabular-nums">
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)} {formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{formatDuration(route.transit.durationS)} ·{' '} {formatDuration(selectedTransit.durationS)} ·{' '}
{route.transit.transfers === 0 {selectedTransit.transfers === 0
? 'direct' ? 'direct'
: `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`} : `${selectedTransit.transfers} transfer${selectedTransit.transfers > 1 ? 's' : ''}`}
{options.length > 1 && ` · ${options.length} options`}
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />} {routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
</div> </div>
</div> </div>
</div> </div>
{route.transit.legs.some((l) => l.mode !== 'WALK') && ( {selectedTransit.legs.some((l) => l.mode !== 'WALK') && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{route.transit.legs {selectedTransit.legs
.filter((l) => l.mode !== 'WALK') .filter((l) => l.mode !== 'WALK')
.map((l, i) => ( .map((l, i) => (
<span <span
@ -1062,6 +1109,120 @@ export function RouteDialog({
</div> </div>
)} )}
{/* Transit itinerary: alternatives + a real leg-by-leg breakdown — all
from the same MOTIS response (nothing extra is fetched). */}
{!following && mode === 'transit' && selectedTransit && (
<div className="border-t bg-muted/20">
{options.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
{options.map((opt, i) => {
const active = i === transitIndex;
const modes = [...new Set(
opt.legs
.filter((l) => l.mode !== 'WALK' && l.mode !== 'BIKE')
.map((l) => transitModeEmoji(l.mode)),
)];
return (
<button
key={`${opt.departISO}-${i}`}
type="button"
onClick={() => setTransitIndex(i)}
className={cn(
// `min-h-0` beats the app-wide 44px mobile touch-target rule.
'flex min-h-0 shrink-0 flex-col items-start gap-0.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors',
active
? 'border-[#ccff00]/60 bg-[#ccff00]/10'
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
)}
>
<span className="text-xs font-black tabular-nums text-white">
{formatClock(opt.departISO)}–{formatClock(opt.arriveISO)}
</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span aria-hidden>{modes.join('')}</span>
{formatDuration(opt.durationS)} ·{' '}
{opt.transfers === 0
? 'direct'
: `${opt.transfers} transfer${opt.transfers > 1 ? 's' : ''}`}
</span>
</button>
);
})}
</div>
)}
<p className="flex items-center gap-2 px-4 pt-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Itinerary
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{selectedTransit.legs.length}
</span>
</p>
<ol className="max-h-72 space-y-1 overflow-y-auto px-4 pb-2 pt-1.5">
{selectedTransit.legs.map((leg, i) => {
const isFoot = leg.mode === 'WALK' || leg.mode === 'BIKE';
const dur = legDurationS(leg);
const startClock = leg.startISO ? formatClock(leg.startISO) : '—';
const endClock = leg.endISO ? formatClock(leg.endISO) : '—';
return (
<li
key={`${leg.mode}-${leg.line}-${i}`}
className="flex min-h-0 items-start gap-2 rounded-lg px-2 py-1.5"
>
<span className="mt-px w-5 shrink-0 text-center text-base leading-none" aria-hidden>
{transitModeEmoji(leg.mode)}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
{!isFoot && (
<span
className="inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-black text-black"
style={{ background: transitLegColor(leg.mode) }}
>
{leg.line ?? leg.mode}
</span>
)}
<span className="text-xs font-bold text-white">
{isFoot
? leg.mode === 'BIKE' ? 'Ride' : 'Walk'
: leg.headsign
? `→ ${leg.headsign}`
: (leg.line ?? leg.mode)}
</span>
{dur > 0 && (
<span className="text-[10px] text-muted-foreground">{formatDuration(dur)}</span>
)}
{leg.realTime && (
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
live
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground">
{isFoot ? (
<>
{leg.toName ? `to ${leg.toName}` : leg.mode === 'BIKE' ? 'ride' : 'walk'}
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
</>
) : (
<>
{leg.fromName}{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
{' → '}
{leg.toName}{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
{typeof leg.stops === 'number' && leg.stops > 0
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
: ''}
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
</>
)}
</div>
</div>
</li>
);
})}
</ol>
</div>
)}
</div>{/* /scrollable details */} </div>{/* /scrollable details */}
{/* Follow mode: live turns + voice, with the screen kept awake. */} {/* Follow mode: live turns + voice, with the screen kept awake. */}

View file

@ -1,5 +1,4 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react'; import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';

View file

@ -63,8 +63,13 @@ export interface RouteResult {
* distance/time are a driving approximation for the chosen mode. * distance/time are a driving approximation for the chosen mode.
*/ */
approximate?: boolean; approximate?: boolean;
/** Present only for transit routes — the chosen public-transport itinerary. */ /** Present only for transit routes — the selected public-transport itinerary. */
transit?: TransitItinerary; transit?: TransitItinerary;
/**
* Present only for transit routes — every planned option, fastest first.
* `transit` duplicates index 0. The dialog lets the user pick another.
*/
transitOptions?: TransitItinerary[];
} }
/** One leg of a public-transport itinerary (walk, bus, subway, rail, …). */ /** One leg of a public-transport itinerary (walk, bus, subway, rail, …). */
@ -73,8 +78,21 @@ export interface TransitLeg {
mode: string; mode: string;
/** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */ /** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */
line?: string; line?: string;
/** Headsign / direction of travel, when the feed provides it. */
headsign?: string;
/** Operator name (e.g. "BVG"), when the feed provides it. */
agencyName?: string;
fromName?: string; fromName?: string;
toName?: string; toName?: string;
/** Boarding / alighting platform or track, when the feed provides it. */
fromTrack?: string;
toTrack?: string;
/** Rough "N stops" cue (intermediate stops + the alighting stop). */
stops?: number;
/** True when the leg carries live/realtime data (vs. a pure timetable). */
realTime?: boolean;
/** Length of this leg in metres. */
distanceM?: number;
startISO?: string; startISO?: string;
endISO?: string; endISO?: string;
/** Decoded [lat, lng] path for this leg. */ /** Decoded [lat, lng] path for this leg. */
@ -327,11 +345,15 @@ interface MotisLeg {
mode?: string; mode?: string;
routeShortName?: string; routeShortName?: string;
routeLongName?: string; routeLongName?: string;
agencyName?: string;
headsign?: string;
realTime?: boolean;
distance?: number; distance?: number;
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
from?: { name?: string }; from?: { name?: string; track?: string };
to?: { name?: string }; to?: { name?: string; track?: string };
intermediateStops?: unknown[];
legGeometry?: { points?: string; precision?: number }; legGeometry?: { points?: string; precision?: number };
} }
interface MotisItinerary { interface MotisItinerary {
@ -364,14 +386,24 @@ export async function fetchTransitRoute(
const data = (await res.json()) as { itineraries?: MotisItinerary[] }; const data = (await res.json()) as { itineraries?: MotisItinerary[] };
const itineraries = Array.isArray(data.itineraries) ? data.itineraries : []; const itineraries = Array.isArray(data.itineraries) ? data.itineraries : [];
const it = itineraries[0]; if (itineraries.length === 0) throw new Error('No transit route found');
if (!it) throw new Error('No transit route found');
const mapItinerary = (it: MotisItinerary): TransitItinerary => {
const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({ const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({
mode: (leg.mode ?? '').toUpperCase(), mode: (leg.mode ?? '').toUpperCase(),
line: leg.routeShortName || leg.routeLongName, line: leg.routeShortName || leg.routeLongName,
headsign: leg.headsign,
agencyName: leg.agencyName,
fromName: leg.from?.name, fromName: leg.from?.name,
toName: leg.to?.name, toName: leg.to?.name,
fromTrack: leg.from?.track,
toTrack: leg.to?.track,
stops:
Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0
? leg.intermediateStops.length + 1
: undefined,
realTime: leg.realTime === true,
distanceM: leg.distance,
startISO: leg.startTime, startISO: leg.startTime,
endISO: leg.endTime, endISO: leg.endTime,
coordinates: coordinates:
@ -379,19 +411,25 @@ export async function fetchTransitRoute(
? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7) ? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7)
: [], : [],
})); }));
return { return {
coordinates: legs.flatMap((l) => l.coordinates),
distanceM: (it.legs ?? []).reduce((sum, l) => sum + (l.distance ?? 0), 0),
durationS: it.duration ?? 0,
mode: 'transit',
transit: {
departISO: it.startTime ?? '', departISO: it.startTime ?? '',
arriveISO: it.endTime ?? '', arriveISO: it.endTime ?? '',
durationS: it.duration ?? 0, durationS: it.duration ?? 0,
transfers: it.transfers ?? 0, transfers: it.transfers ?? 0,
legs, legs,
}, };
};
const options = itineraries.map(mapItinerary);
const first = options[0];
return {
coordinates: first.legs.flatMap((l) => l.coordinates),
distanceM: (itineraries[0].legs ?? []).reduce((sum, l) => sum + (l.distance ?? 0), 0),
durationS: first.durationS,
mode: 'transit',
transit: first,
transitOptions: options,
}; };
} }