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.
This commit is contained in:
parent
126eb38330
commit
5fff51c814
5 changed files with 503 additions and 67 deletions
20
AGENTS.md
20
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
|
`METRO` (verified live) — that's the feed's own classification; don't
|
||||||
"correct" it heuristically. ⚠️ Transitous **403s a generic User-Agent**
|
"correct" it heuristically. ⚠️ Transitous **403s a generic User-Agent**
|
||||||
("Generic user-agent headers are not allowed") — the browser sends its own so
|
("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:
|
the app is fine, but a Node/curl probe must set a `User-Agent`.
|
||||||
depart/arrive-by time picker, per-mode filters (bus/train), stop departure
|
**Time picker + mode filter + stop boards (2026-10-01):** `fetchTransitRoute`
|
||||||
boards, fares.
|
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:**
|
- ⚠️ **`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,
|
||||||
|
|
|
||||||
|
|
@ -35,62 +35,37 @@ import {
|
||||||
skateTime,
|
skateTime,
|
||||||
type TravelMode,
|
type TravelMode,
|
||||||
type RouteResult,
|
type RouteResult,
|
||||||
type TransitItinerary,
|
type TransitPlanOptions,
|
||||||
} from '@/lib/routing';
|
} from '@/lib/routing';
|
||||||
|
import {
|
||||||
|
TRANSIT_MODE_GROUPS,
|
||||||
|
legDurationS,
|
||||||
|
transitLegColor,
|
||||||
|
transitModeEmoji,
|
||||||
|
transitPrimaryEmoji,
|
||||||
|
} from '@/lib/transitUi';
|
||||||
|
import { StopDepartures } from '@/components/StopDepartures';
|
||||||
|
|
||||||
// ─── Transit presentation helpers ────────────────────────────────────────────
|
/** A stop name that opens the departure board, when the feed gave a stop id. */
|
||||||
|
function StopButton({
|
||||||
/** Line colour for a transit leg, keyed by the MOTIS mode. */
|
id,
|
||||||
function transitLegColor(mode: string): string {
|
name,
|
||||||
switch (mode) {
|
onOpen,
|
||||||
case 'BUS': return '#22c55e';
|
}: {
|
||||||
case 'TRAM': return '#eab308';
|
id?: string;
|
||||||
case 'SUBWAY':
|
name?: string;
|
||||||
case 'METRO': return '#3b82f6';
|
onOpen: (id: string, name: string) => void;
|
||||||
case 'RAIL':
|
}) {
|
||||||
case 'REGIONAL_RAIL':
|
if (!id || !name) return <>{name ?? ''}</>;
|
||||||
case 'REGIONAL_FAST_RAIL':
|
return (
|
||||||
case 'LONG_DISTANCE':
|
<button
|
||||||
case 'HIGHSPEED_RAIL':
|
type="button"
|
||||||
case 'NIGHT_RAIL':
|
onClick={() => onOpen(id, name)}
|
||||||
case 'SUBURBAN': return '#ef4444';
|
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground"
|
||||||
case 'FERRY': return '#06b6d4';
|
>
|
||||||
default: return '#ccff00';
|
{name}
|
||||||
}
|
</button>
|
||||||
}
|
);
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteDialogProps {
|
interface RouteDialogProps {
|
||||||
|
|
@ -255,6 +230,21 @@ export function RouteDialog({
|
||||||
// All alternatives (falling back to a single option for older results).
|
// All alternatives (falling back to a single option for older results).
|
||||||
const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []);
|
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<string[]>([]);
|
||||||
|
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). */
|
/** 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;
|
||||||
|
|
@ -636,8 +626,19 @@ export function RouteDialog({
|
||||||
setRouting(true);
|
setRouting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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'
|
const result = mode === 'transit'
|
||||||
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal)
|
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal, planOpts)
|
||||||
: await fetchRoute(effectiveOrigin, dest, mode, controller.signal);
|
: await fetchRoute(effectiveOrigin, dest, mode, controller.signal);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCachedAge(null);
|
setCachedAge(null);
|
||||||
|
|
@ -675,7 +676,9 @@ export function RouteDialog({
|
||||||
if (mode === 'transit') {
|
if (mode === 'transit') {
|
||||||
setError(
|
setError(
|
||||||
msg === 'No transit route found'
|
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.",
|
: "Couldn't reach the transit service. Check your connection and try again.",
|
||||||
);
|
);
|
||||||
} else if (msg === ROUTE_ERR_BUSY) {
|
} else if (msg === ROUTE_ERR_BUSY) {
|
||||||
|
|
@ -720,7 +723,7 @@ export function RouteDialog({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [open, effectiveOrigin, dest, mode]);
|
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes]);
|
||||||
|
|
||||||
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
||||||
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
||||||
|
|
@ -752,6 +755,7 @@ export function RouteDialog({
|
||||||
}, [selectedTransit]);
|
}, [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
|
||||||
order: the map sits directly under the header (bigger — it's the point
|
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). */}
|
from the same MOTIS response (nothing extra is fetched). */}
|
||||||
{!following && mode === 'transit' && selectedTransit && (
|
{!following && mode === 'transit' && selectedTransit && (
|
||||||
<div className="border-t bg-muted/20">
|
<div className="border-t bg-muted/20">
|
||||||
|
{/* When: leave now · depart at · arrive by */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-3">
|
||||||
|
{(['now', 'depart', 'arrive'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTransitTimeMode(m)}
|
||||||
|
aria-pressed={transitTimeMode === m}
|
||||||
|
className={cn(
|
||||||
|
'min-h-0 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
transitTimeMode === m
|
||||||
|
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
|
||||||
|
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m === 'now' ? 'Leave now' : m === 'depart' ? 'Depart at' : 'Arrive by'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{transitTimeMode !== 'now' && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={transitTimeValue}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only: filter the modes (nothing selected = all) */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Only</span>
|
||||||
|
{TRANSIT_MODE_GROUPS.map((g) => {
|
||||||
|
const on = transitModeFilter.includes(g.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleModeGroup(g.key)}
|
||||||
|
aria-pressed={on}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold transition-colors',
|
||||||
|
on
|
||||||
|
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
|
||||||
|
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{g.emoji}</span>
|
||||||
|
{g.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{transitModeFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTransitModeFilter([])}
|
||||||
|
className="min-h-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-muted-foreground underline decoration-dotted hover:text-foreground"
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{options.length > 1 && (
|
{options.length > 1 && (
|
||||||
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
|
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
|
||||||
{options.map((opt, i) => {
|
{options.map((opt, i) => {
|
||||||
|
|
@ -1200,18 +1267,50 @@ export function RouteDialog({
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{isFoot ? (
|
{isFoot ? (
|
||||||
<>
|
<>
|
||||||
{leg.toName ? `to ${leg.toName}` : leg.mode === 'BIKE' ? 'ride' : 'walk'}
|
{leg.toName ? (
|
||||||
|
<>
|
||||||
|
to{' '}
|
||||||
|
<StopButton
|
||||||
|
id={leg.toStopId}
|
||||||
|
name={leg.toName}
|
||||||
|
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : leg.mode === 'BIKE' ? 'ride' : 'walk'}
|
||||||
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{leg.fromName}{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
<StopButton
|
||||||
|
id={leg.fromStopId}
|
||||||
|
name={leg.fromName}
|
||||||
|
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||||
|
/>
|
||||||
|
{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
||||||
{' → '}
|
{' → '}
|
||||||
{leg.toName}{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
|
<StopButton
|
||||||
|
id={leg.toStopId}
|
||||||
|
name={leg.toName}
|
||||||
|
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||||
|
/>
|
||||||
|
{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
|
||||||
{typeof leg.stops === 'number' && leg.stops > 0
|
{typeof leg.stops === 'number' && leg.stops > 0
|
||||||
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
|
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
|
||||||
: ''}
|
: ''}
|
||||||
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
|
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
|
||||||
|
{leg.agencyFareUrl ? (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<a
|
||||||
|
href={leg.agencyFareUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
||||||
|
>
|
||||||
|
tickets ↗
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : ''}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1285,5 +1384,15 @@ export function RouteDialog({
|
||||||
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Departure board — its own dialog, kept OUTSIDE the route dialog so the
|
||||||
|
two focus traps don't fight. */}
|
||||||
|
<StopDepartures
|
||||||
|
open={!!stopBoard}
|
||||||
|
onOpenChange={(o) => { if (!o) setStopBoard(null); }}
|
||||||
|
stopId={stopBoard?.id ?? null}
|
||||||
|
stopName={stopBoard?.name ?? ''}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/components/StopDepartures.tsx
Normal file
142
src/components/StopDepartures.tsx
Normal file
|
|
@ -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<StopDeparture[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[80dvh] w-[calc(100%-2rem)] max-w-md flex-col gap-0 overflow-hidden p-0">
|
||||||
|
<DialogHeader className="border-b px-4 py-3">
|
||||||
|
<DialogTitle className="text-base">
|
||||||
|
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Departures
|
||||||
|
</span>
|
||||||
|
<span className="block truncate">{stopName}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Upcoming departures from {stopName}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<div className="p-4 text-sm text-amber-500">{error}</div>
|
||||||
|
) : !rows ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading departures…
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No upcoming departures found at this stop.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{rows.map((d, i) => (
|
||||||
|
<li key={`${d.line}-${d.departISO}-${i}`} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||||
|
<span className="w-12 shrink-0 text-right text-sm font-black tabular-nums text-white">
|
||||||
|
{d.departISO ? formatClock(d.departISO) : '—'}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="text-lg leading-none">{transitModeEmoji(d.mode)}</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[11px] font-black text-black"
|
||||||
|
style={{ background: transitLegColor(d.mode) }}
|
||||||
|
>
|
||||||
|
{d.line ?? d.mode}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block truncate text-xs font-bold text-white',
|
||||||
|
d.cancelled && 'line-through opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d.headsign ?? '—'}
|
||||||
|
</span>
|
||||||
|
{d.platform && (
|
||||||
|
<span className="block truncate text-[10px] text-muted-foreground">{d.platform}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{d.realTime && !d.cancelled && (
|
||||||
|
<span className="shrink-0 rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{d.cancelled && (
|
||||||
|
<span className="shrink-0 rounded bg-red-500/15 px-1 text-[9px] font-black uppercase text-red-400">
|
||||||
|
cancelled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReloadKey((k) => k + 1)}
|
||||||
|
className="flex min-h-0 items-center gap-1 rounded-full border border-white/10 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -82,11 +82,17 @@ export interface TransitLeg {
|
||||||
headsign?: string;
|
headsign?: string;
|
||||||
/** Operator name (e.g. "BVG"), when the feed provides it. */
|
/** Operator name (e.g. "BVG"), when the feed provides it. */
|
||||||
agencyName?: string;
|
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;
|
fromName?: string;
|
||||||
toName?: string;
|
toName?: string;
|
||||||
/** Boarding / alighting platform or track, when the feed provides it. */
|
/** Boarding / alighting platform or track, when the feed provides it. */
|
||||||
fromTrack?: string;
|
fromTrack?: string;
|
||||||
toTrack?: 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). */
|
/** Rough "N stops" cue (intermediate stops + the alighting stop). */
|
||||||
stops?: number;
|
stops?: number;
|
||||||
/** True when the leg carries live/realtime data (vs. a pure timetable). */
|
/** True when the leg carries live/realtime data (vs. a pure timetable). */
|
||||||
|
|
@ -108,6 +114,28 @@ export interface TransitItinerary {
|
||||||
legs: TransitLeg[];
|
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. */
|
/** Attribution required by Transitous / OpenStreetMap. */
|
||||||
export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors';
|
export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors';
|
||||||
|
|
||||||
|
|
@ -304,7 +332,7 @@ export async function fetchRoute(
|
||||||
// ─── Public transport (Transitous / MOTIS) ───────────────────────────────────
|
// ─── Public transport (Transitous / MOTIS) ───────────────────────────────────
|
||||||
|
|
||||||
const TRANSITOUS_PLAN = 'https://api.transitous.org/api/v1/plan';
|
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.
|
* Decode an encoded polyline (Google/OSRM format) into [lat, lng] pairs.
|
||||||
* MOTIS emits these with `precision: 7` (1e7). Standard decoder — kept local
|
* MOTIS emits these with `precision: 7` (1e7). Standard decoder — kept local
|
||||||
|
|
@ -346,16 +374,26 @@ interface MotisLeg {
|
||||||
routeShortName?: string;
|
routeShortName?: string;
|
||||||
routeLongName?: string;
|
routeLongName?: string;
|
||||||
agencyName?: string;
|
agencyName?: string;
|
||||||
|
agencyFareUrl?: string;
|
||||||
headsign?: string;
|
headsign?: string;
|
||||||
realTime?: boolean;
|
realTime?: boolean;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
from?: { name?: string; track?: string };
|
from?: { name?: string; track?: string; stopId?: string };
|
||||||
to?: { name?: string; track?: string };
|
to?: { name?: string; track?: string; stopId?: string };
|
||||||
intermediateStops?: unknown[];
|
intermediateStops?: unknown[];
|
||||||
legGeometry?: { points?: string; precision?: number };
|
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 {
|
interface MotisItinerary {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
|
@ -373,14 +411,19 @@ export async function fetchTransitRoute(
|
||||||
from: { lat: number; lng: number },
|
from: { lat: number; lng: number },
|
||||||
to: { lat: number; lng: number },
|
to: { lat: number; lng: number },
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
opts: TransitPlanOptions = {},
|
||||||
): Promise<RouteResult> {
|
): Promise<RouteResult> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
fromPlace: `${from.lat},${from.lng}`,
|
fromPlace: `${from.lat},${from.lng}`,
|
||||||
toPlace: `${to.lat},${to.lng}`,
|
toPlace: `${to.lat},${to.lng}`,
|
||||||
numItineraries: '4',
|
numItineraries: '4',
|
||||||
// ISO in UTC (JS always emits the Gregorian calendar — unlike the shell).
|
// 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 });
|
const res = await fetch(`${TRANSITOUS_PLAN}?${params.toString()}`, { signal });
|
||||||
if (!res.ok) throw new Error(`Transit service returned ${res.status}`);
|
if (!res.ok) throw new Error(`Transit service returned ${res.status}`);
|
||||||
|
|
||||||
|
|
@ -394,10 +437,13 @@ export async function fetchTransitRoute(
|
||||||
line: leg.routeShortName || leg.routeLongName,
|
line: leg.routeShortName || leg.routeLongName,
|
||||||
headsign: leg.headsign,
|
headsign: leg.headsign,
|
||||||
agencyName: leg.agencyName,
|
agencyName: leg.agencyName,
|
||||||
|
agencyFareUrl: leg.agencyFareUrl,
|
||||||
fromName: leg.from?.name,
|
fromName: leg.from?.name,
|
||||||
toName: leg.to?.name,
|
toName: leg.to?.name,
|
||||||
fromTrack: leg.from?.track,
|
fromTrack: leg.from?.track,
|
||||||
toTrack: leg.to?.track,
|
toTrack: leg.to?.track,
|
||||||
|
fromStopId: leg.from?.stopId,
|
||||||
|
toStopId: leg.to?.stopId,
|
||||||
stops:
|
stops:
|
||||||
Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0
|
Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0
|
||||||
? leg.intermediateStops.length + 1
|
? 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<StopDeparture[]> {
|
||||||
|
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". */
|
/** Format an ISO timestamp as a local clock time, e.g. "08:39". */
|
||||||
export function formatClock(iso: string): string {
|
export function formatClock(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|
|
||||||
95
src/lib/transitUi.ts
Normal file
95
src/lib/transitUi.ts
Normal file
|
|
@ -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'] },
|
||||||
|
];
|
||||||
Loading…
Reference in a new issue