From 9d2ced4eb6c3e8be0ac3638942da4810b14fa1b7 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 22:47:17 +0200 Subject: [PATCH] rewrite phase 10 --- CHECKLIST.md | 14 ++--- README.md | 4 +- package.json | 2 +- src/app/api/calendar/parse/route.ts | 40 ++---------- src/app/api/calendar/route.ts | 54 +++++----------- src/app/layout.tsx | 9 +-- src/hooks/useBikeRoute.ts | 30 ++++++--- src/hooks/useJourneys.ts | 98 ++++++++++++++++++++++++++--- src/hooks/useOriginStation.ts | 52 ++++++++++++--- src/lib/constants.ts | 12 ++-- src/lib/countdown-utils.ts | 2 +- src/lib/demo.ts | 20 +++--- src/lib/formatting.ts | 26 ++++---- src/lib/hafas-client.ts | 22 +------ src/lib/hafas-time.ts | 40 ++++++++++++ src/lib/index.ts | 3 +- src/lib/live-status-utils.ts | 2 +- src/lib/status-utils.ts | 2 +- src/types/index.ts | 2 +- 19 files changed, 266 insertions(+), 168 deletions(-) create mode 100644 src/lib/hafas-time.ts 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 ──────────────────────────────────────────────