Add Wiener Linien API integration and departures monitoring

This commit is contained in:
2026-05-10 17:19:24 +02:00
parent c0f34b9e2a
commit 7901368971
20 changed files with 1746 additions and 197 deletions
+3
View File
@@ -12,3 +12,6 @@ OSRM_URL=https://router.project-osrm.org
# Nominatim user-agent / referer (required by their ToS)
NOMINATIM_USER_AGENT=OebbPlanner/1.0
# Wiener Linien Open Data API
WIENER_LINIEN_API_URL=https://api.wienerlinien.at/darwin-v2
+1
View File
@@ -27,3 +27,4 @@ npm-debug.log*
# typescript
*.tsbuildinfo
next-env.d.ts
agent_loop/
+15
View File
@@ -0,0 +1,15 @@
[
{
"label": "Run Wiener Linien agent loop",
"command": "python",
"args": [
"ttl_agent_gemma4.py",
"--workspace", "/home/fegger/Code/TimeToLeave",
"--run-until-done"
],
"cwd": "$ZED_WORKTREE_ROOT/agent_loop",
"use_new_terminal": true,
"allow_concurrent_runs": false,
"reveal": "always"
}
]
+12 -60
View File
@@ -1,62 +1,14 @@
# TimeToLeave — Implementation Checklist
> **Source:** [REWRITE_PLAN.md](./REWRITE_PLAN.md)
> **Total:** 4 phases, 14 steps, ~5 hours estimated effort
---
## Phase 1 — Unblock Runtime (~45 min)
Fix bugs that crash the app or lose user data.
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:--------------:|:-----------:|-------|
| **Step 1: Fix SSR Crash in `useBikeRoute`** (~10 min) | [x] | [x] | Relative URL fetch replaces `window.location.href` — SSR-safe. `isMounted` guard intact. |
| **Step 2: Wire Calendar Events into `EventsStore`** (~15 min) | [x] | [x] | `CalendarPanel.tsx` merges via `useEffect` when calendar events arrive. `mergeEvents()` converts `CalendarEvent` (string `eventTime`) → `Event` (Date `eventTime`). Bonus: localStorage persistence with rehydration. |
| **Step 3: Add Input Validation to `/api/hafas`** (~20 min) | [x] | [x] | Validates `svcReqL` array shape, method allowlist (TripSearch/LocMatch), caps `numF` at 10. Extra type guard on `svcReq.meth` (`typeof svcReq.meth !== 'string'`) exceeds spec. |
---
## Phase 2 — Deduplicate Code (~2 hours)
Eliminate duplicated logic so each integration has one source of truth.
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------|
| **Step 4: Consolidate HAFAS Journey Parsing** (~40 min) | [x] | [x] | parseHafasJourneys moved to hafas-client.ts, exported, imported by useJourneys.ts. Option A followed. |
| **Step 5: Wire API Routes to Use Library Clients** (~30 min) | [x] | [x] | Both routes use module-level singleton clients. Param validation, error handling, and try/catch intact. |
| **Step 6: Remove Dead Code** (~5 min) | [x] | [x] | live-status-utils.ts deleted, export removed from index.ts. No remaining references. |
| **Step 7: Create Missing Test Setup File** (~10 min) | [x] | [x] | src/test/setup.ts created with jest-dom vitest import. Referenced correctly in vitest.config.ts. |
---
## Phase 3 — Performance & UX (~1.5 hours)
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------|
| **Step 8: Add Debounce to Lookup Hooks** (~25 min) | [x] | [x] | 400ms setTimeout + AbortController in `useGeocode.ts` and `useDestinationStation.ts`. AbortError silently ignored. |
| **Step 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | [x] | `useMemo` builds `Map<string, Event[]>` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. |
| **Step 10: Add Dark Mode Toggle** (~20 min) | [x] | [x] | `useTheme.ts` created (localStorage + prefers-color-scheme). Sun/moon toggle button added to `Header.tsx`. |
| **Step 11: Fix Bike Route Steps** (~5 min) | [x] | [x] | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. |
---
## Phase 4 — Monitoring & Testing (~1 hour)
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------|
| **Step 12: Add Correlation IDs to API Errors** (~15 min) | [x] | [x] | `randomUUID().slice(0, 8)` in catch blocks of `bike-route`, `geocode`, `hafas`, `calendar`, `calendar/parse`. Logged server-side, returned in JSON. Existing API tests updated to assert `correlationId`. |
| **Step 13: Add Hook Tests** (~30 min) | [x] | [x] | `useJourneys.test.ts` (4 tests: no-op when missing IDs, success, HTTP error, fetch throw). `useBikeRoute.test.ts` (4 tests: no-op when missing coords, success, HTTP error, fetch throw). |
| **Step 14: Add Component Tests** (~15 min) | [x] | [x] | `EventCard.test.tsx` (renders title + destination, hooks mocked). `CalendarView.test.tsx` (3 tests: month header, event on correct day, overflow indicator). |
---
## Summary
| Phase | Steps | Est. Time |
|-------|-------|-----------|
| 1 — Unblock Runtime | 13 | ~45 min |
| 2 — Deduplicate Code | 47 | ~2 hours |
| 3 — Performance & UX | 811 | ~1.5 hours |
| 4 — Monitoring & Testing | 1214 | ~1 hour |
| **Total** | **14** | **~5 hours** |
| # | Step | ✅ | ✔️ |
|---|------|----|----|
| 1 | Project scaffolding & baseline setup | [x] | [x] |
| 2 | Environment config + constants (`src/lib/constants.ts`) | [x] | [x] |
| 3 | Types (`src/types/index.ts`) | [x] | [x] |
| 4 | Geocode client + route + hook + component | [x] | [x] |
| 5 | Journeys client + route + hook + component | [x] | [x] |
| 6 | Bike routing client + route + hook + component | [x] | [x] |
| 7 | WienerLinien client + route + hook + component | [x] | [x] |
| 8 | Wire `useWienerLinien` and `<WienerLinienSection>` into `EventCard.tsx` | [x] | [ ] |
| 9 | End-to-end integration test for full event card | [ ] | [ ] |
| 10 | Polish: loading skeletons, empty states, dark-mode audit | [ ] | [ ] |
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/wienerlinien-client", () => {
const mockGetMonitor = vi.fn();
return {
WienerLinienClient: vi.fn(function () {
return {
getMonitor: mockGetMonitor,
};
}),
__mockGetMonitor: mockGetMonitor,
};
});
import { GET } from "../route";
import * as wlClient from "@/lib/wienerlinien-client";
const mockGetMonitor = (
wlClient as typeof import("@/lib/wienerlinien-client") & {
__mockGetMonitor: ReturnType<typeof vi.fn>;
}
).__mockGetMonitor;
describe("GET /api/wienerlinien/monitor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 200 with departures for valid stop IDs", async () => {
const mockMonitorResponse = {
stops: [
{
stopId: "123",
departures: [
{
stopId: "123",
line: { name: "U1" },
direction: "Leopoldau",
departureTime: Date.now() + 120_000,
},
],
},
],
};
mockGetMonitor.mockResolvedValue(mockMonitorResponse);
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=123,456");
const response = await GET(request);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.departures).toHaveLength(1);
expect(json.departures[0]).toMatchObject({
stopId: "123",
line: { name: "U1" },
direction: "Leopoldau",
});
expect(mockGetMonitor).toHaveBeenCalledWith(["123", "456"]);
});
it("returns 400 when stopIds is missing", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/monitor");
const response = await GET(request);
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toContain("stopIds");
});
it("returns 400 when stopIds is empty or whitespace", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=%20");
const response = await GET(request);
expect(response.status).toBe(400);
});
it("caps stop IDs to 10", async () => {
mockGetMonitor.mockResolvedValue({ stops: [] });
const manyIds = Array.from({ length: 15 }, (_, i) => String(i + 1)).join(",");
const request = new NextRequest(`http://localhost/api/wienerlinien/monitor?stopIds=${manyIds}`);
await GET(request);
expect(mockGetMonitor).toHaveBeenCalledTimes(1);
expect(mockGetMonitor.mock.calls[0][0]).toHaveLength(10);
});
it("deduplicates stop IDs while preserving order", async () => {
mockGetMonitor.mockResolvedValue({ stops: [] });
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=1,2,1,3,2");
await GET(request);
expect(mockGetMonitor).toHaveBeenCalledWith(["1", "2", "3"]);
});
it("returns 500 with correlationId when client throws", async () => {
mockGetMonitor.mockRejectedValue(new Error("upstream timeout"));
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=123");
const response = await GET(request);
expect(response.status).toBe(500);
const json = await response.json();
expect(json.error).toBeTruthy();
expect(json.correlationId).toHaveLength(8);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client";
const client = new WienerLinienClient();
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const stopIdsParam = searchParams.get("stopIds");
if (!stopIdsParam || stopIdsParam.trim() === "") {
return NextResponse.json({ error: "Missing 'stopIds' query parameter" }, { status: 400 });
}
const rawIds = stopIdsParam.split(",");
const validStopIds = rawIds
.map((id) => id.trim())
.filter((id) => id.length > 0)
.filter((id, index, self) => self.indexOf(id) === index);
if (validStopIds.length === 0) {
return NextResponse.json({ error: "No valid stop IDs provided" }, { status: 400 });
}
const cappedIds = validStopIds.slice(0, 10);
try {
const monitorData = await client.getMonitor(cappedIds);
// Flatten nested stops array into a single departures list
const departures = monitorData.stops.flatMap((s) => s.departures);
return NextResponse.json({ departures });
} catch (error) {
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Wiener Linien monitor request failed:`, error);
return NextResponse.json({ error: "Failed to fetch departures", correlationId: corrId }, { status: 500 });
}
}
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/wienerlinien-client", () => {
const mockFindNearbyStops = vi.fn();
return {
WienerLinienClient: vi.fn(function () {
return {
findNearbyStops: mockFindNearbyStops,
};
}),
__mockFindNearbyStops: mockFindNearbyStops,
};
});
import { GET } from "../route";
import * as wlClient from "@/lib/wienerlinien-client";
const mockFindNearbyStops = (
wlClient as typeof import("@/lib/wienerlinien-client") & {
__mockFindNearbyStops: ReturnType<typeof vi.fn>;
}
).__mockFindNearbyStops;
describe("api/wienerlinien/stops/route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 200 with stops for valid coordinates", async () => {
const mockStops = [
{
id: "1",
name: "Test Stop",
lat: 48.2,
lng: 16.3,
},
];
mockFindNearbyStops.mockResolvedValue(mockStops);
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3");
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toEqual({ stops: mockStops });
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 1000);
});
it("uses custom radius when provided", async () => {
mockFindNearbyStops.mockResolvedValue([]);
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3&radius=2000");
await GET(request);
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 2000);
});
it("returns 400 when lat is missing", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lng=16.3");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("lat");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when lng is missing", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("lng");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when both lat and lng are missing", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("lat");
expect(data.error).toContain("lng");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when lat is NaN", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=abc&lng=16.3");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("numbers");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when lng is NaN", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=xyz");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("numbers");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when latitude is out of bounds (too high)", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=95&lng=16.3");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("latitude");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when latitude is out of bounds (too low)", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=-95&lng=16.3");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("latitude");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when longitude is out of bounds (too high)", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=200");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("longitude");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("returns 400 when longitude is out of bounds (too low)", async () => {
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=-200");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("longitude");
expect(mockFindNearbyStops).not.toHaveBeenCalled();
});
it("accepts boundary values for latitude (-90 and 90)", async () => {
mockFindNearbyStops.mockResolvedValue([]);
const requestLow = new NextRequest("http://localhost/api/wienerlinien/stops?lat=-90&lng=0");
const responseLow = await GET(requestLow);
expect(responseLow.status).toBe(200);
const requestHigh = new NextRequest("http://localhost/api/wienerlinien/stops?lat=90&lng=0");
const responseHigh = await GET(requestHigh);
expect(responseHigh.status).toBe(200);
});
it("accepts boundary values for longitude (-180 and 180)", async () => {
mockFindNearbyStops.mockResolvedValue([]);
const requestLow = new NextRequest("http://localhost/api/wienerlinien/stops?lat=0&lng=-180");
const responseLow = await GET(requestLow);
expect(responseLow.status).toBe(200);
const requestHigh = new NextRequest("http://localhost/api/wienerlinien/stops?lat=0&lng=180");
const responseHigh = await GET(requestHigh);
expect(responseHigh.status).toBe(200);
});
it("clamps radius to 5000 when exceeded", async () => {
mockFindNearbyStops.mockResolvedValue([]);
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3&radius=10000");
await GET(request);
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 5000);
});
it("ignores invalid radius string and defaults to 1000", async () => {
mockFindNearbyStops.mockResolvedValue([]);
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3&radius=abc");
await GET(request);
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 1000);
});
it("returns 500 with correlationId when client throws", async () => {
mockFindNearbyStops.mockRejectedValue(new Error("Network failure"));
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3");
const response = await GET(request);
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe("Failed to fetch nearby stops");
expect(data.correlationId).toBeDefined();
expect(data.correlationId).toHaveLength(8);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client";
const client = new WienerLinienClient();
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const latStr = searchParams.get("lat");
const lngStr = searchParams.get("lng");
if (!latStr || !lngStr) {
return NextResponse.json({ error: "Missing required parameters: lat and lng" }, { status: 400 });
}
const lat = parseFloat(latStr);
const lng = parseFloat(lngStr);
if (isNaN(lat) || isNaN(lng)) {
return NextResponse.json({ error: "Invalid coordinates: lat and lng must be numbers" }, { status: 400 });
}
if (lat < -90 || lat > 90) {
return NextResponse.json({ error: "Invalid latitude: must be between -90 and 90" }, { status: 400 });
}
if (lng < -180 || lng > 180) {
return NextResponse.json({ error: "Invalid longitude: must be between -180 and 180" }, { status: 400 });
}
let radius = 1000;
const radiusStr = searchParams.get("radius");
if (radiusStr !== null) {
const parsed = parseFloat(radiusStr);
if (!isNaN(parsed)) {
radius = parsed;
}
}
if (radius > 5000) {
radius = 5000;
}
try {
const stops = await client.findNearbyStops(lat, lng, radius);
return NextResponse.json({ stops });
} catch (error) {
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Wiener Linien nearby stops lookup failed:`, error);
return NextResponse.json({ error: "Failed to fetch nearby stops", correlationId: corrId }, { status: 500 });
}
}
+57 -57
View File
@@ -1,75 +1,75 @@
'use client';
"use client";
import React, { useState, useCallback } from 'react';
import { Event, Station } from '@/types';
import TrainSection from './TrainSection';
import BikeSection from './BikeSection';
import LeaveByBadge from './LeaveByBadge';
import { calculateCountdown } from '@/lib/countdown-utils';
import { useDestinationStation } from '@/hooks/useDestinationStation';
import { useGeocode } from '@/hooks/useGeocode';
import { useJourneys } from '@/hooks/useJourneys';
import { useBikeRoute } from '@/hooks/useBikeRoute';
import { useGeolocation } from '@/hooks/useGeolocation';
import { format } from "date-fns";
import { useJourneys } from "@/hooks/useJourneys";
import { useBikeRoute } from "@/hooks/useBikeRoute";
import { useGeocode } from "@/hooks/useGeocode";
import { useClock } from "@/hooks/useClock";
import { useWienerLinien } from "@/hooks/useWienerLinien";
import type { Event, Station } from "@/types";
import TrainSection from "./TrainSection";
import BikeSection from "./BikeSection";
import WienerLinienSection from "./WienerLinienSection";
import CountdownBadge from "@/app/ui/CountdownBadge";
type EventCardProps = {
interface EventCardProps {
event: Event;
originStation: Station | null;
className?: string;
};
}
const EventCard: React.FC<EventCardProps> = ({ event, originStation, className = '' }) => {
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = useCallback(() => setRefreshKey((k) => k + 1), []);
export default function EventCard({ event, originStation }: EventCardProps) {
const {
journeys,
loading: journeysLoading,
error: journeysError,
} = useJourneys(originStation?.extId ?? null, null, event.eventTime, 0);
const { location } = useGeolocation();
const { station: destStation } = useDestinationStation(event.destination);
const { coords: destCoords } = useGeocode(event.destination);
const destCoords = useGeocode(event.destination);
const { journeys, loading, error } = useJourneys(
originStation?.extId ?? null,
destStation?.extId ?? null,
event.eventTime,
refreshKey,
);
const {
bikeRoute,
loading: bikeLoading,
error: bikeError,
} = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng);
const { bikeRoute, loading: bikeLoading, error: bikeError } = useBikeRoute(
location?.coords.latitude,
location?.coords.longitude,
destCoords?.lat,
destCoords?.lng,
);
const {
stops,
departures,
loading: wlLoading,
error: wlError,
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
const { countdown, status } = useClock(event.eventTime);
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{event.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">{event.destination}</p>
</div>
<div className="ml-4 flex items-center">
<LeaveByBadge countdown={calculateCountdown(event.eventTime)} />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{event.title}</h3>
<CountdownBadge countdown={countdown} status={status} />
</div>
<div className="p-4">
<div className="mb-2 text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Destination:</span> {event.destination}
</div>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")}
</div>
<div className="space-y-4">
<TrainSection
journeys={journeys}
eventTime={event.eventTime}
destName={destStation?.name ?? event.destination}
loading={loading}
error={error}
onRefresh={handleRefresh}
/>
<BikeSection
bikeRoute={bikeRoute}
bikeLoading={bikeLoading}
bikeError={bikeError}
destName={event.destination}
loading={journeysLoading}
error={journeysError}
/>
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} />
{stops.length > 0 && (
<WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
)}
</div>
</div>
);
};
export default EventCard;
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import type { WienerLinienStop } from "@/types";
import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Chip from "@/app/ui/Chip";
interface DepartureRow {
stopId: string;
lineName: string;
direction: string;
minutes: number;
}
type WienerLinienSectionProps = {
stops: WienerLinienStop[];
departures: DepartureRow[];
loading: boolean;
error: string | null;
className?: string;
};
export default function WienerLinienSection({
stops,
departures,
loading,
error,
className = "",
}: WienerLinienSectionProps) {
if (loading) {
return (
<div className={className}>
<LoadingSpinner />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading nearby stops...</p>
</div>
);
}
if (error) {
return (
<div className={className}>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
);
}
if (stops.length === 0) {
return (
<div className={className}>
<p className="text-sm text-gray-500 dark:text-gray-400">No nearby stops found.</p>
</div>
);
}
const departuresByStop = new Map<string, DepartureRow[]>();
for (const departure of departures) {
const stopId = departure.stopId;
const existing = departuresByStop.get(stopId) ?? [];
existing.push(departure);
departuresByStop.set(stopId, existing);
}
return (
<div className={className}>
{stops.map((stop) => {
const stopDepartures = departuresByStop.get(stop.id) ?? [];
return (
<div key={stop.id} className="mb-4 last:mb-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{stop.name}</h3>
{stopDepartures.length === 0 ? (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No departures available</p>
) : (
<ul className="mt-1 space-y-1">
{stopDepartures.map((departure, index) => (
<li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
<Chip>{departure.lineName}</Chip>
<span className="text-sm text-gray-600 dark:text-gray-300">{departure.direction}</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{departure.minutes} min
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,61 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import WienerLinienSection from "../WienerLinienSection";
import type { WienerLinienStop } from "@/types";
vi.mock("@/app/ui/LoadingSpinner", () => ({
default: function MockLoadingSpinner() {
return <div data-testid="loading-spinner">Loading...</div>;
},
}));
vi.mock("@/app/ui/Chip", () => ({
default: function MockChip({ children }: { children: React.ReactNode }) {
return <span data-testid={`chip-${String(children)}`}>{children}</span>;
},
}));
const mockStops: WienerLinienStop[] = [
{ id: "stop-1", name: "Stephansplatz", lat: 48.208, lng: 16.373 },
{ id: "stop-2", name: "Karlsplatz", lat: 48.201, lng: 16.372 },
];
const mockDepartures = [
{ stopId: "stop-1", lineName: "U1", direction: "Leopoldau", minutes: 2 },
{ stopId: "stop-1", lineName: "U3", direction: "Simmering", minutes: 5 },
{ stopId: "stop-2", lineName: "U2", direction: "Seitengasse", minutes: 1 },
];
describe("WienerLinienSection", () => {
it("renders skeleton when loading=true", () => {
render(<WienerLinienSection stops={[]} departures={[]} loading error={null} />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.getByText("Loading nearby stops...")).toBeInTheDocument();
});
it("renders error message when error string is provided", () => {
render(<WienerLinienSection stops={[]} departures={[]} loading={false} error="Failed to fetch stops" />);
expect(screen.getByText("Failed to fetch stops")).toBeInTheDocument();
});
it("renders stop names and departure line badges correctly", () => {
render(<WienerLinienSection stops={mockStops} departures={mockDepartures} loading={false} error={null} />);
expect(screen.getByText("Stephansplatz")).toBeInTheDocument();
expect(screen.getByText("Karlsplatz")).toBeInTheDocument();
expect(screen.getByTestId("chip-U1")).toBeInTheDocument();
expect(screen.getByTestId("chip-U3")).toBeInTheDocument();
expect(screen.getByTestId("chip-U2")).toBeInTheDocument();
});
it("renders minute countdown text for each departure", () => {
render(<WienerLinienSection stops={mockStops} departures={mockDepartures} loading={false} error={null} />);
expect(screen.getByText("2 min")).toBeInTheDocument();
expect(screen.getByText("5 min")).toBeInTheDocument();
expect(screen.getByText("1 min")).toBeInTheDocument();
});
it("handles empty stops/departures arrays without crashing", () => {
render(<WienerLinienSection stops={[]} departures={[]} loading={false} error={null} />);
expect(screen.getByText("No nearby stops found.")).toBeInTheDocument();
});
});
+37
View File
@@ -0,0 +1,37 @@
"use client";
import React from "react";
import Chip from "./Chip";
const colorMap: Record<string, string> = {
red: "text-red-600 bg-red-50 dark:bg-red-900/30",
orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30",
yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30",
green: "text-green-600 bg-green-50 dark:bg-green-900/30",
blue: "text-blue-600 bg-blue-50 dark:bg-blue-900/30",
};
type CountdownBadgeProps = {
countdown: {
label: string;
color: string;
urgent: boolean;
};
status: "upcoming" | "now" | "past";
className?: string;
};
const CountdownBadge: React.FC<CountdownBadgeProps> = ({ countdown, status, className = "" }) => {
const bgColor = colorMap[countdown.color] || colorMap.blue;
const isUrgent = countdown.urgent || status === "now";
return (
<Chip
className={`${className} ${bgColor} ${isUrgent ? "animate-pulse" : ""}`}
>
{countdown.label}
</Chip>
);
};
export default CountdownBadge;
+362
View File
@@ -0,0 +1,362 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useWienerLinien } from "../useWienerLinien";
const mockStops = [
{ id: "stop-1", name: "Test Station 1", lat: 48.2, lng: 16.37 },
{ id: "stop-2", name: "Test Station 2", lat: 48.21, lng: 16.38 },
];
const mockDepartures = [
{
stopId: "stop-1",
line: { name: "U1" },
direction: "Leopoldau",
departureTime: Date.now() + 120_000,
delay: 0,
},
{
stopId: "stop-2",
line: { name: "U2" },
direction: "Seitengasse",
departureTime: Date.now() + 300_000,
delay: 0,
},
];
describe("useWienerLinien", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns empty arrays when coordinates are undefined", () => {
const { result } = renderHook(() => useWienerLinien(undefined, undefined));
expect(result.current.stops).toEqual([]);
expect(result.current.departures).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it("debounces the initial fetch by 400 ms", async () => {
let fetchCalled = false;
vi.spyOn(globalThis, "fetch").mockImplementation(() => {
fetchCalled = true;
return Promise.resolve({
ok: true,
json: async () => ({ stops: mockStops }),
} as Response);
});
renderHook(() => useWienerLinien(48.2, 16.37));
expect(fetchCalled).toBe(false);
await act(async () => {
vi.advanceTimersByTime(400);
});
expect(fetchCalled).toBe(true);
});
it("fetches stops then monitor sequentially", async () => {
const fetchCalls: string[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
fetchCalls.push(url);
if (url.includes("/api/wienerlinien/stops")) {
return {
ok: true,
json: async () => ({ stops: mockStops }),
} as Response;
}
if (url.includes("/api/wienerlinien/monitor")) {
return {
ok: true,
json: async () => ({ departures: mockDepartures }),
} as Response;
}
return {
ok: false,
status: 404,
json: async () => ({ error: "Unknown route" }),
} as Response;
});
const { result } = renderHook(() => useWienerLinien(48.2, 16.37));
// Advance timer to fire debounce, then flush all promise chains
await act(async () => {
vi.advanceTimersByTime(400);
await vi.runAllTimersAsync();
});
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0]).toContain("/api/wienerlinien/stops");
expect(fetchCalls[1]).toContain("/api/wienerlinien/monitor");
expect(result.current.stops).toEqual(mockStops);
expect(result.current.departures).toHaveLength(2);
expect(result.current.departures[0]).toMatchObject({
stopId: "stop-1",
lineName: "U1",
direction: "Leopoldau",
});
expect(result.current.loading).toBe(false);
});
it("includes radius parameter in stops request when provided", async () => {
let capturedStopsUrl = "";
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/wienerlinien/stops")) {
capturedStopsUrl = url;
return {
ok: true,
json: async () => ({ stops: mockStops }),
} as Response;
}
if (url.includes("/api/wienerlinien/monitor")) {
return {
ok: true,
json: async () => ({ departures: mockDepartures }),
} as Response;
}
return {
ok: false,
status: 404,
json: async () => ({ error: "Unknown route" }),
} as Response;
});
renderHook(() => useWienerLinien(48.2, 16.37, 500));
await act(async () => {
vi.advanceTimersByTime(400);
await vi.runAllTimersAsync();
});
expect(capturedStopsUrl).toContain("radius=500");
});
it("refreshes departures every 60 seconds", async () => {
let monitorCallCount = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/wienerlinien/stops")) {
return {
ok: true,
json: async () => ({ stops: mockStops }),
} as Response;
}
if (url.includes("/api/wienerlinien/monitor")) {
monitorCallCount++;
return {
ok: true,
json: async () => ({ departures: mockDepartures }),
} as Response;
}
return {
ok: false,
status: 404,
json: async () => ({ error: "Unknown route" }),
} as Response;
});
const { result } = renderHook(() => useWienerLinien(48.2, 16.37));
// Initial fetch
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(monitorCallCount).toBe(1);
// Advance by 60 s to trigger refresh
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(monitorCallCount).toBe(2);
expect(result.current.departures).toHaveLength(2);
});
it("cleans up interval on unmount", async () => {
let monitorCallCount = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/wienerlinien/stops")) {
return {
ok: true,
json: async () => ({ stops: mockStops }),
} as Response;
}
if (url.includes("/api/wienerlinien/monitor")) {
monitorCallCount++;
return {
ok: true,
json: async () => ({ departures: mockDepartures }),
} as Response;
}
return {
ok: false,
status: 404,
json: async () => ({ error: "Unknown route" }),
} as Response;
});
const { unmount } = renderHook(() => useWienerLinien(48.2, 16.37));
// Initial fetch
await act(async () => {
vi.advanceTimersByTime(400);
await vi.runAllTimersAsync();
});
expect(monitorCallCount).toBe(1);
// Unmount before the next interval tick
unmount();
// Advance by 60 s — interval should NOT fire
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(monitorCallCount).toBe(1);
});
it("sets error state on non-200 stops response", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: "Internal server error" }),
} as Response);
const { result } = renderHook(() => useWienerLinien(48.2, 16.37));
await act(async () => {
vi.advanceTimersByTime(400);
await vi.runAllTimersAsync();
});
expect(result.current.error).toBe("Internal server error");
expect(result.current.loading).toBe(false);
});
it("refetches when coordinates change", async () => {
let fetchCount = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
fetchCount++;
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/wienerlinien/stops")) {
return {
ok: true,
json: async () => ({ stops: mockStops }),
} as Response;
}
if (url.includes("/api/wienerlinien/monitor")) {
return {
ok: true,
json: async () => ({ departures: mockDepartures }),
} as Response;
}
return {
ok: false,
status: 404,
json: async () => ({ error: "Unknown route" }),
} as Response;
});
const { rerender } = renderHook(({ lat, lng }) => useWienerLinien(lat, lng), {
initialProps: { lat: 48.2, lng: 16.37 },
});
// Initial fetch
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(fetchCount).toBe(2); // stops + monitor
// Change coordinates — triggers cleanup + new debounced fetch
rerender({ lat: 48.3, lng: 16.4 });
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(fetchCount).toBe(4); // 2 more: stops + monitor
});
it("resets state when coordinates become undefined", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/wienerlinien/stops")) {
return {
ok: true,
json: async () => ({ stops: mockStops }),
} as Response;
}
if (url.includes("/api/wienerlinien/monitor")) {
return {
ok: true,
json: async () => ({ departures: mockDepartures }),
} as Response;
}
return {
ok: false,
status: 404,
json: async () => ({ error: "Unknown route" }),
} as Response;
});
const { result, rerender } = renderHook(({ lat, lng }) => useWienerLinien(lat, lng), {
initialProps: { lat: 48.2, lng: 16.37 } as { lat: number | undefined; lng: number | undefined },
});
// Initial fetch
await act(async () => {
vi.advanceTimersByTime(400);
await vi.runAllTimersAsync();
});
expect(result.current.stops).toHaveLength(2);
expect(result.current.departures).toHaveLength(2);
// Coordinates become undefined
rerender({ lat: undefined, lng: undefined });
expect(result.current.stops).toEqual([]);
expect(result.current.departures).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
+25 -4
View File
@@ -1,10 +1,17 @@
import { useState, useEffect } from "react";
"use client";
export function useClock() {
import { useState, useEffect, useMemo } from "react";
import { calculateCountdown } from "@/lib/countdown-utils";
interface ClockResult {
countdown: ReturnType<typeof calculateCountdown>;
status: "upcoming" | "now" | "past";
}
export function useClock(targetDate: Date): ClockResult {
const [now, setNow] = useState<Date>(new Date());
useEffect(() => {
// Update every 10 seconds
const interval = setInterval(() => {
setNow(new Date());
}, 10_000);
@@ -12,5 +19,19 @@ export function useClock() {
return () => clearInterval(interval);
}, []);
return now;
return useMemo(() => {
const countdown = calculateCountdown(targetDate);
const diffMs = targetDate.getTime() - now.getTime();
let status: "upcoming" | "now" | "past";
if (diffMs <= 0) {
status = "now";
} else if (diffMs <= 10 * 60 * 1000) {
status = "now";
} else {
status = "upcoming";
}
return { countdown, status };
}, [targetDate, now]);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import type { WienerLinienStop, WienerLinienDeparture } from "@/types";
interface DepartureRow {
stopId: string;
lineName: string;
direction: string;
minutes: number;
}
const DEBOUNCE_MS = 400;
const REFRESH_INTERVAL_MS = 60_000;
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
return {
stopId: dep.stopId,
lineName: dep.line.name,
direction: dep.direction,
minutes,
};
}
export function useWienerLinien(lat: number | undefined, lng: number | undefined, radius?: number) {
const [stops, setStops] = useState<WienerLinienStop[]>([]);
const [departures, setDepartures] = useState<DepartureRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const stopIdsRef = useRef<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const cancelledRef = useRef(false);
const fetchMonitor = useCallback(async (stopIds: string[], signal: AbortSignal): Promise<WienerLinienDeparture[]> => {
if (stopIds.length === 0) {
return [];
}
const params = new URLSearchParams();
for (const id of stopIds) {
params.append("stopIds", id);
}
const response = await fetch(`/api/wienerlinien/monitor?${params.toString()}`, { signal });
if (!response.ok) {
const json = (await response.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? "Failed to fetch departures");
}
const data = (await response.json()) as {
departures?: WienerLinienDeparture[];
};
return data.departures ?? [];
}, []);
// Effect for fetching stops and initial departures
useEffect(() => {
cancelledRef.current = false;
const resetState = () => {
setStops([]);
setDepartures([]);
setError(null);
setLoading(false);
};
if (lat === undefined || lng === undefined) {
resetState();
return;
}
const debounceTimer = setTimeout(async () => {
if (cancelledRef.current) return;
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
const signal = abortController.signal;
setLoading(true);
setError(null);
try {
const stopsParams = new URLSearchParams({
lat: String(lat),
lng: String(lng),
});
if (radius != null) {
stopsParams.set("radius", String(radius));
}
const stopsResponse = await fetch(`/api/wienerlinien/stops?${stopsParams.toString()}`, { signal });
if (!stopsResponse.ok) {
const json = (await stopsResponse.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? "Failed to fetch nearby stops");
}
const stopsData = (await stopsResponse.json()) as {
stops?: WienerLinienStop[];
};
const stopsList: WienerLinienStop[] = stopsData.stops ?? [];
if (!cancelledRef.current) {
setStops(stopsList);
setLoading(false);
}
const ids = stopsList.map((s) => s.id);
stopIdsRef.current = ids;
// Chain monitor fetch
try {
const rawDepartures = await fetchMonitor(ids, signal);
if (!cancelledRef.current) {
setDepartures(rawDepartures.map(transformDeparture));
}
} catch (monitorErr) {
if (monitorErr instanceof DOMException && monitorErr.name === "AbortError") return;
if (!cancelledRef.current) {
setError(monitorErr instanceof Error ? monitorErr.message : "Failed to fetch departures");
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (!cancelledRef.current) {
setError(err instanceof Error ? err.message : "An unexpected error occurred");
setLoading(false);
}
}
}, DEBOUNCE_MS);
return () => {
cancelledRef.current = true;
clearTimeout(debounceTimer);
abortRef.current?.abort();
abortRef.current = null;
};
}, [lat, lng, radius, fetchMonitor]);
// Effect for periodic departures refresh
useEffect(() => {
if (stops.length === 0) return;
const intervalId = setInterval(async () => {
const currentIds = stopIdsRef.current;
if (currentIds.length === 0) return;
const refreshController = new AbortController();
try {
const rawDepartures = await fetchMonitor(currentIds, refreshController.signal);
if (!cancelledRef.current) {
setDepartures(rawDepartures.map(transformDeparture));
}
} catch {
// Silently ignore refresh errors
}
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [stops.length, fetchMonitor]);
return { stops, departures, loading, error };
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
describe("WIENER_LINIEN_API_URL", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("returns default URL when WIENER_LINIEN_API_URL is undefined", async () => {
const { WIENER_LINIEN_API_URL } = await import("@/lib/constants");
expect(WIENER_LINIEN_API_URL).toBe("https://api.wienerlinien.at/darwin-v2");
});
it("returns custom URL when WIENER_LINIEN_API_URL is set", async () => {
vi.stubEnv("WIENER_LINIEN_API_URL", "https://custom.api.example.com/v2");
const { WIENER_LINIEN_API_URL } = await import("@/lib/constants");
expect(WIENER_LINIEN_API_URL).toBe("https://custom.api.example.com/v2");
});
});
@@ -0,0 +1,299 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-service", () => ({
ApiClient: vi.fn(() => ({
get: vi.fn(),
})),
}));
import { WienerLinienClient } from "@/lib/wienerlinien-client";
import { ApiClient } from "@/lib/api-service";
describe("WienerLinienClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("findNearbyStops", () => {
it("returns mapped stops on success", async () => {
const mockGet = vi.fn().mockResolvedValue([
{ id: "WL:StopPoint:2000001", name: "Schottentor", lat: 48.2109, lng: 16.3676 },
{ id: "WL:StopPoint:2000002", name: "Karlsplatz", lat: 48.2009, lng: 16.3716 },
]);
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const stops = await client.findNearbyStops(48.21, 16.37, 500);
expect(stops).toHaveLength(2);
expect(stops[0]).toMatchObject({
id: "WL:StopPoint:2000001",
name: "Schottentor",
lat: 48.2109,
lng: 16.3676,
});
expect(stops[1]).toMatchObject({
id: "WL:StopPoint:2000002",
name: "Karlsplatz",
lat: 48.2009,
lng: 16.3716,
});
});
it("generates correct cache key and TTL", async () => {
const mockGet = vi.fn().mockResolvedValue([]);
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
await client.findNearbyStops(48.21, 16.37, 500);
expect(mockGet).toHaveBeenCalledWith(
"/nearbyStops",
{ lat: "48.21", lon: "16.37", radius: "500" },
expect.objectContaining({
cacheKey: "wl-nearby-48.2100-16.3700-500",
ttl: 300_000,
}),
);
});
it("returns empty array for non-array response", async () => {
const mockGet = vi.fn().mockResolvedValue({ error: "not found" });
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const stops = await client.findNearbyStops(48.21, 16.37, 500);
expect(stops).toEqual([]);
});
it("returns empty array for null response", async () => {
const mockGet = vi.fn().mockResolvedValue(null);
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const stops = await client.findNearbyStops(48.21, 16.37, 500);
expect(stops).toEqual([]);
});
it("filters out malformed stop entries", async () => {
const mockGet = vi.fn().mockResolvedValue([
{ id: "WL:StopPoint:2000001", name: "Valid", lat: 48.21, lng: 16.37 },
{ id: "missing-fields" },
null,
"string",
{ id: 123, name: "wrong-types", lat: "not-a-number", lng: "also-not" },
]);
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const stops = await client.findNearbyStops(48.21, 16.37, 500);
expect(stops).toHaveLength(1);
expect(stops[0]).toMatchObject({
id: "WL:StopPoint:2000001",
name: "Valid",
lat: 48.21,
lng: 16.37,
});
});
it("propagates API errors", async () => {
const mockGet = vi.fn().mockRejectedValue(new Error("API timeout"));
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
await expect(client.findNearbyStops(48.21, 16.37, 500)).rejects.toThrow("API timeout");
});
});
describe("getMonitor", () => {
it("returns mapped departures on success", async () => {
const mockGet = vi.fn().mockResolvedValue({
"WL:StopPoint:2000001": [
{
line: { name: "U2", type: "subway" },
direction: "Aspern Nord",
departureTime: 1700000000000,
delay: 0,
},
{
line: { name: "U4", type: "subway" },
direction: "Heiligenstadt",
departureTime: 1700000060000,
delay: 120,
},
],
});
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const response = await client.getMonitor(["WL:StopPoint:2000001"]);
expect(response.stops).toHaveLength(1);
expect(response.stops[0].stopId).toBe("WL:StopPoint:2000001");
expect(response.stops[0].departures).toHaveLength(2);
expect(response.stops[0].departures[0]).toMatchObject({
line: { name: "U2", type: "subway" },
direction: "Aspern Nord",
departureTime: 1700000000000,
delay: 0,
});
expect(response.stops[0].departures[1]).toMatchObject({
line: { name: "U4", type: "subway" },
direction: "Heiligenstadt",
departureTime: 1700000060000,
delay: 120,
});
});
it("generates correct cache key with sorted stop IDs", async () => {
const mockGet = vi.fn().mockResolvedValue({});
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
await client.getMonitor(["WL:StopPoint:2000002", "WL:StopPoint:2000001"]);
expect(mockGet).toHaveBeenCalledWith(
"/monitor",
{ stopIds: "WL:StopPoint:2000002,WL:StopPoint:2000001" },
expect.objectContaining({
cacheKey: "wl-monitor-WL:StopPoint:2000001,WL:StopPoint:2000002",
ttl: 60_000,
}),
);
});
it("returns empty stops for non-object response", async () => {
const mockGet = vi.fn().mockResolvedValue("error");
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const response = await client.getMonitor(["WL:StopPoint:2000001"]);
expect(response.stops).toEqual([]);
});
it("returns empty stops for null response", async () => {
const mockGet = vi.fn().mockResolvedValue(null);
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const response = await client.getMonitor(["WL:StopPoint:2000001"]);
expect(response.stops).toEqual([]);
});
it("filters out malformed departure entries", async () => {
const mockGet = vi.fn().mockResolvedValue({
"WL:StopPoint:2000001": [
{
line: { name: "U2", type: "subway" },
direction: "Aspern Nord",
departureTime: 1700000000000,
delay: 0,
},
{ line: null, direction: "bad", departureTime: 123 },
"not-an-object",
],
});
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const response = await client.getMonitor(["WL:StopPoint:2000001"]);
expect(response.stops[0].departures).toHaveLength(1);
});
it("skips stops with non-array departure lists", async () => {
const mockGet = vi.fn().mockResolvedValue({
"WL:StopPoint:2000001": "not-an-array",
"WL:StopPoint:2000002": [
{
line: { name: "U1" },
direction: "Leopoldau",
departureTime: 1700000100000,
delay: 30,
},
],
});
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
const response = await client.getMonitor(["WL:StopPoint:2000001", "WL:StopPoint:2000002"]);
expect(response.stops).toHaveLength(1);
expect(response.stops[0].stopId).toBe("WL:StopPoint:2000002");
});
it("propagates API errors", async () => {
const mockGet = vi.fn().mockRejectedValue(new Error("503 Service Unavailable"));
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: mockGet,
}));
const client = new WienerLinienClient();
await expect(client.getMonitor(["WL:StopPoint:2000001"])).rejects.toThrow("503 Service Unavailable");
});
});
describe("constructor", () => {
it("uses default base URL", () => {
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: vi.fn(),
}));
new WienerLinienClient();
expect(ApiClient).toHaveBeenCalledWith("https://api.wienerlinien.at/darvin-v1");
});
it("uses custom base URL when provided", () => {
(ApiClient as ReturnType<typeof vi.fn>).mockImplementation(() => ({
get: vi.fn(),
}));
new WienerLinienClient("https://custom.api.example.com/v1");
expect(ApiClient).toHaveBeenCalledWith("https://custom.api.example.com/v1");
});
});
});
+9 -16
View File
@@ -1,17 +1,10 @@
// Constants for TimeToLeave
export const HAFAS_URL = process.env.HAFAS_URL ?? "https://fahrplan.oebb.at/bin/mgate.exe";
export const HAFAS_TIMEOUT_MS = 12_000; // 12 seconds
export const NOMINATIM_URL = process.env.NOMINATIM_URL ?? "https://nominatim.openstreetmap.org";
export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT ?? "TimeToLeave/2.0";
export const OSRM_URL = process.env.OSRM_URL ?? "https://router.project-osrm.org";
export const HAFAS_URL = process.env.HAFAS_URL || "https://fahrplan.oebb.at/bin/mgate.exe";
export const HAFAS_TIMEOUT_MS = parseInt(process.env.HAFAS_TIMEOUT_MS ?? "10000", 10);
export const NOMINATIM_URL = process.env.NOMINATIM_URL || "https://nominatim.openstreetmap.org";
export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToLeave/2.0";
export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org";
export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
export const DEFAULT_DAYS = 14;
export const APP_VERSION = "2.0.0";
export const DEFAULT_STATION_NAME = process.env.NEXT_PUBLIC_DEFAULT_STATION_NAME ?? "Graz Hbf";
export const DEFAULT_STATION_EXT_ID = process.env.NEXT_PUBLIC_DEFAULT_STATION_EXT_ID ?? "0WB0F0000600";
export const DEFAULT_STATION_NAME = "Wiener Hauptbahnhof";
export const DEFAULT_STATION_EXT_ID = "wik9000001";
export const APP_VERSION = process.env.APP_VERSION || "0.1.0";
+111
View File
@@ -0,0 +1,111 @@
import { ApiClient } from "@/lib/api-service";
import type { NearbyStop, WienerLinienDeparture, WienerLinienLine, WienerLinienMonitorResponse } from "@/types";
const DEFAULT_BASE_URL = "https://api.wienerlinien.at/darvin-v1";
export class WienerLinienClient {
private readonly client: ApiClient;
constructor(baseUrl: string = DEFAULT_BASE_URL) {
this.client = new ApiClient({ baseUrl });
}
async findNearbyStops(lat: number, lng: number, radius: number): Promise<NearbyStop[]> {
const cacheKey = `wl-nearby-${lat.toFixed(4)}-${lng.toFixed(4)}-${radius}`;
const raw = await this.client.get<unknown>(
"/nearbyStops",
{ lat: String(lat), lon: String(lng), radius: String(radius) },
{ cacheKey, ttl: 300_000 },
);
if (!Array.isArray(raw)) {
return [];
}
const stops: NearbyStop[] = [];
for (const item of raw) {
const stop = parseNearbyStop(item);
if (stop) {
stops.push(stop);
}
}
return stops;
}
async getMonitor(stopIds: string[]): Promise<WienerLinienMonitorResponse> {
const sortedIds = [...stopIds].sort();
const cacheKey = `wl-monitor-${sortedIds.join(",")}`;
const raw = await this.client.get<unknown>("/monitor", { stopIds: stopIds.join(",") }, { cacheKey, ttl: 60_000 });
return parseMonitorResponse(raw);
}
}
function parseNearbyStop(item: unknown): NearbyStop | null {
if (typeof item !== "object" || item === null) return null;
const obj = item as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id : null;
const name = typeof obj.name === "string" ? obj.name : null;
const lat = typeof obj.lat === "number" ? obj.lat : null;
const lng = typeof obj.lng === "number" ? obj.lng : null;
if (!id || !name || lat === null || lng === null) return null;
return { id, name, lat, lng };
}
function parseMonitorResponse(raw: unknown): WienerLinienMonitorResponse {
if (typeof raw !== "object" || raw === null) {
return { stops: [] };
}
const obj = raw as Record<string, unknown>;
const stops: WienerLinienMonitorResponse["stops"] = [];
for (const [stopId, departures] of Object.entries(obj)) {
if (!Array.isArray(departures)) continue;
const parsedDepartures: WienerLinienDeparture[] = [];
for (const dep of departures) {
const parsed = parseDeparture(dep, stopId);
if (parsed) {
parsedDepartures.push(parsed);
}
}
if (parsedDepartures.length > 0) {
stops.push({ stopId, departures: parsedDepartures });
}
}
return { stops };
}
function parseDeparture(item: unknown, stopId: string): WienerLinienDeparture | null {
if (typeof item !== "object" || item === null) return null;
const obj = item as Record<string, unknown>;
const line = parseLine(obj.line);
const direction = typeof obj.direction === "string" ? obj.direction : null;
const departureTime = typeof obj.departureTime === "number" ? obj.departureTime : null;
const delay = typeof obj.delay === "number" ? obj.delay : 0;
if (!line || !direction || departureTime === null) return null;
return { stopId, line, direction, departureTime, delay };
}
function parseLine(item: unknown): WienerLinienLine | null {
if (typeof item !== "object" || item === null) return null;
const obj = item as Record<string, unknown>;
const name = typeof obj.name === "string" ? obj.name : null;
const type = typeof obj.type === "string" ? obj.type : undefined;
if (!name) return null;
return { name, type };
}
+72 -60
View File
@@ -1,6 +1,27 @@
// Types for TimeToLeave - Next.js Rewrite
// Core domain types
// ── HAFAS / Train ──────────────────────────────────────────────
export interface Event {
id: string;
title: string;
destination: string;
eventTime: Date;
source: string;
}
export interface CalendarEvent {
id: string;
title: string;
destination: string;
eventTime: string;
source: string;
}
export interface Station {
name: string;
extId: string;
lat?: number;
lng?: number;
}
export interface Journey {
id: string;
@@ -8,49 +29,13 @@ export interface Journey {
rD: Date; // real departure
sA: Date; // scheduled arrival
rA: Date; // real arrival
delay: number; // minutes
delay: number; // delay in minutes
platform: string;
changes: number;
trains: string[];
cancelled: boolean;
}
export interface Station {
name: string;
extId: string;
}
// ── Events ─────────────────────────────────────────────────────
export interface Event {
id: string;
title: string;
destination: string;
eventTime: Date;
source: "manual" | "calendar";
}
// ── Trip Data (per event) ──────────────────────────────────────
export interface TripDataEntry {
journeys: Journey[];
destName: string;
demo: boolean;
loading: boolean;
bikeRoute?: BikeRoute | null; // NEW
bikeLoading?: boolean; // NEW
bikeError?: string | null; // NEW
destCoords?: { lat: number; lng: number }; // NEW (cached geocode)
}
// ── Bicycle Routing (NEW) ─────────────────────────────────────
export interface BikeRoute {
distance: number; // meters
duration: number; // seconds
steps?: BikeStep[];
}
export interface BikeStep {
name: string;
distance: number;
@@ -58,33 +43,60 @@ export interface BikeStep {
instruction: string;
}
// ── Geocoding (NEW) ────────────────────────────────────────────
export interface GeocodeResult {
lat: number;
lng: number;
display_name: string;
export interface BikeRoute {
distance: number;
duration: number;
steps: BikeStep[];
}
// ── Calendar Import ────────────────────────────────────────────
export interface CalendarEvent {
id: string;
title: string;
destination: string;
eventTime: string; // ISO string from API
source: "calendar";
}
// ── UI Helpers ─────────────────────────────────────────────────
export interface CountdownInfo {
label: string;
color: string;
urgent: boolean;
}
export type ServerStatus = null | true | false;
export type LiveStatus = null | true | false;
export type LocState = "pending" | "granted" | "denied";
export type CalStatus = null | "loading" | "ok" | "error";
export type ServerStatus = boolean | null;
export interface GeocodeResult {
lat: number;
lng: number;
display_name: string;
}
// WienerLinien types
export interface WienerLinienLine {
name: string;
type?: string;
}
export interface WienerLinienStop {
id: string;
name: string;
lat: number;
lng: number;
}
export interface WienerLinienDeparture {
stopId: string;
line: WienerLinienLine;
direction: string;
departureTime: number;
delay?: number;
}
export interface WienerLinienMonitorResponse {
stops: {
stopId: string;
departures: WienerLinienDeparture[];
}[];
}
export interface NearbyStop {
id: string;
name: string;
lat: number;
lng: number;
}