Refactor API routes and hooks to use library clients

- Wire `api/geocode` and `api/bike-route` to use `GeocodingClient`
  and `BikeRoutingClient` instead of raw fetch calls
- Move `parseHafasJourneys` from `useJourneys` to `hafas-client`
- Remove dead code `src/lib/live-status-utils.ts`
- Update test setup to import `@testing-library/jest-dom/vitest`
This commit is contained in:
2026-05-10 03:23:07 +02:00
parent 77b8c6db98
commit 28c25c32ab
9 changed files with 108 additions and 282 deletions
+4 -4
View File
@@ -21,19 +21,19 @@ Fix bugs that crash the app or lose user data.
Eliminate duplicated logic so each integration has one source of truth. Eliminate duplicated logic so each integration has one source of truth.
- [ ] **Step 4: Consolidate HAFAS Journey Parsing** (~40 min) - [x] **Step 4: Consolidate HAFAS Journey Parsing** (~40 min)
- Move `parseHafasJourneys` from `useJourneys.ts` into `hafas-client.ts` and export it; import from `useJourneys.ts` (Option A — minimal risk) - Move `parseHafasJourneys` from `useJourneys.ts` into `hafas-client.ts` and export it; import from `useJourneys.ts` (Option A — minimal risk)
- Files: `src/lib/hafas-client.ts`, `src/hooks/useJourneys.ts` - Files: `src/lib/hafas-client.ts`, `src/hooks/useJourneys.ts`
- [ ] **Step 5: Wire API Routes to Use Library Clients** (~30 min) - [x] **Step 5: Wire API Routes to Use Library Clients** (~30 min)
- Replace raw `fetch()` in `api/geocode/route.ts` with `GeocodingClient`, and in `api/bike-route/route.ts` with `BikeRoutingClient` (module-level singleton, proper error handling) - Replace raw `fetch()` in `api/geocode/route.ts` with `GeocodingClient`, and in `api/bike-route/route.ts` with `BikeRoutingClient` (module-level singleton, proper error handling)
- Files: `src/app/api/geocode/route.ts`, `src/app/api/bike-route/route.ts` - Files: `src/app/api/geocode/route.ts`, `src/app/api/bike-route/route.ts`
- [ ] **Step 6: Remove Dead Code** (~5 min) - [x] **Step 6: Remove Dead Code** (~5 min)
- Delete `src/lib/live-status-utils.ts` entirely; remove its export from `src/lib/index.ts` - Delete `src/lib/live-status-utils.ts` entirely; remove its export from `src/lib/index.ts`
- File: `src/lib/live-status-utils.ts`, `src/lib/index.ts` - File: `src/lib/live-status-utils.ts`, `src/lib/index.ts`
- [ ] **Step 7: Create Missing Test Setup File** (~10 min) - [x] **Step 7: Create Missing Test Setup File** (~10 min)
- Create `src/test/setup.ts` with `import "@testing-library/jest-dom/vitest"` so jsdom matchers are registered globally - Create `src/test/setup.ts` with `import "@testing-library/jest-dom/vitest"` so jsdom matchers are registered globally
- File: `src/test/setup.ts` - File: `src/test/setup.ts`
+21 -50
View File
@@ -1,14 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { GET } from "../bike-route/route";
// Mock the fetch function to avoid making actual HTTP requests const mockGetBikeRoute = vi.fn();
global.fetch = vi.fn();
vi.mock("@/lib/bike-routing-client", () => ({
BikeRoutingClient: class MockBikeRoutingClient {
getBikeRoute = mockGetBikeRoute;
},
}));
const { GET } = await import("../bike-route/route");
describe("api/bike-route/route", () => { describe("api/bike-route/route", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); mockGetBikeRoute.mockReset();
global.fetch = vi.fn();
}); });
it("should return error when no required parameters are provided", async () => { it("should return error when no required parameters are provided", async () => {
@@ -21,40 +26,14 @@ describe("api/bike-route/route", () => {
}); });
it("should handle valid bike route request", async () => { it("should handle valid bike route request", async () => {
// Mock successful fetch response mockGetBikeRoute.mockResolvedValue({
const mockResponse = { distance: 1500,
ok: true, duration: 300,
json: vi.fn().mockResolvedValue({ steps: [
distance: 1500, { name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
duration: 300, { name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" },
routes: [ ],
{ });
distance: 1500,
duration: 300,
legs: [
{
steps: [
{
name: "Start",
distance: 100,
duration: 10,
maneuver: { instruction: "Go straight", type: "straight" },
},
{
name: "Turn left",
distance: 200,
duration: 20,
maneuver: { instruction: "Turn left at the corner", type: "turn", modifier: "left" },
},
],
},
],
},
],
}),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
const request = new NextRequest( const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800", "http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
@@ -68,8 +47,8 @@ describe("api/bike-route/route", () => {
expect(data).toHaveProperty("steps"); expect(data).toHaveProperty("steps");
}); });
it("should handle fetch error", async () => { it("should handle client error", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Network error")); mockGetBikeRoute.mockRejectedValue(new Error("Network error"));
const request = new NextRequest( const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800", "http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
@@ -82,15 +61,7 @@ describe("api/bike-route/route", () => {
}); });
it("should handle no route found", async () => { it("should handle no route found", async () => {
// Mock fetch response with empty routes mockGetBikeRoute.mockResolvedValue(null);
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
routes: [],
}),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
const request = new NextRequest( const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800", "http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
+11 -42
View File
@@ -1,62 +1,31 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { OSRM_URL } from "@/lib/constants"; import { BikeRoutingClient } from "@/lib/bike-routing-client";
import { BikeRoute } from "@/types";
interface OsrmStep { // Module-level singleton — cache persists across requests
name: string; const client = new BikeRoutingClient();
distance: number;
duration: number;
maneuver: { instruction?: string; type: string; modifier?: string };
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fromLat = searchParams.get("fromLat"); const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = searchParams.get("fromLng"); const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = searchParams.get("toLat"); const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = searchParams.get("toLng"); const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (!fromLat || !fromLng || !toLat || !toLng) { if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
return NextResponse.json( return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" }, { error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 }, { status: 400 },
); );
} }
// Build OSRM URL const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
const url = new URL(`${OSRM_URL}/route/v1/bicycle/${fromLng},${fromLat};${toLng},${toLat}`);
url.searchParams.append("overview", "false");
const response = await fetch(url.toString(), { if (!route) {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
return NextResponse.json({ error: "Bike routing request failed" }, { status: response.status });
}
const data = await response.json();
if (!data.routes || data.routes.length === 0) {
return NextResponse.json({ error: "No route found" }, { status: 404 }); return NextResponse.json({ error: "No route found" }, { status: 404 });
} }
const route = data.routes[0]; return NextResponse.json(route);
const bikeRoute: BikeRoute = {
distance: route.distance,
duration: route.duration,
steps:
route.legs?.[0]?.steps?.map((step: OsrmStep) => ({
name: step.name || "",
distance: step.distance,
duration: step.duration,
instruction: step.maneuver.instruction,
})) || [],
};
return NextResponse.json(bikeRoute);
} catch (error) { } catch (error) {
console.error("Bike route API error:", error); console.error("Bike route API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+6 -57
View File
@@ -1,10 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "@/lib/constants"; import { GeocodingClient } from "@/lib/geocoding-client";
import { GeocodeResult } from "@/types";
// Simple in-memory cache with TTL // Module-level singleton — cache persists across requests
const cache = new Map<string, { result: GeocodeResult; timestamp: number }>(); const client = new GeocodingClient();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -16,62 +14,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 }); return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
} }
// Create cache key const results = await client.geocode(name, countrycodes || undefined);
const cacheKey = `${name}|${countrycodes || ""}`;
// Check cache if (results.length === 0) {
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(cached.result);
}
// Build URL with parameters
const url = new URL(`${NOMINATIM_URL}/search`);
url.searchParams.append("q", name);
url.searchParams.append("format", "json");
url.searchParams.append("limit", "1");
if (countrycodes) {
url.searchParams.append("countrycodes", countrycodes);
}
let response: Response;
try {
response = await fetch(url.toString(), {
headers: {
"User-Agent": NOMINATIM_USER_AGENT,
},
});
} catch (error) {
console.error("Geocoding fetch failed:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
if (!response.ok) {
// If response is not ok, return error status without caching.
return NextResponse.json({ error: "Geocoding request failed" }, { status: response.status });
}
const data = await response.json();
if (!data || data.length === 0) {
// If no results, return error status without caching.
return NextResponse.json({ error: "No results found" }, { status: 404 }); return NextResponse.json({ error: "No results found" }, { status: 404 });
} }
const result: GeocodeResult = { return NextResponse.json(results[0]);
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
display_name: data[0].display_name,
};
// Cache the result ONLY on success path
cache.set(cacheKey, {
result,
timestamp: Date.now(),
});
return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error("Geocode API error:", error); console.error("Geocode API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+8 -61
View File
@@ -1,67 +1,14 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { Journey } from "@/types"; import type { Journey } from "@/types";
import { parseHafasTime, hafasDateTime } from "@/lib/hafas-time"; import { parseHafasJourneys } from "@/lib/hafas-client";
import { hafasDateTime } from "@/lib/hafas-time";
interface HafasJourney { export function useJourneys(
ctxRecon?: string; fromStationExtId: string | null,
secL?: Array<{ toStationExtId: string | null,
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string }; date: Date,
arr?: { aTimeS?: string; aTimeR?: string }; refreshKey = 0,
jny?: { ) {
prodX?: number;
stopL?: Array<{ name: string }>;
dlySum?: number;
isCncl?: boolean;
};
chgDurR?: number;
}>;
}
function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
// HAFAS response shape is too complex for a clean TypeScript type; use any for parsing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
const data = json as any;
const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? [];
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
const last = con.secL?.[con.secL.length - 1];
const dep = first?.dep;
const arr = last?.arr;
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate;
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
const delayMs = rD.getTime() - sD.getTime();
const delay = Math.max(0, Math.round(delayMs / 60000));
const trains = (con.secL ?? [])
.filter((s) => s.jny)
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
.filter(Boolean);
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
const platform = first?.dep?.dPlatfS ?? "";
return {
id: con.ctxRecon ?? `journey-${i}`,
sD,
rD,
sA,
rA,
delay,
platform,
changes,
trains,
cancelled,
};
});
}
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date, refreshKey = 0) {
const [journeys, setJourneys] = useState<Journey[]>([]); const [journeys, setJourneys] = useState<Journey[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
+57 -43
View File
@@ -3,9 +3,9 @@ import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
import { parseHafasTime, hafasDateTime } from "./hafas-time"; import { parseHafasTime, hafasDateTime } from "./hafas-time";
import { ApiClient } from "./api-service"; import { ApiClient } from "./api-service";
// --------------------------------------------------------------------------- // -------------------------------------------------------------
// HAFAS response types (internal, not exported) // HAFAS response types (internal, not exported)
// --------------------------------------------------------------------------- // -------------------------------------------------------------
interface HafasLocation { interface HafasLocation {
type: "S" | "A" | "P"; type: "S" | "A" | "P";
@@ -23,7 +23,7 @@ interface HafasLocationResponse {
}>; }>;
} }
interface HafasJourney { export interface HafasJourney {
ctxRecon?: string; ctxRecon?: string;
secL?: Array<{ secL?: Array<{
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string }; dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
@@ -46,9 +46,60 @@ interface HafasTripResponse {
}>; }>;
} }
// --------------------------------------------------------------------------- // -------------------------------------------------------------
// Shared HAFAS journey parser
// -------------------------------------------------------------
/**
* Parse a raw HAFAS response into Journey[].
* Works with both typed and untyped (raw JSON) responses.
*/
export function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
const data = json as any;
const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? [];
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
const last = con.secL?.[con.secL.length - 1];
const dep = first?.dep;
const arr = last?.arr;
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate;
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
const delayMs = rD.getTime() - sD.getTime();
const delay = Math.max(0, Math.round(delayMs / 60000));
const trains = (con.secL ?? [])
.filter((s) => s.jny)
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
.filter(Boolean);
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
const platform = first?.dep?.dPlatfS ?? "";
return {
id: con.ctxRecon ?? `journey-${i}`,
sD,
rD,
sA,
rA,
delay,
platform,
changes,
trains,
cancelled,
};
});
}
// -------------------------------------------------------------
// HafasClient — Wraps the ÖBB HAFAS journey-planning API // HafasClient — Wraps the ÖBB HAFAS journey-planning API
// --------------------------------------------------------------------------- // -------------------------------------------------------------
export class HafasClient { export class HafasClient {
private client: ApiClient; private client: ApiClient;
@@ -103,44 +154,7 @@ export class HafasClient {
const res = await this.client.post<HafasTripResponse>("", body); const res = await this.client.post<HafasTripResponse>("", body);
const outConL: HafasJourney[] = res?.svcResL?.[0]?.res?.outConL ?? []; return parseHafasJourneys(res, hafasDate, date);
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
const last = con.secL?.[con.secL.length - 1];
const dep = first?.dep;
const arr = last?.arr;
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : date;
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
const delayMs = rD.getTime() - sD.getTime();
const delay = Math.max(0, Math.round(delayMs / 60000));
const trains = (con.secL ?? [])
.filter((s) => s.jny)
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
.filter(Boolean);
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
const platform = first?.dep?.dPlatfS ?? "";
return {
id: con.ctxRecon ?? `journey-${i}`,
sD,
rD,
sA,
rA,
delay,
platform,
changes,
trains,
cancelled,
};
});
} }
/** /**
-1
View File
@@ -5,7 +5,6 @@ export * from "./geocoding-client";
export * from "./bike-routing-client"; export * from "./bike-routing-client";
export * from "./calendar-utils"; export * from "./calendar-utils";
export * from "./status-utils"; export * from "./status-utils";
export * from "./live-status-utils";
export * from "./countdown-utils"; export * from "./countdown-utils";
export * from "./formatting"; export * from "./formatting";
export * from "./constants"; export * from "./constants";
-23
View File
@@ -1,23 +0,0 @@
// Live status utilities for TimeToLeave
import type { LiveStatus } from "@/types";
export class LiveStatusUtils {
static async getLiveStatus(_journeyId: string): Promise<LiveStatus> {
// This would make an actual API call to get live status
// For now, we'll return mock data
return null;
}
static getStatusMessage(status: LiveStatus): string {
switch (status) {
case true:
return "On time";
case false:
return "Delayed or cancelled";
case null:
return "No live data";
default:
return "Unknown status";
}
}
}
+1 -1
View File
@@ -1 +1 @@
import "@testing-library/jest-dom"; import "@testing-library/jest-dom/vitest";