Refactor API clients and update project structure for TimeToLeave
This commit is contained in:
Generated
+3
-3
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+11413
-968
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "Add Event" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user