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 ( {/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM @@ -921,26 +967,27 @@ export function RouteDialog({ {error}
- ) : route?.transit ? ( + ) : mode === 'transit' && selectedTransit ? (
-
🚇
+
{transitPrimaryEmoji(selectedTransit)}
- {formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)} + {formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
- {formatDuration(route.transit.durationS)} ·{' '} - {route.transit.transfers === 0 + {formatDuration(selectedTransit.durationS)} ·{' '} + {selectedTransit.transfers === 0 ? 'direct' - : `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`} + : `${selectedTransit.transfers} transfer${selectedTransit.transfers > 1 ? 's' : ''}`} + {options.length > 1 && ` · ${options.length} options`} {routing && }
- {route.transit.legs.some((l) => l.mode !== 'WALK') && ( + {selectedTransit.legs.some((l) => l.mode !== 'WALK') && (
- {route.transit.legs + {selectedTransit.legs .filter((l) => l.mode !== 'WALK') .map((l, i) => ( )} + {/* Transit itinerary: alternatives + a real leg-by-leg breakdown — all + from the same MOTIS response (nothing extra is fetched). */} + {!following && mode === 'transit' && selectedTransit && ( +
+ {options.length > 1 && ( +
+ {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 ( + + ); + })} +
+ )} + +

+ Itinerary + + {selectedTransit.legs.length} + +

+
    + {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 ( +
  1. + + {transitModeEmoji(leg.mode)} + +
    +
    + {!isFoot && ( + + {leg.line ?? leg.mode} + + )} + + {isFoot + ? leg.mode === 'BIKE' ? 'Ride' : 'Walk' + : leg.headsign + ? `→ ${leg.headsign}` + : (leg.line ?? leg.mode)} + + {dur > 0 && ( + {formatDuration(dur)} + )} + {leg.realTime && ( + + live + + )} +
    +
    + {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}` : ''} + + )} +
    +
    +
  2. + ); + })} +
+
+ )} +
{/* /scrollable details */} {/* Follow mode: live turns + voice, with the screen kept awake. */} diff --git a/src/components/TradeNotificationCard.tsx b/src/components/TradeNotificationCard.tsx index 51e87fa..e1f4409 100644 --- a/src/components/TradeNotificationCard.tsx +++ b/src/components/TradeNotificationCard.tsx @@ -1,5 +1,4 @@ import { Link } from 'react-router-dom'; -import { nip19 } from 'nostr-tools'; import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useAuthor } from '@/hooks/useAuthor'; diff --git a/src/lib/routing.ts b/src/lib/routing.ts index 3f0758b..dd634f1 100644 --- a/src/lib/routing.ts +++ b/src/lib/routing.ts @@ -63,8 +63,13 @@ export interface RouteResult { * distance/time are a driving approximation for the chosen mode. */ approximate?: boolean; - /** Present only for transit routes — the chosen public-transport itinerary. */ + /** Present only for transit routes — the selected public-transport itinerary. */ 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, …). */ @@ -73,8 +78,21 @@ export interface TransitLeg { mode: string; /** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */ 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; 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; endISO?: string; /** Decoded [lat, lng] path for this leg. */ @@ -327,11 +345,15 @@ interface MotisLeg { mode?: string; routeShortName?: string; routeLongName?: string; + agencyName?: string; + headsign?: string; + realTime?: boolean; distance?: number; startTime?: string; endTime?: string; - from?: { name?: string }; - to?: { name?: string }; + from?: { name?: string; track?: string }; + to?: { name?: string; track?: string }; + intermediateStops?: unknown[]; legGeometry?: { points?: string; precision?: number }; } interface MotisItinerary { @@ -364,34 +386,50 @@ export async function fetchTransitRoute( const data = (await res.json()) as { itineraries?: MotisItinerary[] }; const itineraries = Array.isArray(data.itineraries) ? data.itineraries : []; - const it = itineraries[0]; - if (!it) throw new Error('No transit route found'); + if (itineraries.length === 0) throw new Error('No transit route found'); - const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({ - mode: (leg.mode ?? '').toUpperCase(), - line: leg.routeShortName || leg.routeLongName, - fromName: leg.from?.name, - toName: leg.to?.name, - startISO: leg.startTime, - endISO: leg.endTime, - coordinates: - leg.legGeometry?.points - ? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7) - : [], - })); - - 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: { + const mapItinerary = (it: MotisItinerary): TransitItinerary => { + const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({ + mode: (leg.mode ?? '').toUpperCase(), + line: leg.routeShortName || leg.routeLongName, + headsign: leg.headsign, + agencyName: leg.agencyName, + fromName: leg.from?.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, + endISO: leg.endTime, + coordinates: + leg.legGeometry?.points + ? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7) + : [], + })); + return { departISO: it.startTime ?? '', arriveISO: it.endTime ?? '', durationS: it.duration ?? 0, transfers: it.transfers ?? 0, 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, }; }