diff --git a/.env.example b/.env.example index 5bc88a8..91db444 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index a896a8b..4f0831f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ npm-debug.log* # typescript *.tsbuildinfo next-env.d.ts +agent_loop/ diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..19e45d2 --- /dev/null +++ b/.zed/tasks.json @@ -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" + } +] diff --git a/CHECKLIST.md b/CHECKLIST.md index 397a0b8..8001619 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -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` 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 `` into `EventCard.tsx` | [x] | [ ] | +| 9 | End-to-end integration test for full event card | [ ] | [ ] | +| 10 | Polish: loading skeletons, empty states, dark-mode audit | [ ] | [ ] | diff --git a/src/app/api/wienerlinien/monitor/__tests__/route.test.ts b/src/app/api/wienerlinien/monitor/__tests__/route.test.ts new file mode 100644 index 0000000..6145ad9 --- /dev/null +++ b/src/app/api/wienerlinien/monitor/__tests__/route.test.ts @@ -0,0 +1,117 @@ +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; + } +).__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("accepts repeated stopIds params from hook (stopIds=a&stopIds=b)", async () => { + mockGetMonitor.mockResolvedValue({ stops: [] }); + const request = new NextRequest( + "http://localhost/api/wienerlinien/monitor?stopIds=WL:2000001&stopIds=WL:2000002&stopIds=WL:2000003", + ); + await GET(request); + + expect(mockGetMonitor).toHaveBeenCalledWith(["WL:2000001", "WL:2000002", "WL:2000003"]); + }); + + 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); + }); +}); diff --git a/src/app/api/wienerlinien/monitor/route.ts b/src/app/api/wienerlinien/monitor/route.ts new file mode 100644 index 0000000..0a85551 --- /dev/null +++ b/src/app/api/wienerlinien/monitor/route.ts @@ -0,0 +1,36 @@ +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 stopIdsList = searchParams.getAll("stopIds"); + if (stopIdsList.length === 0 || stopIdsList.every((v) => v.trim() === "")) { + return NextResponse.json({ error: "Missing 'stopIds' query parameter" }, { status: 400 }); + } + + const rawIds = stopIdsList.flatMap((param) => param.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 }); + } +} diff --git a/src/app/api/wienerlinien/stops/__tests__/route.test.ts b/src/app/api/wienerlinien/stops/__tests__/route.test.ts new file mode 100644 index 0000000..c8489f7 --- /dev/null +++ b/src/app/api/wienerlinien/stops/__tests__/route.test.ts @@ -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; + } +).__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); + }); +}); diff --git a/src/app/api/wienerlinien/stops/route.ts b/src/app/api/wienerlinien/stops/route.ts new file mode 100644 index 0000000..6efb6e7 --- /dev/null +++ b/src/app/api/wienerlinien/stops/route.ts @@ -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 }); + } +} diff --git a/src/app/event/EventCard.tsx b/src/app/event/EventCard.tsx index 1d06725..fa1e9d6 100644 --- a/src/app/event/EventCard.tsx +++ b/src/app/event/EventCard.tsx @@ -1,75 +1,78 @@ -'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 { useDestinationStation } from "@/hooks/useDestinationStation"; +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 = ({ event, originStation, className = '' }) => { - const [refreshKey, setRefreshKey] = useState(0); - const handleRefresh = useCallback(() => setRefreshKey((k) => k + 1), []); +export default function EventCard({ event, originStation }: EventCardProps) { + const destStation = useDestinationStation(event.destination); - const { location } = useGeolocation(); - const { station: destStation } = useDestinationStation(event.destination); - const { coords: destCoords } = useGeocode(event.destination); + const { + journeys, + loading: journeysLoading, + error: journeysError, + } = useJourneys(originStation?.extId ?? null, destStation.station?.extId ?? null, event.eventTime, 0); - const { journeys, loading, error } = useJourneys( - originStation?.extId ?? null, - destStation?.extId ?? null, - event.eventTime, - refreshKey, - ); + const destCoords = useGeocode(event.destination); - const { bikeRoute, loading: bikeLoading, error: bikeError } = useBikeRoute( - location?.coords.latitude, - location?.coords.longitude, - destCoords?.lat, - destCoords?.lng, - ); + const { + bikeRoute, + loading: bikeLoading, + error: bikeError, + } = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng); + + const { + stops, + departures, + loading: wlLoading, + error: wlError, + } = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng); + + const { countdown, status } = useClock(event.eventTime); return ( -
-
-
-
-

{event.title}

-

{event.destination}

-
-
- -
-
+
+
+

{event.title}

+
-
+ +
+ Destination: {event.destination} +
+
+ Time: {format(event.eventTime, "EEE dd MMM yyyy HH:mm")} +
+ +
- + + + + {stops.length > 0 && ( + + )}
); -}; - -export default EventCard; +} diff --git a/src/app/event/WienerLinienSection.tsx b/src/app/event/WienerLinienSection.tsx new file mode 100644 index 0000000..23a6c02 --- /dev/null +++ b/src/app/event/WienerLinienSection.tsx @@ -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 ( +
+ +

Loading nearby stops...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (stops.length === 0) { + return ( +
+

No nearby stops found.

+
+ ); + } + + const departuresByStop = new Map(); + for (const departure of departures) { + const stopId = departure.stopId; + const existing = departuresByStop.get(stopId) ?? []; + existing.push(departure); + departuresByStop.set(stopId, existing); + } + + return ( +
+ {stops.map((stop) => { + const stopDepartures = departuresByStop.get(stop.id) ?? []; + + return ( +
+

{stop.name}

+ + {stopDepartures.length === 0 ? ( +

No departures available

+ ) : ( +
    + {stopDepartures.map((departure, index) => ( +
  • + {departure.lineName} + {departure.direction} + + {departure.minutes} min + +
  • + ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/app/event/__tests__/EventCard.test.tsx b/src/app/event/__tests__/EventCard.test.tsx index 9012b1d..c0d7535 100644 --- a/src/app/event/__tests__/EventCard.test.tsx +++ b/src/app/event/__tests__/EventCard.test.tsx @@ -1,6 +1,11 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import EventCard from "../EventCard"; +import { useDestinationStation } from "@/hooks/useDestinationStation"; +import { useJourneys } from "@/hooks/useJourneys"; + +// vi.mock hoists, so these imports are the mocked versions +// We can inspect their call arguments directly // Mock all hooks that EventCard depends on vi.mock("@/hooks/useGeolocation", () => ({ @@ -12,11 +17,11 @@ vi.mock("@/hooks/useGeolocation", () => ({ })); vi.mock("@/hooks/useDestinationStation", () => ({ - useDestinationStation: () => ({ + useDestinationStation: vi.fn(() => ({ station: { name: "Wien Hbf", extId: "0WB0F0001500" }, loading: false, error: null, - }), + })), })); vi.mock("@/hooks/useGeocode", () => ({ @@ -28,11 +33,11 @@ vi.mock("@/hooks/useGeocode", () => ({ })); vi.mock("@/hooks/useJourneys", () => ({ - useJourneys: () => ({ + useJourneys: vi.fn(() => ({ journeys: [], loading: false, error: null, - }), + })), })); vi.mock("@/hooks/useBikeRoute", () => ({ @@ -43,6 +48,13 @@ vi.mock("@/hooks/useBikeRoute", () => ({ }), })); +vi.mock("@/hooks/useClock", () => ({ + useClock: () => ({ + countdown: { label: "No deadline set", color: "text-gray-400", urgent: false }, + status: "upcoming", + }), +})); + vi.mock("@/lib/countdown-utils", () => ({ calculateCountdown: () => ({ label: "No deadline set", @@ -52,6 +64,22 @@ vi.mock("@/lib/countdown-utils", () => ({ })); describe("EventCard", () => { + beforeEach(() => { + (useDestinationStation as ReturnType).mockReset(); + (useDestinationStation as ReturnType).mockImplementation(() => ({ + station: { name: "Wien Hbf", extId: "0WB0F0001500" }, + loading: false, + error: null, + })); + + (useJourneys as ReturnType).mockReset(); + (useJourneys as ReturnType).mockImplementation(() => ({ + journeys: [], + loading: false, + error: null, + })); + }); + it("renders event title and destination", () => { const mockEvent = { id: "test-1", @@ -67,4 +95,40 @@ describe("EventCard", () => { expect(screen.getByText("Team Meeting")).toBeInTheDocument(); expect(screen.getByText("Wien Hbf")).toBeInTheDocument(); }); + + it("passes destStation.station.extId to useJourneys as the destination argument", () => { + const mockEvent = { + id: "test-2", + title: "Lunch at Hofburg", + destination: "Hofburg", + eventTime: new Date("2025-12-01T12:00:00"), + source: "manual" as const, + }; + + const mockStation = { name: "Graz Hbf", extId: "0WB0F0000600" }; + + render(); + + // useDestinationStation should be called with the event's destination string + expect(useDestinationStation).toHaveBeenCalledWith("Hofburg"); + + // useJourneys should be called with origin extId as first arg + // and the destination station's extId as second arg (NOT null) + expect(useJourneys).toHaveBeenCalledWith("0WB0F0000600", "0WB0F0001500", new Date("2025-12-01T12:00:00"), 0); + }); + + it("passes null as originStation extId to useJourneys when originStation is null", () => { + const mockEvent = { + id: "test-3", + title: "Meeting without origin", + destination: "Parapluie", + eventTime: new Date("2025-12-01T10:00:00"), + source: "manual" as const, + }; + + render(); + + // When originStation is null, the first arg to useJourneys should be null + expect(useJourneys).toHaveBeenCalledWith(null, "0WB0F0001500", new Date("2025-12-01T10:00:00"), 0); + }); }); diff --git a/src/app/event/__tests__/WienerLinienSection.test.tsx b/src/app/event/__tests__/WienerLinienSection.test.tsx new file mode 100644 index 0000000..1933a96 --- /dev/null +++ b/src/app/event/__tests__/WienerLinienSection.test.tsx @@ -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
Loading...
; + }, +})); + +vi.mock("@/app/ui/Chip", () => ({ + default: function MockChip({ children }: { children: React.ReactNode }) { + return {children}; + }, +})); + +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(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + expect(screen.getByText("Loading nearby stops...")).toBeInTheDocument(); + }); + + it("renders error message when error string is provided", () => { + render(); + expect(screen.getByText("Failed to fetch stops")).toBeInTheDocument(); + }); + + it("renders stop names and departure line badges correctly", () => { + render(); + 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(); + 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(); + expect(screen.getByText("No nearby stops found.")).toBeInTheDocument(); + }); +}); diff --git a/src/app/ui/CountdownBadge.tsx b/src/app/ui/CountdownBadge.tsx new file mode 100644 index 0000000..bcaa6bb --- /dev/null +++ b/src/app/ui/CountdownBadge.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import Chip from "./Chip"; + +const colorMap: Record = { + 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 = ({ countdown, status, className = "" }) => { + const bgColor = colorMap[countdown.color] || colorMap.blue; + const isUrgent = countdown.urgent || status === "now"; + + return ( + + {countdown.label} + + ); +}; + +export default CountdownBadge; diff --git a/src/hooks/__tests__/useWienerLinien.test.ts b/src/hooks/__tests__/useWienerLinien.test.ts new file mode 100644 index 0000000..e6faadf --- /dev/null +++ b/src/hooks/__tests__/useWienerLinien.test.ts @@ -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(); + }); +}); diff --git a/src/hooks/useClock.ts b/src/hooks/useClock.ts index 2081bd2..27eaca4 100644 --- a/src/hooks/useClock.ts +++ b/src/hooks/useClock.ts @@ -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; + status: "upcoming" | "now" | "past"; +} + +export function useClock(targetDate: Date): ClockResult { const [now, setNow] = useState(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]); } diff --git a/src/hooks/useOriginStation.ts b/src/hooks/useOriginStation.ts index f07371f..3aee6a6 100644 --- a/src/hooks/useOriginStation.ts +++ b/src/hooks/useOriginStation.ts @@ -7,6 +7,8 @@ interface HafasLocation { type: string; name: string; extId: string; + lat: number; + lon: number; } export function useOriginStation() { @@ -65,7 +67,7 @@ export function useOriginStation() { const match = data?.svcResL?.[0]?.res?.match?.locL ?? []; const stations: Station[] = (match as HafasLocation[]) .filter((l) => l.type === "S") - .map((l) => ({ name: l.name, extId: l.extId })); + .map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon })); if (!isMounted) return; diff --git a/src/hooks/useWienerLinien.ts b/src/hooks/useWienerLinien.ts new file mode 100644 index 0000000..da6cc3c --- /dev/null +++ b/src/hooks/useWienerLinien.ts @@ -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([]); + const [departures, setDepartures] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const stopIdsRef = useRef([]); + const abortRef = useRef(null); + const cancelledRef = useRef(false); + + const fetchMonitor = useCallback(async (stopIds: string[], signal: AbortSignal): Promise => { + 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 }; +} diff --git a/src/lib/__tests__/constants.test.ts b/src/lib/__tests__/constants.test.ts new file mode 100644 index 0000000..97ade97 --- /dev/null +++ b/src/lib/__tests__/constants.test.ts @@ -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"); + }); +}); diff --git a/src/lib/__tests__/wienerlinien-client.test.ts b/src/lib/__tests__/wienerlinien-client.test.ts new file mode 100644 index 0000000..5bf6e23 --- /dev/null +++ b/src/lib/__tests__/wienerlinien-client.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-service", () => { + const MockApiClient = vi.fn(); + MockApiClient.mockImplementation(function () { + return { + get: vi.fn(), + }; + }); + return { + ApiClient: MockApiClient, + }; +}); + +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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { 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).mockImplementation(function () { + return { get: vi.fn() }; + }); + + new WienerLinienClient(); + + expect(ApiClient).toHaveBeenCalledWith({ baseUrl: "https://api.wienerlinien.at/darwin-v2" }); + }); + + it("uses custom base URL when provided", () => { + (ApiClient as ReturnType).mockImplementation(function () { + return { get: vi.fn() }; + }); + + new WienerLinienClient("https://custom.api.example.com/v1"); + + expect(ApiClient).toHaveBeenCalledWith({ baseUrl: "https://custom.api.example.com/v1" }); + }); + }); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2905747..3de357d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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"; diff --git a/src/lib/wienerlinien-client.ts b/src/lib/wienerlinien-client.ts new file mode 100644 index 0000000..e3a0e6d --- /dev/null +++ b/src/lib/wienerlinien-client.ts @@ -0,0 +1,110 @@ +import { WIENER_LINIEN_API_URL } from "@/lib/constants"; +import { ApiClient } from "@/lib/api-service"; +import type { NearbyStop, WienerLinienDeparture, WienerLinienLine, WienerLinienMonitorResponse } from "@/types"; + +export class WienerLinienClient { + private readonly client: ApiClient; + + constructor(baseUrl: string = WIENER_LINIEN_API_URL) { + this.client = new ApiClient({ baseUrl }); + } + + async findNearbyStops(lat: number, lng: number, radius: number): Promise { + const cacheKey = `wl-nearby-${lat.toFixed(4)}-${lng.toFixed(4)}-${radius}`; + + const raw = await this.client.get( + "/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 { + const sortedIds = [...stopIds].sort(); + const cacheKey = `wl-monitor-${sortedIds.join(",")}`; + + const raw = await this.client.get("/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; + + 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; + 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; + + 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; + + 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 }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 906f7d1..898c1e3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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,35 +43,20 @@ 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"; +<<<<<<< HEAD export type CalStatus = null | "loading" | "ok" | "error"; // ── Reminder Settings ─────────────────────────────────────── @@ -94,4 +64,49 @@ export type CalStatus = null | "loading" | "ok" | "error"; export interface ReminderSettings { bufferMinutes: number; enabled: boolean; +======= + +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; +>>>>>>> feature/wienerlinien }