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:
+4
-4
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|||||||
Reference in New Issue
Block a user