Add Wiener Linien API integration and departures monitoring
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -27,3 +27,4 @@ npm-debug.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
agent_loop/
|
||||
|
||||
@@ -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
@@ -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 | 1–3 | ~45 min |
|
||||
| 2 — Deduplicate Code | 4–7 | ~2 hours |
|
||||
| 3 — Performance & UX | 8–11 | ~1.5 hours |
|
||||
| 4 — Monitoring & Testing | 12–14 | ~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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user