diff --git a/CHECKLIST.md b/CHECKLIST.md
index 2fecb3a..8675896 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -14,10 +14,10 @@ The API route tests were written against an earlier interface and will fail as-i
| # | Item | ✅ | ✔️ |
|---|---|----|-|
-| 58 | `useJourneys.ts`: remove direct `HafasClient` instantiation; route all HAFAS calls through `/api/hafas` to prevent direct browser→HAFAS requests (CORS + IP leakage) | [ ] | [ ] |
-| 59 | `useBikeRoute.ts`: remove direct `BikeRoutingClient` instantiation; call `/api/bike-route` instead so OSRM is never contacted directly from the browser | [ ] | [ ] |
-| 60 | `useOriginStation.ts`: use `location.coords.latitude` / `longitude` in the station search instead of the hardcoded `"Bahnhof"` query; use a HAFAS nearby-station lookup or geocode → nearest-station fallback | [ ] | [ ] |
-| 61 | `hafas-client.ts` `parseHafasTime`: replace `new Date(y, mo, d, h, m, s)` (local TZ) with Vienna-timezone-aware construction — use `Intl` or a fixed UTC offset — so departure/arrival times are correct when the server is not in CET/CEST | [ ] | [ ] |
-| 62 | `api/calendar/route.ts` and `api/calendar/parse/route.ts`: replace the inlined parsing logic with calls to `extractEvents()` from `calendar-utils.ts` so `cleanLocation()` and location-presence filtering are applied consistently | [ ] | [ ] |
-| 63 | `useBikeRoute.ts:18`: replace `if (!fromLat || !fromLng || !toLat || !toLng)` with `!= null` checks so coordinates at `0` (valid) are not skipped | [ ] | [ ] |
-| 64 | Move `HafasClient` / `GeocodingClient` / `BikeRoutingClient` instances to module scope (or a shared context) so the in-instance caches in `GeocodingClient` survive across renders | [ ] | [ ] |
\ No newline at end of file
+| 58 | `useJourneys.ts`: remove direct `HafasClient` instantiation; route all HAFAS calls through `/api/hafas` to prevent direct browser→HAFAS requests (CORS + IP leakage) | [x] | [x] |
+| 59 | `useBikeRoute.ts`: remove direct `BikeRoutingClient` instantiation; call `/api/bike-route` instead so OSRM is never contacted directly from the browser | [x] | [x] |
+| 60 | `useOriginStation.ts`: use `location.coords.latitude` / `longitude` in the station search instead of the hardcoded `"Bahnhof"` query; use a HAFAS nearby-station lookup or geocode → nearest-station fallback | [x] | [x] |
+| 61 | `hafas-client.ts` `parseHafasTime`: replace `new Date(y, mo, d, h, m, s)` (local TZ) with Vienna-timezone-aware construction — use `Intl` or a fixed UTC offset — so departure/arrival times are correct when the server is not in CET/CEST | [x] | [x] |
+| 62 | `api/calendar/route.ts` and `api/calendar/parse/route.ts`: replace the inlined parsing logic with calls to `extractEvents()` from `calendar-utils.ts` so `cleanLocation()` and location-presence filtering are applied consistently | [x] | [x] |
+| 63 | `useBikeRoute.ts:18`: replace `if (!fromLat || !fromLng || !toLat || !toLng)` with `!= null` checks so coordinates at `0` (valid) are not skipped | [x] | [x] |
+| 64 | Move `HafasClient` / `GeocodingClient` / `BikeRoutingClient` instances to module scope (or a shared context) so the in-instance caches in `GeocodingClient` survive across renders | [x] | [x] |
\ No newline at end of file
diff --git a/README.md b/README.md
index 5bbd0eb..3aa50ad 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# ÖBB Planner - Next.js Rewrite
+# TimeToLeave - Next.js Rewrite
-This is a rewrite of the ÖBB Planner application using Next.js App Router with TypeScript.
+This is a rewrite of the TimeToLeave application using Next.js App Router with TypeScript.
## Development
diff --git a/package.json b/package.json
index 8590e21..f987e78 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "oebb_planner",
+ "name": "time-to-leave",
"version": "0.1.0",
"private": true,
"scripts": {
diff --git a/src/app/api/calendar/parse/route.ts b/src/app/api/calendar/parse/route.ts
index 3092dac..e141535 100644
--- a/src/app/api/calendar/parse/route.ts
+++ b/src/app/api/calendar/parse/route.ts
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import * as ical from "node-ical";
-import type { VEvent } from "node-ical";
-import { CalendarEvent } from "@/types";
+import { extractEvents } from "@/lib/calendar-utils";
+import { DEFAULT_DAYS } from "@/lib/constants";
export async function POST(request: NextRequest) {
try {
@@ -11,39 +10,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
}
- // Parse the ICS content
- const events = await new Promise((resolve, reject) => {
- ical.parseICS(body, (err, data) => {
- if (err) {
- reject(err);
- } else {
- // Filter to only include events (VEVENT type)
- const filteredEvents: VEvent[] = [];
+ // Use extractEvents for consistent parsing with cleanLocation() and filtering
+ const events = extractEvents(body, DEFAULT_DAYS);
- for (const key in data) {
- if (data.hasOwnProperty(key)) {
- const event = data[key];
- if (event.type === "VEVENT" && event.start) {
- filteredEvents.push(event);
- }
- }
- }
-
- resolve(filteredEvents);
- }
- });
- });
-
- // Transform events to CalendarEvent format
- const calendarEvents: CalendarEvent[] = (events as VEvent[]).map((event) => ({
- id: event.uid,
- title: event.summary,
- destination: event.location || "",
- eventTime: event.start.toISOString(),
- source: "calendar",
- }));
-
- return NextResponse.json(calendarEvents);
+ return NextResponse.json(events);
} catch (error) {
console.error("Calendar parse API error:", error);
return NextResponse.json({ error: "Failed to parse calendar" }, { status: 500 });
diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts
index 67fd836..7e010b4 100644
--- a/src/app/api/calendar/route.ts
+++ b/src/app/api/calendar/route.ts
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import * as ical from "node-ical";
-import type { VEvent } from "node-ical";
-import { CalendarEvent } from "@/types";
+import { extractEvents } from "@/lib/calendar-utils";
+import { DEFAULT_DAYS } from "@/lib/constants";
export async function GET(request: NextRequest) {
try {
@@ -13,46 +12,23 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
}
- const days = daysParam ? parseInt(daysParam, 10) : 14;
+ const days = daysParam ? parseInt(daysParam, 10) : DEFAULT_DAYS;
- // Fetch and parse the ICS calendar
- const events = await new Promise((resolve, reject) => {
- ical.fromURL(url, {}, (err, data) => {
- if (err) {
- reject(err);
- } else {
- // Filter events to only include those within the specified number of days
- const filteredEvents: VEvent[] = [];
- const now = new Date();
- const maxDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
-
- for (const key in data) {
- if (data.hasOwnProperty(key)) {
- const event = data[key];
- if (event.type === "VEVENT" && event.start) {
- const eventDate = new Date(event.start);
- if (eventDate >= now && eventDate <= maxDate) {
- filteredEvents.push(event);
- }
- }
- }
- }
-
- resolve(filteredEvents);
- }
- });
+ // Fetch the ICS content from the provided URL
+ const icsResponse = await fetch(url, {
+ signal: AbortSignal.timeout(10_000),
});
- // Transform events to CalendarEvent format
- const calendarEvents: CalendarEvent[] = (events as VEvent[]).map((event) => ({
- id: event.uid,
- title: event.summary,
- destination: event.location || "",
- eventTime: event.start.toISOString(),
- source: "calendar",
- }));
+ if (!icsResponse.ok) {
+ return NextResponse.json({ error: "Failed to fetch calendar" }, { status: icsResponse.status });
+ }
- return NextResponse.json(calendarEvents);
+ const content = await icsResponse.text();
+
+ // Use extractEvents for consistent parsing with cleanLocation() and filtering
+ const events = extractEvents(content, days);
+
+ return NextResponse.json(events);
} catch (error) {
console.error("Calendar API error:", error);
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: 500 });
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 976eb90..60680c6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "TimeToLeave",
+ description: "Plan your train journeys and compare with bicycle routing",
};
export default function RootLayout({
@@ -23,10 +23,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
{children}
);
diff --git a/src/hooks/useBikeRoute.ts b/src/hooks/useBikeRoute.ts
index 3317f48..0f1f982 100644
--- a/src/hooks/useBikeRoute.ts
+++ b/src/hooks/useBikeRoute.ts
@@ -1,8 +1,12 @@
import { useState, useEffect } from "react";
-import { BikeRoute } from "@/types";
-import { BikeRoutingClient } from "@/lib/bike-routing-client";
+import type { BikeRoute } from "@/types";
-export function useBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number) {
+export function useBikeRoute(
+ fromLat: number | undefined,
+ fromLng: number | undefined,
+ toLat: number | undefined,
+ toLng: number | undefined,
+) {
const [bikeRoute, setBikeRoute] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -11,7 +15,7 @@ export function useBikeRoute(fromLat: number, fromLng: number, toLat: number, to
let isMounted = true;
const fetchRoute = async () => {
- if (!fromLat || !fromLng || !toLat || !toLng) {
+ if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
return;
}
@@ -19,11 +23,23 @@ export function useBikeRoute(fromLat: number, fromLng: number, toLat: number, to
setError(null);
try {
- const client = new BikeRoutingClient();
- const result = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
+ const url = new URL("/api/bike-route", window.location.href);
+ url.searchParams.set("fromLat", String(fromLat));
+ url.searchParams.set("fromLng", String(fromLng));
+ url.searchParams.set("toLat", String(toLat));
+ url.searchParams.set("toLng", String(toLng));
+
+ const response = await fetch(url.toString());
+
+ if (!response.ok) {
+ const errBody = await response.json().catch(() => ({}));
+ throw new Error(errBody.error ?? `Bike route request failed with status ${response.status}`);
+ }
+
+ const data = await response.json();
if (isMounted) {
- setBikeRoute(result);
+ setBikeRoute(data);
setLoading(false);
}
} catch (err: unknown) {
diff --git a/src/hooks/useJourneys.ts b/src/hooks/useJourneys.ts
index d9ccd55..041a97f 100644
--- a/src/hooks/useJourneys.ts
+++ b/src/hooks/useJourneys.ts
@@ -1,6 +1,65 @@
import { useState, useEffect } from "react";
-import { Journey } from "@/types";
-import { HafasClient } from "@/lib/hafas-client";
+import type { Journey } from "@/types";
+import { parseHafasTime, hafasDateTime } from "@/lib/hafas-time";
+
+interface HafasJourney {
+ ctxRecon?: string;
+ secL?: Array<{
+ dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
+ arr?: { aTimeS?: string; aTimeR?: string };
+ jny?: {
+ prodX?: number;
+ stopL?: Array<{ name: string }>;
+ dlySum?: number;
+ isCncl?: boolean;
+ };
+ chgDurR?: number;
+ }>;
+}
+
+function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
+ // HAFAS response shape is too complex for a clean TypeScript type; use any for parsing.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
+ const data = json as any;
+ const outConL: HafasJourney[] = data?.svcResL?.[0]?.res?.outConL ?? [];
+
+ return outConL.map((con, i): Journey => {
+ const first = con.secL?.[0];
+ const last = con.secL?.[con.secL.length - 1];
+ const dep = first?.dep;
+ const arr = last?.arr;
+
+ const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : queryDate;
+ const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
+ const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
+ const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
+
+ const delayMs = rD.getTime() - sD.getTime();
+ const delay = Math.max(0, Math.round(delayMs / 60000));
+
+ const trains = (con.secL ?? [])
+ .filter((s) => s.jny)
+ .map((s) => s.jny?.stopL?.[0]?.name ?? "")
+ .filter(Boolean);
+
+ const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
+ const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
+ const platform = first?.dep?.dPlatfS ?? "";
+
+ return {
+ id: con.ctxRecon ?? `journey-${i}`,
+ sD,
+ rD,
+ sA,
+ rA,
+ delay,
+ platform,
+ changes,
+ trains,
+ cancelled,
+ };
+ });
+}
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date) {
const [journeys, setJourneys] = useState([]);
@@ -19,15 +78,36 @@ export function useJourneys(fromStationExtId: string | null, toStationExtId: str
setError(null);
try {
- const client = new HafasClient();
+ const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
- // First, search for the stations by their extId to get full station objects
- // This is a bit redundant since we already have extIds, but it ensures we have the full station data
- const fromStation = { extId: fromStationExtId, name: "" }; // We only need extId for the API
- const toStation = { extId: toStationExtId, name: "" };
+ const body = {
+ svcReqL: [
+ {
+ meth: "TripSearch",
+ req: {
+ depLocL: [{ type: "S", extId: fromStationExtId }],
+ arrLocL: [{ type: "S", extId: toStationExtId }],
+ outDate: hafasDate,
+ outTime: hafasTime,
+ numF: 5,
+ },
+ },
+ ],
+ };
- // Fetch journeys between the stations
- const result = await client.fetchJourneys(fromStation, toStation, date);
+ 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 ?? `HAFAS request failed with status ${response.status}`);
+ }
+
+ const data = await response.json();
+ const result = parseHafasJourneys(data, hafasDate, date);
if (isMounted) {
setJourneys(result);
diff --git a/src/hooks/useOriginStation.ts b/src/hooks/useOriginStation.ts
index f038415..7c548ea 100644
--- a/src/hooks/useOriginStation.ts
+++ b/src/hooks/useOriginStation.ts
@@ -1,7 +1,12 @@
import { useState, useEffect } from "react";
-import { Station } from "@/types";
+import type { Station } from "@/types";
import { useGeolocation } from "./useGeolocation";
-import { HafasClient } from "@/lib/hafas-client";
+
+interface HafasLocation {
+ type: string;
+ name: string;
+ extId: string;
+}
export function useOriginStation() {
const [station, setStation] = useState(null);
@@ -24,16 +29,47 @@ export function useOriginStation() {
setError(null);
try {
- const client = new HafasClient();
+ const { latitude, longitude } = location.coords;
- // Search for nearby stations based on geolocation
- // In a real implementation, we'd calculate actual distance to find the nearest
- const results = await client.searchStation("Bahnhof");
+ // Use HAFAS LocMatch with coordinates to find nearest station
+ const body = {
+ svcReqL: [
+ {
+ meth: "LocMatch",
+ req: {
+ input: {
+ loc: {
+ lat: latitude,
+ lon: longitude,
+ type: "S",
+ },
+ maxLoc: 5,
+ },
+ },
+ },
+ ],
+ };
+
+ const response = await fetch("/api/hafas", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HAFAS request failed with status ${response.status}`);
+ }
+
+ const data = await response.json();
+ const match = data?.svcResL?.[0]?.res?.match?.locL ?? [];
+ const stations: Station[] = (match as HafasLocation[])
+ .filter((l) => l.type === "S")
+ .map((l) => ({ name: l.name, extId: l.extId }));
if (!isMounted) return;
- if (results.length > 0) {
- setStation(results[0]);
+ if (stations.length > 0) {
+ setStation(stations[0]);
} else {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 37abf40..cb4cc78 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,14 +1,14 @@
-// Constants for ÖBB Planner
+// Constants for TimeToLeave
-export const HAFAS_URL = process.env.HAFAS_URL ?? 'https://fahrplan.oebb.at/bin/mgate.exe';
+export const HAFAS_URL = process.env.HAFAS_URL ?? "https://fahrplan.oebb.at/bin/mgate.exe";
export const HAFAS_TIMEOUT_MS = 12_000; // 12 seconds
-export const NOMINATIM_URL = process.env.NOMINATIM_URL ?? 'https://nominatim.openstreetmap.org';
-export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT ?? 'OebbPlanner/1.0';
+export const NOMINATIM_URL = process.env.NOMINATIM_URL ?? "https://nominatim.openstreetmap.org";
+export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT ?? "TimeToLeave/2.0";
-export const OSRM_URL = process.env.OSRM_URL ?? 'https://router.project-osrm.org';
+export const 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 APP_VERSION = "2.0.0";
diff --git a/src/lib/countdown-utils.ts b/src/lib/countdown-utils.ts
index cd09c6e..72fe0d6 100644
--- a/src/lib/countdown-utils.ts
+++ b/src/lib/countdown-utils.ts
@@ -1,4 +1,4 @@
-// Countdown utilities for ÖBB Planner
+// Countdown utilities for TimeToLeave
import { CountdownInfo } from "@/types";
export function calculateCountdown(targetDate: Date): CountdownInfo {
diff --git a/src/lib/demo.ts b/src/lib/demo.ts
index 1a3761e..ef61175 100644
--- a/src/lib/demo.ts
+++ b/src/lib/demo.ts
@@ -1,14 +1,14 @@
-// Demo data for ÖBB Planner
-import type { Journey, Station } from '@/types';
+// Demo data for TimeToLeave
+import type { Journey, Station } from "@/types";
export const DEMO_STATIONS: Station[] = [
- { name: 'Wien Hbf', extId: '0WB0F0001500' },
- { name: 'Graz Hbf', extId: '0WB0F0000600' },
- { name: 'Salzburg Hbf', extId: '0WB000040000' },
- { name: 'Innsbruck Hbf', extId: '0WB000023000' },
- { name: 'Linz Hbf', extId: '0WB000031000' },
- { name: 'Villach Hbf', extId: '0WB000095000' },
- { name: 'Klagenfurt Hbf', extId: '0WB000086000' },
+ { name: "Wien Hbf", extId: "0WB0F0001500" },
+ { name: "Graz Hbf", extId: "0WB0F0000600" },
+ { name: "Salzburg Hbf", extId: "0WB000040000" },
+ { name: "Innsbruck Hbf", extId: "0WB000023000" },
+ { name: "Linz Hbf", extId: "0WB000031000" },
+ { name: "Villach Hbf", extId: "0WB000095000" },
+ { name: "Klagenfurt Hbf", extId: "0WB000086000" },
];
export function createDemoJourney(id: string, _station: Station): Journey {
@@ -20,7 +20,7 @@ export function createDemoJourney(id: string, _station: Station): Journey {
sA: new Date(now.getTime() + 90 * 60 * 1000),
rA: new Date(now.getTime() + 90 * 60 * 1000),
delay: 0,
- platform: '3',
+ platform: "3",
changes: 0,
trains: [`RJX ${Math.floor(Math.random() * 9000) + 1000}`],
cancelled: false,
diff --git a/src/lib/formatting.ts b/src/lib/formatting.ts
index d68dbf6..e661af1 100644
--- a/src/lib/formatting.ts
+++ b/src/lib/formatting.ts
@@ -1,25 +1,25 @@
-// Formatting utilities for ÖBB Planner
+// Formatting utilities for TimeToLeave
export function formatTime(date: Date): string {
- return date.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' });
+ return date.toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit" });
}
export function formatDate(date: Date): string {
- return date.toLocaleDateString('de-AT', {
- weekday: 'short',
- day: 'numeric',
- month: 'long',
- year: 'numeric',
+ return date.toLocaleDateString("de-AT", {
+ weekday: "short",
+ day: "numeric",
+ month: "long",
+ year: "numeric",
});
}
export function formatDateTime(date: Date): string {
- return date.toLocaleString('de-AT', {
- day: 'numeric',
- month: 'long',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
+ return date.toLocaleString("de-AT", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
});
}
diff --git a/src/lib/hafas-client.ts b/src/lib/hafas-client.ts
index dd93278..da83c46 100644
--- a/src/lib/hafas-client.ts
+++ b/src/lib/hafas-client.ts
@@ -1,5 +1,6 @@
import type { Journey, Station } from "@/types";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
+import { parseHafasTime, hafasDateTime } from "./hafas-time";
interface HafasLocation {
type: "S" | "A" | "P";
@@ -22,23 +23,6 @@ interface HafasJourney {
}>;
}
-function parseHafasTime(date: string, time: string): Date {
- const y = parseInt(date.slice(0, 4));
- const mo = parseInt(date.slice(4, 6)) - 1;
- const d = parseInt(date.slice(6, 8));
- const h = parseInt(time.slice(0, 2));
- const m = parseInt(time.slice(2, 4));
- const s = parseInt(time.slice(4, 6));
- return new Date(y, mo, d, h, m, s);
-}
-
-function hafasDateTime(date: Date): { date: string; time: string } {
- const pad = (n: number) => String(n).padStart(2, "0");
- const d = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
- const t = `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}000`;
- return { date: d, time: t };
-}
-
export class HafasClient {
private baseUrl: string;
private timeoutMs: number;
@@ -69,9 +53,7 @@ export class HafasClient {
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 }));
+ 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 {
diff --git a/src/lib/hafas-time.ts b/src/lib/hafas-time.ts
new file mode 100644
index 0000000..d8f1f0f
--- /dev/null
+++ b/src/lib/hafas-time.ts
@@ -0,0 +1,40 @@
+// Timezone-aware parsing for HAFAS times.
+// HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing
+// regardless of the server or browser timezone.
+
+// Helper: get the offset (in minutes) from UTC for a given date in a specific IANA timezone.
+function getTimezoneOffsetMinutes(date: Date, tz: string): number {
+ const utcStr = date.toUTCString();
+ const tzStr = date.toLocaleString("en-US", { timeZone: tz });
+ const utcDate = new Date(utcStr);
+ const tzDate = new Date(tzStr);
+ // The difference tells us how much the TZ clock differs from UTC for that instant.
+ return ((tzDate.getTime() - utcDate.getTime()) / 60000) | 0;
+}
+
+/**
+ * Parse a HAFAS date+time string into a proper JavaScript Date.
+ * HAFAS dates are "YYYYMMDD" and times are "HHMMSS".
+ * The resulting Date represents the correct UTC instant for the Vienna local time.
+ */
+export function parseHafasTime(date: string, time: string): Date {
+ const y = parseInt(date.slice(0, 4));
+ const mo = parseInt(date.slice(4, 6)) - 1;
+ const d = parseInt(date.slice(6, 8));
+ const h = parseInt(time.slice(0, 2));
+ const m = parseInt(time.slice(2, 4));
+ const s = parseInt(time.slice(4, 6));
+
+ const tzOffset = getTimezoneOffsetMinutes(new Date(y, mo, d, h, m, s), "Europe/Vienna");
+ return new Date(y, mo, d, h, m - tzOffset, s);
+}
+
+/**
+ * Build HAFAS date and time strings from a JavaScript Date.
+ */
+export function hafasDateTime(date: Date): { date: string; time: string } {
+ const pad = (n: number) => String(n).padStart(2, "0");
+ const d = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
+ const t = `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}000`;
+ return { date: d, time: t };
+}
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 4556d4c..00e86c9 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,4 +1,4 @@
-// Main library exports for ÖBB Planner
+// Main library exports for TimeToLeave
export * from "./hafas-client";
export * from "./geocoding-client";
export * from "./bike-routing-client";
@@ -9,3 +9,4 @@ export * from "./countdown-utils";
export * from "./formatting";
export * from "./constants";
export * from "./demo";
+export * from "./hafas-time";
diff --git a/src/lib/live-status-utils.ts b/src/lib/live-status-utils.ts
index 12fa3ea..1bd236e 100644
--- a/src/lib/live-status-utils.ts
+++ b/src/lib/live-status-utils.ts
@@ -1,4 +1,4 @@
-// Live status utilities for ÖBB Planner
+// Live status utilities for TimeToLeave
import type { LiveStatus } from "@/types";
export class LiveStatusUtils {
diff --git a/src/lib/status-utils.ts b/src/lib/status-utils.ts
index 597ce3e..07dad01 100644
--- a/src/lib/status-utils.ts
+++ b/src/lib/status-utils.ts
@@ -1,4 +1,4 @@
-// Status utilities for ÖBB Planner
+// Status utilities for TimeToLeave
import type { ServerStatus } from "@/types";
export class StatusUtils {
diff --git a/src/types/index.ts b/src/types/index.ts
index 0e68f59..9e34adf 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,4 +1,4 @@
-// Types for ÖBB Planner - Next.js Rewrite
+// Types for TimeToLeave - Next.js Rewrite
// ── HAFAS / Train ──────────────────────────────────────────────