Refactor API clients and update project structure for TimeToLeave

This commit is contained in:
2026-05-10 01:47:22 +02:00
parent 7c345785a7
commit a7fcbd811e
23 changed files with 12680 additions and 1181 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8" ?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/oebb_planner.iml" filepath="$PROJECT_DIR$/.idea/oebb_planner.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/TimeToLeave.iml" filepath="$PROJECT_DIR$/.idea/TimeToLeave.iml" />
</modules>
</component>
</project>
</project>
+5
View File
@@ -0,0 +1,5 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{}
+1 -1
View File
@@ -101,7 +101,7 @@ Each event card shows both travel modes side by side:
## 3. Target Architecture
```
oebb_planner/
TimeToLeave/
├── package.json
├── next.config.ts
├── tsconfig.json
+11413 -968
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,8 +13,8 @@
},
"dependencies": {
"date-fns": "^4.1.0",
"next": "16.2.6",
"node-ical": "^0.18.0",
"next": "^9.3.3",
"node-ical": "^0.26.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
+4 -9
View File
@@ -2,19 +2,18 @@
import React from "react";
import { format } from "date-fns";
import { Event, TripDataEntry } from "@/types";
import { Event, Station } from "@/types";
import EventCard from "@/app/event/EventCard";
type DayEventsProps = {
events: Event[];
date: Date;
tripData?: Record<string, TripDataEntry>; // Map of event id to trip data
originStation: Station | null;
className?: string;
};
const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, className = "" }) => {
const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, className = "" }) => {
const filteredEvents = events.filter((event) => {
// Direct Date comparison instead of parseISO
const eventDate = new Date(event.eventTime);
return (
eventDate.getDate() === date.getDate() &&
@@ -36,11 +35,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, clas
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
<div className="space-y-3">
{filteredEvents.map((event) => (
<EventCard
key={event.id}
event={event}
tripData={tripData[event.id] || { journeys: [], destName: "", demo: false, loading: false }}
/>
<EventCard key={event.id} event={event} originStation={originStation} />
))}
</div>
</div>
+6 -32
View File
@@ -1,42 +1,16 @@
"use client";
import React from "react";
import { Event } from "@/types";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents";
export default function CalendarPage() {
// Mock events for demonstration
const events: Event[] = [
{
id: "1",
title: "Meeting with team",
destination: "Berlin",
eventTime: new Date(new Date().setDate(new Date().getDate() + 1)), // Tomorrow
source: "manual",
},
{
id: "2",
title: "Train trip to Munich",
destination: "Munich",
eventTime: new Date(new Date().setDate(new Date().getDate() + 2)), // Day after tomorrow
source: "manual",
},
{
id: "3",
title: "Conference in Vienna",
destination: "Vienna",
eventTime: new Date(new Date().setDate(new Date().getDate() + 3)), // In 3 days
source: "calendar",
},
];
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
const handleDateSelect = (date: Date) => {
setSelectedDate(date);
};
return (
<div className="max-w-6xl mx-auto p-4">
<div className="mb-6">
@@ -46,10 +20,10 @@ export default function CalendarPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<CalendarView events={events} onDateSelect={handleDateSelect} selectedDate={selectedDate} />
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
</div>
<div>
<DayEvents events={events} date={selectedDate} />
<DayEvents events={events} date={selectedDate} originStation={originStation} />
</div>
</div>
</div>
+39 -17
View File
@@ -1,27 +1,47 @@
'use client';
import React from 'react';
import { Event, TripDataEntry } from '@/types';
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';
type EventCardProps = {
event: Event;
tripData: TripDataEntry;
originStation: Station | null;
className?: string;
};
const EventCard: React.FC<EventCardProps> = ({
event,
tripData,
className = '',
}) => {
const EventCard: React.FC<EventCardProps> = ({ event, originStation, className = '' }) => {
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const { location } = useGeolocation();
const { station: destStation } = useDestinationStation(event.destination);
const { coords: destCoords } = useGeocode(event.destination);
const { journeys, loading, error } = useJourneys(
originStation?.extId ?? null,
destStation?.extId ?? null,
event.eventTime,
refreshKey,
);
const { bikeRoute, loading: bikeLoading, error: bikeError } = useBikeRoute(
location?.coords.latitude,
location?.coords.longitude,
destCoords?.lat,
destCoords?.lng,
);
return (
<div className={
`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`
}>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between">
<div className="flex-1">
@@ -35,15 +55,17 @@ const EventCard: React.FC<EventCardProps> = ({
</div>
<div className="p-4">
<TrainSection
journeys={tripData.journeys}
journeys={journeys}
eventTime={event.eventTime}
destName={event.destination}
loading={tripData.loading}
destName={destStation?.name ?? event.destination}
loading={loading}
error={error}
onRefresh={handleRefresh}
/>
<BikeSection
bikeRoute={tripData.bikeRoute}
bikeLoading={tripData.bikeLoading || false}
bikeError={tripData.bikeError}
bikeRoute={bikeRoute}
bikeLoading={bikeLoading}
bikeError={bikeError}
/>
</div>
</div>
+4
View File
@@ -12,6 +12,7 @@ type TrainSectionProps = {
eventTime: Date;
destName: string;
loading: boolean;
error?: string | null;
onRefresh?: () => void;
className?: string;
};
@@ -21,6 +22,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
eventTime,
destName,
loading,
error,
onRefresh,
className = "",
}) => {
@@ -46,6 +48,8 @@ const TrainSection: React.FC<TrainSectionProps> = ({
<div className="p-8 text-center">
<LoadingSpinner size="lg" />
</div>
) : error ? (
<div className="p-8 text-center text-red-600 dark:text-red-400 text-sm">{error}</div>
) : (
<JourneyList journeys={journeys} eventTime={eventTime} />
)}
+10 -1
View File
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { EventsProvider } from "@/hooks/useEventsStore";
import Header from "@/app/layout/Header";
import Navbar from "@/app/layout/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -24,7 +27,13 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
<EventsProvider>
<Header />
<div className="flex-1">{children}</div>
<Navbar />
</EventsProvider>
</body>
</html>
);
}
+24 -45
View File
@@ -1,52 +1,31 @@
import Image from "next/image";
"use client";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
import EventCard from "@/app/event/EventCard";
export default function Home() {
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
const upcoming = events
.filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<main className="max-w-3xl mx-auto w-full px-4 py-6">
{upcoming.length === 0 ? (
<div className="text-center py-16 text-gray-500 dark:text-gray-400">
<p className="text-lg font-medium">No upcoming events</p>
<p className="text-sm mt-1">Use &quot;Add Event&quot; to add one, or import from the Calendar.</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-39.5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image className="dark:invert" src="/vercel.svg" alt="Vercel logomark" width={16} height={16} />
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/8 px-5 transition-colors hover:border-transparent hover:bg-black/4 dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-39.5"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
) : (
<div className="space-y-4">
{upcoming.map((event) => (
<EventCard key={event.id} event={event} originStation={originStation} />
))}
</div>
</main>
</div>
)}
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState, useEffect } from "react";
import type { Station } from "@/types";
interface HafasLocation {
type: string;
name: string;
extId: string;
}
export function useDestinationStation(destination: string) {
const [station, setStation] = useState<Station | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination.trim()) return;
let isMounted = true;
setLoading(true);
setError(null);
const fetchStation = async () => {
try {
const body = {
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { name: destination, type: "S" }, maxLoc: 1 } },
},
],
};
const response = await fetch("/api/hafas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
throw new Error(errBody.error ?? `Station lookup failed with status ${response.status}`);
}
const data = await response.json();
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
const stations = locL.filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId }));
if (!isMounted) return;
setStation(stations[0] ?? null);
setLoading(false);
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : "Station lookup failed");
setLoading(false);
}
}
};
fetchStation();
return () => {
isMounted = false;
};
}, [destination]);
return { station, loading, error };
}
+32 -20
View File
@@ -1,6 +1,22 @@
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
"use client";
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import type { Event } from "@/types";
const STORAGE_KEY = "ttl_events";
function loadFromStorage(): Event[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime as string) })) as Event[];
} catch {
return [];
}
}
interface EventsContextType {
events: Event[];
addEvent: (event: Event) => void;
@@ -13,41 +29,37 @@ interface EventsContextType {
const EventsContext = createContext<EventsContextType | undefined>(undefined);
export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEvents] = useState<Event[]>([]);
const [events, setEventsState] = useState<Event[]>(loadFromStorage);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
}, [events]);
const addEvent = useCallback((event: Event) => {
setEvents((prev) => {
const exists = prev.some((e) => e.id === event.id);
if (exists) {
return prev;
}
setEventsState((prev) => {
if (prev.some((e) => e.id === event.id)) return prev;
return [...prev, event];
});
}, []);
const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
setEvents((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
setEventsState((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
}, []);
const removeEvent = useCallback((id: string) => {
setEvents((prev) => prev.filter((event) => event.id !== id));
setEventsState((prev) => prev.filter((event) => event.id !== id));
}, []);
const clearEvents = useCallback(() => {
setEvents([]);
setEventsState([]);
}, []);
const setEvents = useCallback((evts: Event[]) => {
setEventsState(evts);
}, []);
return (
<EventsContext.Provider
value={{
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents,
}}
>
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents }}>
{children}
</EventsContext.Provider>
);
+42
View File
@@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
export function useGeocode(destination: string) {
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination.trim()) return;
let isMounted = true;
setLoading(true);
setError(null);
const fetchCoords = async () => {
try {
const res = await fetch(`/api/geocode?name=${encodeURIComponent(destination)}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Geocoding failed with status ${res.status}`);
}
const data = await res.json();
if (isMounted) {
setCoords({ lat: data.lat, lng: data.lng });
setLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : "Geocoding failed");
setLoading(false);
}
}
};
fetchCoords();
return () => {
isMounted = false;
};
}, [destination]);
return { coords, loading, error };
}
+2 -2
View File
@@ -61,7 +61,7 @@ function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date):
});
}
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date) {
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date, refreshKey = 0) {
const [journeys, setJourneys] = useState<Journey[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
@@ -127,7 +127,7 @@ export function useJourneys(fromStationExtId: string | null, toStationExtId: str
return () => {
isMounted = false;
};
}, [fromStationExtId, toStationExtId, date]);
}, [fromStationExtId, toStationExtId, date, refreshKey]);
return { journeys, loading, error };
}
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import type { Station } from "@/types";
import { DEFAULT_STATION_NAME, DEFAULT_STATION_EXT_ID } from "@/lib/constants";
import { useGeolocation } from "./useGeolocation";
interface HafasLocation {
@@ -20,7 +21,7 @@ export function useOriginStation() {
const fetchNearestStation = async () => {
if (!location || state !== "granted") {
if (isMounted) {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
}
return;
}
@@ -71,14 +72,14 @@ export function useOriginStation() {
if (stations.length > 0) {
setStation(stations[0]);
} else {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
}
setLoading(false);
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : "Failed to find nearest station";
setError(message);
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
setLoading(false);
}
}
+398
View File
@@ -0,0 +1,398 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MemoryCache, ApiClient, ApiError, fetchWithRetry, cachedFetch, calculateBackoff, sleep } from "../api-service";
// ---------------------------------------------------------------------------
// MemoryCache Tests
// ---------------------------------------------------------------------------
describe("MemoryCache", () => {
let cache: MemoryCache<string>;
beforeEach(() => {
vi.useFakeTimers();
cache = new MemoryCache({ defaultTtlMs: 5000 });
});
afterEach(() => {
vi.useRealTimers();
});
it("stores and retrieves values", () => {
cache.set("key", "value");
expect(cache.get("key")).toBe("value");
});
it("returns null for missing keys", () => {
expect(cache.get("missing")).toBeNull();
});
it("expires entries after TTL", () => {
cache.set("key", "value");
expect(cache.get("key")).toBe("value");
vi.advanceTimersByTime(6000);
expect(cache.get("key")).toBeNull();
});
it("supports per-entry TTL override", () => {
cache.set("key", "value", 10000);
vi.advanceTimersByTime(6000);
expect(cache.get("key")).toBe("value");
vi.advanceTimersByTime(5000);
expect(cache.get("key")).toBeNull();
});
it("invalidates specific keys", () => {
cache.set("key", "value");
expect(cache.invalidate("key")).toBe(true);
expect(cache.get("key")).toBeNull();
expect(cache.invalidate("key")).toBe(false);
});
it("clears all entries", () => {
cache.set("a", "1");
cache.set("b", "2");
cache.clear();
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBeNull();
});
it("evicts oldest entry when max size exceeded", () => {
const smallCache = new MemoryCache<string>({ defaultTtlMs: 60_000, maxSize: 2 });
smallCache.set("first", "1");
vi.advanceTimersByTime(100);
smallCache.set("second", "2");
vi.advanceTimersByTime(100);
smallCache.set("third", "3");
// Oldest entry should be evicted
expect(smallCache.get("first")).toBeNull();
expect(smallCache.get("second")).toBe("2");
expect(smallCache.get("third")).toBe("3");
});
it("tracks hit/miss statistics", () => {
cache.set("key", "value");
cache.get("key"); // hit
cache.get("missing"); // miss
cache.get("key"); // hit
const stats = cache.stats();
expect(stats.hits).toBe(2);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBeCloseTo(0.6667, 2);
});
});
// ---------------------------------------------------------------------------
// calculateBackoff Tests
// ---------------------------------------------------------------------------
describe("calculateBackoff", () => {
it("doubles delay exponentially", () => {
// Attempt 0: baseDelay * 2^0 = 100
const d0 = calculateBackoff(0, 100, 10000, 0);
expect(d0).toBeGreaterThanOrEqual(100);
expect(d0).toBeLessThanOrEqual(100);
// Attempt 1: baseDelay * 2^1 = 200
const d1 = calculateBackoff(1, 100, 10000, 0);
expect(d1).toBe(200);
// Attempt 2: baseDelay * 2^2 = 400
const d2 = calculateBackoff(2, 100, 10000, 0);
expect(d2).toBe(400);
});
it("caps delay at maxDelayMs", () => {
const d = calculateBackoff(10, 100, 500, 0);
expect(d).toBe(500);
});
it("adds jitter within expected range", () => {
// With jitter=0.3 and base delay 1000, max delay 10000
// Attempt 0: 1000 + [0, 300]
const d = calculateBackoff(0, 1000, 10000, 0.3);
expect(d).toBeGreaterThanOrEqual(1000);
expect(d).toBeLessThanOrEqual(1300);
});
});
// ---------------------------------------------------------------------------
// fetchWithRetry Tests — use real timers with tiny delays
// ---------------------------------------------------------------------------
describe("fetchWithRetry", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns response on first success", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, { maxRetries: 3 });
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("retries on HTTP 500 with backoff", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Server Error" })
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("retries on HTTP 429", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 429,
headers: new Headers({ "Retry-After": "0" }),
})
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("throws ApiError after exhausting retries", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({ ok: false, status: 503 });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 1,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
});
it("retries on network errors (TypeError)", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch
.mockRejectedValueOnce(new TypeError("network failure"))
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("does not retry on non-retryable status (404)", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("respects maxRetries limit", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({ ok: false, status: 500 });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 2,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
});
// ---------------------------------------------------------------------------
// cachedFetch Tests — use real timers with tiny delays
// ---------------------------------------------------------------------------
describe("cachedFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caches successful responses", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
const result1 = await cachedFetch(cache, "https://example.com", undefined, {});
const result2 = await cachedFetch(cache, "https://example.com", undefined, {});
expect(result1).toEqual({ data: "hello" });
expect(result2).toEqual({ data: "hello" });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("bypasses cache when skipCache is true", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
await cachedFetch(cache, "https://example.com", undefined, { skipCache: true });
await cachedFetch(cache, "https://example.com", undefined, { skipCache: true });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses custom cache key when provided", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
// Same cache key, different URL
await cachedFetch(cache, "https://example.com/a", undefined, { cacheKey: "shared" });
await cachedFetch(cache, "https://example.com/b", undefined, { cacheKey: "shared" });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws on non-OK response after retries", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
});
const cache = new MemoryCache();
await expect(cachedFetch(cache, "https://example.com", undefined, { maxRetries: 0 })).rejects.toThrow(ApiError);
});
});
// ---------------------------------------------------------------------------
// ApiClient Tests
// ---------------------------------------------------------------------------
describe("ApiClient", () => {
let client: ApiClient;
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
client = new ApiClient({
baseUrl: "https://api.example.com",
defaultTimeoutMs: 5000,
defaultTtlMs: 30_000,
maxRetries: 2,
userAgent: "TestClient/1.0",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("builds correct headers with User-Agent", () => {
expect(client.headers).toHaveProperty("User-Agent", "TestClient/1.0");
});
it("provides cache statistics", () => {
const stats = client.cacheStats();
expect(stats.size).toBe(0);
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
});
it("clears cache", () => {
client.clearCache();
const stats = client.cacheStats();
expect(stats.size).toBe(0);
});
});
// ---------------------------------------------------------------------------
// ApiError Tests
// ---------------------------------------------------------------------------
describe("ApiError", () => {
it("can be constructed with status and flags", () => {
const err = new ApiError("Rate limited");
err.status = 429;
(err as ApiError).isRateLimit = true;
expect(err.message).toBe("Rate limited");
expect(err.status).toBe(429);
expect((err as ApiError).isRateLimit).toBe(true);
});
it("is an instance of Error", () => {
const err = new ApiError("Something broke");
expect(err).toBeInstanceOf(Error);
});
it("has name ApiError", () => {
const err = new ApiError("oops");
expect(err.name).toBe("ApiError");
});
});
// ---------------------------------------------------------------------------
// sleep Tests
// ---------------------------------------------------------------------------
describe("sleep", () => {
it("resolves after specified delay", async () => {
vi.useFakeTimers();
const promise = sleep(100);
expect(promise).toBeDefined();
vi.advanceTimersByTime(100);
await promise;
vi.useRealTimers();
});
});
+434
View File
@@ -0,0 +1,434 @@
// ============================================================================
// API Service Wrapper — Rate Limiting, Exponential Backoff, and Caching
// ============================================================================
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// NOTE: ApiError is a class (not interface) because it is instantiated with `new`.
export class ApiError extends Error {
status?: number;
body?: unknown;
isRateLimit?: boolean;
isRetryable?: boolean;
constructor(message: string) {
super(message);
this.name = "ApiError";
}
}
export interface RetryOptions {
/** Maximum number of retry attempts. Default: 3 */
maxRetries?: number;
/** Base delay in ms before the first retry. Default: 1000 */
baseDelayMs?: number;
/** Maximum delay cap in ms. Default: 30_000 */
maxDelayMs?: number;
/** Jitter factor (0-1). Adds randomness to prevent thundering herd. Default: 0.3 */
jitter?: number;
/** HTTP status codes that should trigger a retry. Default: [429, 500, 502, 503, 504] */
retryableStatuses?: number[];
}
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
export interface CacheStats {
size: number;
hits: number;
misses: number;
hitRate: number;
}
// ---------------------------------------------------------------------------
// In-Memory Cache with TTL support and LRU eviction
// ---------------------------------------------------------------------------
export class MemoryCache<T = unknown> {
private store = new Map<string, CacheEntry<T>>();
private _defaultTtl: number;
private _maxSize: number;
private _hits = 0;
private _misses = 0;
constructor(options: { defaultTtlMs?: number; maxSize?: number } = {}) {
this._defaultTtl = options.defaultTtlMs ?? 15 * 60 * 1000; // 15 minutes
this._maxSize = options.maxSize ?? 500;
}
get(key: string): T | null {
const entry = this.store.get(key);
if (!entry) {
this._misses++;
return null;
}
// Check TTL expiration
if (Date.now() - entry.timestamp > entry.ttl) {
this.store.delete(key);
this._misses++;
return null;
}
this._hits++;
return entry.data;
}
set(key: string, data: T, ttl?: number): void {
// Evict oldest entries if over capacity
if (this.store.size >= this._maxSize && !this.store.has(key)) {
const oldestKey = this.store.keys().next().value;
if (oldestKey) this.store.delete(oldestKey);
}
this.store.set(key, {
data,
timestamp: Date.now(),
ttl: ttl ?? this._defaultTtl,
});
}
invalidate(key: string): boolean {
return this.store.delete(key);
}
clear(): void {
this.store.clear();
}
stats(): CacheStats {
const total = this._hits + this._misses;
return {
size: this.store.size,
hits: this._hits,
misses: this._misses,
hitRate: total > 0 ? this._hits / total : 0,
};
}
}
// ---------------------------------------------------------------------------
// Retry Logic with Exponential Backoff + Jitter
// ---------------------------------------------------------------------------
function calculateBackoff(attempt: number, baseDelayMs: number, maxDelayMs: number, jitter: number): number {
// Exponential backoff: baseDelay * 2^attempt
const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
// Add jitter to prevent thundering herd
const jitterRange = cappedDelay * jitter;
const jitterValue = Math.random() * jitterRange;
return cappedDelay + jitterValue;
}
function isRetryableError(error: unknown, retryableStatuses: number[]): boolean {
if (error instanceof ApiError) {
const status = error.status;
if (status !== undefined && retryableStatuses.includes(status)) {
return true;
}
// Network errors (no status code) are retryable
if (status === undefined) {
return true;
}
}
// Generic network/timeout errors
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof TypeError) {
// Network failures often throw TypeError in browsers/Node
return true;
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithRetry(
url: string | URL | Request,
init?: RequestInit,
options: RetryOptions = {},
): Promise<Response> {
const maxRetries = options.maxRetries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 1000;
const maxDelayMs = options.maxDelayMs ?? 30_000;
const jitter = options.jitter ?? 0.3;
const retryableStatuses = options.retryableStatuses ?? [429, 500, 502, 503, 504];
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, init);
// Check if the status is retryable
if (response.status === 429) {
// HTTP 429: Too Many Requests — respect Retry-After header
const retryAfterHeader = response.headers.get("Retry-After");
let retryAfterMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
if (retryAfterHeader) {
const parsed = parseInt(retryAfterHeader, 10);
if (!isNaN(parsed)) {
retryAfterMs = Math.max(retryAfterMs, parsed * 1000);
}
}
if (attempt < maxRetries) {
lastError = new ApiError(`Rate limited (HTTP 429). Retrying in ${Math.round(retryAfterMs)}ms…`);
(lastError as ApiError).status = 429;
(lastError as ApiError).isRateLimit = true;
(lastError as ApiError).isRetryable = true;
await sleep(retryAfterMs);
continue;
}
// Exhausted retries — throw
const err = new ApiError("Rate limit exceeded after all retries");
err.status = 429;
(err as ApiError).isRateLimit = true;
(err as ApiError).isRetryable = false;
throw err;
}
if (!response.ok && retryableStatuses.includes(response.status)) {
const delay = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
if (attempt < maxRetries) {
lastError = new ApiError(`Server error (HTTP ${response.status}). Retrying in ${Math.round(delay)}ms…`);
(lastError as ApiError).status = response.status;
(lastError as ApiError).isRetryable = true;
await sleep(delay);
continue;
}
// Exhausted retries — throw
const err = new ApiError(`Server error after all retries: HTTP ${response.status}`);
err.status = response.status;
(err as ApiError).isRetryable = false;
throw err;
}
return response;
} catch (error) {
lastError = error;
// If it's already a non-retryable ApiError, throw immediately
if (error instanceof ApiError && !(error as ApiError).isRetryable) {
throw error;
}
if (attempt < maxRetries && isRetryableError(error, retryableStatuses)) {
const delay = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
await sleep(delay);
continue;
}
throw error;
}
}
// Should not reach here, but TypeScript needs it
throw lastError ?? new ApiError("Unexpected fetch failure");
}
// ---------------------------------------------------------------------------
// Cached Fetch Wrapper
// ---------------------------------------------------------------------------
interface CachedFetchOptions extends RetryOptions {
cacheKey?: string;
ttl?: number;
skipCache?: boolean;
}
async function cachedFetch<T>(
cache: MemoryCache<unknown>,
url: string | URL | Request,
init?: RequestInit,
options: CachedFetchOptions = {},
): Promise<T> {
const key = options.cacheKey ?? String(url);
// Check cache (unless skipped)
if (!options.skipCache) {
const cached = cache.get(key);
if (cached !== null) {
return cached as T;
}
}
const response = await fetchWithRetry(url, init, options);
if (!response.ok) {
const err = new ApiError(`HTTP ${response.status}: ${response.statusText}`);
err.status = response.status;
throw err;
}
const data = (await response.json()) as T;
cache.set(key, data, options.ttl);
return data;
}
// ---------------------------------------------------------------------------
// APIClient — Composable Base Class
// ---------------------------------------------------------------------------
export interface ApiClientOptions {
baseUrl: string;
defaultTimeoutMs?: number;
defaultTtlMs?: number;
maxRetries?: number;
userAgent?: string;
headers?: Record<string, string>;
}
export class ApiClient {
public readonly baseUrl: string;
public readonly cache: MemoryCache<unknown>;
public readonly defaultTimeoutMs: number;
public readonly defaultTtlMs: number;
public readonly maxRetries: number;
public readonly userAgent?: string;
public readonly headers: Record<string, string>;
constructor(options: ApiClientOptions) {
this.baseUrl = options.baseUrl;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 12_000;
this.defaultTtlMs = options.defaultTtlMs ?? 15 * 60 * 1000;
this.maxRetries = options.maxRetries ?? 3;
this.userAgent = options.userAgent;
this.headers = { ...options.headers };
if (this.userAgent) {
this.headers["User-Agent"] = this.userAgent;
}
this.cache = new MemoryCache({ defaultTtlMs: this.defaultTtlMs });
}
/**
* Build a common headers object, merging defaults with overrides.
*/
protected buildHeaders(extra?: Record<string, string>): Record<string, string> {
return {
...this.headers,
...extra,
};
}
/**
* Build an AbortSignal that fires after the configured timeout.
*/
protected buildTimeoutSignal(timeoutMs?: number): AbortSignal {
const ms = timeoutMs ?? this.defaultTimeoutMs;
return AbortSignal.timeout(ms);
}
/**
* Perform a GET request with retry + caching.
*/
protected async get<T>(
path: string,
search?: Record<string, string>,
options: {
ttl?: number;
cacheKey?: string;
skipCache?: boolean;
maxRetries?: number;
timeoutMs?: number;
headers?: Record<string, string>;
} = {},
): Promise<T> {
const url = new URL(path, this.baseUrl);
if (search) {
for (const [k, v] of Object.entries(search)) {
url.searchParams.set(k, v);
}
}
return cachedFetch<T>(
this.cache,
url.toString(),
{
headers: this.buildHeaders(options.headers),
signal: this.buildTimeoutSignal(options.timeoutMs),
},
{
cacheKey: options.cacheKey ?? url.toString(),
ttl: options.ttl,
skipCache: options.skipCache,
maxRetries: options.maxRetries ?? this.maxRetries,
},
);
}
/**
* Perform a POST request with retry logic (never cached).
*/
protected async post<T>(
path: string,
body: unknown,
options: {
maxRetries?: number;
timeoutMs?: number;
headers?: Record<string, string>;
} = {},
): Promise<T> {
const url = new URL(path, this.baseUrl);
return cachedFetch<T>(
this.cache,
url.toString(),
{
method: "POST",
headers: {
...this.buildHeaders(options.headers),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: this.buildTimeoutSignal(options.timeoutMs),
},
{
skipCache: true,
maxRetries: options.maxRetries ?? this.maxRetries,
},
);
}
/**
* Get cache statistics.
*/
public cacheStats(): CacheStats {
return this.cache.stats();
}
/**
* Clear the cache.
*/
public clearCache(): void {
this.cache.clear();
}
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { fetchWithRetry, cachedFetch, calculateBackoff, sleep };
+57 -10
View File
@@ -1,5 +1,10 @@
import type { BikeRoute, BikeStep } from "@/types";
import { OSRM_URL } from "./constants";
import { ApiClient } from "./api-service";
// ---------------------------------------------------------------------------
// OSRM response types (internal, not exported)
// ---------------------------------------------------------------------------
interface OsrmStep {
name: string;
@@ -19,30 +24,58 @@ interface OsrmResponse {
routes: OsrmRoute[];
}
// ---------------------------------------------------------------------------
// Helper: Build human-readable instruction from OSRM step data
// ---------------------------------------------------------------------------
function stepInstruction(step: OsrmStep): string {
if (step.maneuver.instruction) return step.maneuver.instruction;
const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
return `${step.maneuver.type}${modifier}${step.name ? ` onto ${step.name}` : ""}`;
}
export class BikeRoutingClient {
private baseUrl: string;
// ---------------------------------------------------------------------------
// BikeRoutingClient — Wraps the OSRM bike routing API
//
// OSRM routes are cached for 5 minutes. Bike routes between the same
// coordinates rarely change, and caching significantly reduces API load.
// ---------------------------------------------------------------------------
constructor(baseUrl: string = OSRM_URL) {
this.baseUrl = baseUrl;
export class BikeRoutingClient {
private client: ApiClient;
private readonly defaultTtlMs: number;
constructor(
baseUrl: string = OSRM_URL,
ttlMs: number = 5 * 60 * 1000, // 5 minutes — routes are very stable
) {
this.client = new ApiClient({
baseUrl,
defaultTimeoutMs: 10_000,
defaultTtlMs: ttlMs,
maxRetries: 3,
});
this.defaultTtlMs = ttlMs;
}
async getBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<BikeRoute | null> {
const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
const url = `${this.baseUrl}/route/v1/bicycle/${coords}?overview=false&steps=true`;
const path = `/route/v1/bicycle/${coords}`;
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`OSRM request failed: ${res.status}`);
const cacheKey = `osrm:bike:${coords}`;
const data: OsrmResponse = await res.json();
if (data.code !== "Ok" || !data.routes.length) return null;
const res = await this.client.get<OsrmResponse>(
path,
{ overview: "false", steps: "true" },
{
cacheKey,
ttl: this.defaultTtlMs,
},
);
const route = data.routes[0];
if (res.code !== "Ok" || !res.routes.length) return null;
const route = res.routes[0];
const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
name: s.name,
distance: s.distance,
@@ -56,4 +89,18 @@ export class BikeRoutingClient {
steps,
};
}
/**
* Expose cache statistics for debugging/monitoring.
*/
public cacheStats() {
return this.client.cacheStats();
}
/**
* Clear the cache (useful for testing or forced refresh).
*/
public clearCache() {
this.client.clearCache();
}
}
+3
View File
@@ -12,3 +12,6 @@ export const OSRM_URL = process.env.OSRM_URL ?? "https://router.project-osrm.org
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";
+79 -43
View File
@@ -1,5 +1,10 @@
import type { GeocodeResult } from "@/types";
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "./constants";
import { ApiClient } from "./api-service";
// ---------------------------------------------------------------------------
// Nominatim response types (internal, not exported)
// ---------------------------------------------------------------------------
interface NominatimResult {
lat: string;
@@ -7,67 +12,98 @@ interface NominatimResult {
display_name: string;
}
export class GeocodingClient {
private baseUrl: string;
private userAgent: string;
private cache = new Map<string, GeocodeResult[]>();
type NominatimSearchResponse = NominatimResult[];
type NominatimReverseResponse = NominatimResult;
constructor(baseUrl: string = NOMINATIM_URL, userAgent: string = NOMINATIM_USER_AGENT) {
this.baseUrl = baseUrl;
this.userAgent = userAgent;
// ---------------------------------------------------------------------------
// GeocodingClient — Wraps the Nominatim geocoding API
//
// Nominatim has strict usage limits (1 request/sec, require User-Agent).
// Results are cached for 15 minutes by default since geographic data changes
// very slowly.
// ---------------------------------------------------------------------------
export class GeocodingClient {
private client: ApiClient;
private readonly defaultTtlMs: number;
constructor(
baseUrl: string = NOMINATIM_URL,
userAgent: string = NOMINATIM_USER_AGENT,
ttlMs: number = 15 * 60 * 1000, // 15 minutes — geographic data is very stable
) {
this.client = new ApiClient({
baseUrl,
defaultTimeoutMs: 10_000,
defaultTtlMs: ttlMs,
maxRetries: 3,
userAgent,
});
this.defaultTtlMs = ttlMs;
}
async geocode(query: string, countrycodes?: string): Promise<GeocodeResult[]> {
const cacheKey = `${query}|${countrycodes ?? ""}`;
const cached = this.cache.get(cacheKey);
if (cached) return cached;
const params: Record<string, string> = {
q: query,
format: "json",
limit: "5",
};
const params = new URLSearchParams({ q: query, format: "json", limit: "5" });
if (countrycodes) params.set("countrycodes", countrycodes);
if (countrycodes) {
params.countrycodes = countrycodes;
}
const res = await fetch(`${this.baseUrl}/search?${params}`, {
headers: {
"User-Agent": this.userAgent,
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
// Build a stable cache key that includes all query parameters
const cacheKey = `nominatim:search:${query}|${countrycodes ?? ""}`;
const res = await this.client.get<NominatimSearchResponse>("/search", params, {
cacheKey,
ttl: this.defaultTtlMs,
});
if (!res.ok) throw new Error(`Nominatim geocode failed: ${res.status}`);
const data: NominatimResult[] = await res.json();
const results = data.map((r) => ({
return res.map((r) => ({
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
display_name: r.display_name,
}));
this.cache.set(cacheKey, results);
return results;
}
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
const params = new URLSearchParams({
const params: Record<string, string> = {
lat: String(lat),
lon: String(lng),
format: "json",
});
const res = await fetch(`${this.baseUrl}/reverse?${params}`, {
headers: {
"User-Agent": this.userAgent,
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data: NominatimResult = await res.json();
return {
lat: parseFloat(data.lat),
lng: parseFloat(data.lon),
display_name: data.display_name,
};
const cacheKey = `nominatim:reverse:${lat}:${lng}`;
try {
const res = await this.client.get<NominatimReverseResponse>("/reverse", params, {
cacheKey,
ttl: this.defaultTtlMs,
});
return {
lat: parseFloat(res.lat),
lng: parseFloat(res.lon),
display_name: res.display_name,
};
} catch {
return null;
}
}
/**
* Expose cache statistics for debugging/monitoring.
*/
public cacheStats() {
return this.client.cacheStats();
}
/**
* Clear the cache (useful for testing or forced refresh).
*/
public clearCache() {
this.client.clearCache();
}
}
+51 -25
View File
@@ -1,6 +1,11 @@
import type { Journey, Station } from "@/types";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
import { parseHafasTime, hafasDateTime } from "./hafas-time";
import { ApiClient } from "./api-service";
// ---------------------------------------------------------------------------
// HAFAS response types (internal, not exported)
// ---------------------------------------------------------------------------
interface HafasLocation {
type: "S" | "A" | "P";
@@ -8,6 +13,16 @@ interface HafasLocation {
extId: string;
}
interface HafasLocationResponse {
svcResL?: Array<{
res?: {
match?: {
locL?: HafasLocation[];
};
};
}>;
}
interface HafasJourney {
ctxRecon?: string;
secL?: Array<{
@@ -23,13 +38,28 @@ interface HafasJourney {
}>;
}
interface HafasTripResponse {
svcResL?: Array<{
res?: {
outConL?: HafasJourney[];
};
}>;
}
// ---------------------------------------------------------------------------
// HafasClient — Wraps the ÖBB HAFAS journey-planning API
// ---------------------------------------------------------------------------
export class HafasClient {
private baseUrl: string;
private timeoutMs: number;
private client: ApiClient;
constructor(baseUrl: string = HAFAS_URL, timeoutMs: number = HAFAS_TIMEOUT_MS) {
this.baseUrl = baseUrl;
this.timeoutMs = timeoutMs;
this.client = new ApiClient({
baseUrl,
defaultTimeoutMs: timeoutMs,
defaultTtlMs: 0, // No caching for HAFAS — journeys are live data
maxRetries: 3,
});
}
async searchStation(query: string): Promise<Station[]> {
@@ -42,18 +72,15 @@ export class HafasClient {
],
};
const res = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs),
});
const res = await this.client.post<HafasLocationResponse>("", body);
if (!res.ok) throw new Error(`HAFAS station search failed: ${res.status}`);
const json = await res.json();
const match = json?.svcResL?.[0]?.res?.match?.locL ?? [];
return (match as HafasLocation[]).filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId }));
const match = res?.svcResL?.[0]?.res?.match?.locL ?? [];
return (match as HafasLocation[])
.filter((l) => l.type === "S")
.map((l) => ({
name: l.name,
extId: l.extId,
}));
}
async fetchJourneys(from: Station, to: Station, date: Date): Promise<Journey[]> {
@@ -74,17 +101,9 @@ export class HafasClient {
],
};
const res = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs),
});
const res = await this.client.post<HafasTripResponse>("", body);
if (!res.ok) throw new Error(`HAFAS trip search failed: ${res.status}`);
const json = await res.json();
const outConL: HafasJourney[] = json?.svcResL?.[0]?.res?.outConL ?? [];
const outConL: HafasJourney[] = res?.svcResL?.[0]?.res?.outConL ?? [];
return outConL.map((con, i): Journey => {
const first = con.secL?.[0];
@@ -123,4 +142,11 @@ export class HafasClient {
};
});
}
/**
* Expose cache statistics (always empty for HAFAS, but useful for debugging).
*/
public cacheStats() {
return this.client.cacheStats();
}
}
+1
View File
@@ -1,4 +1,5 @@
// Main library exports for TimeToLeave
export * from "./api-service";
export * from "./hafas-client";
export * from "./geocoding-client";
export * from "./bike-routing-client";