rewrite phase 10

This commit is contained in:
2026-05-09 22:47:17 +02:00
parent 3b6bb9d108
commit 9d2ced4eb6
19 changed files with 266 additions and 168 deletions
+7 -7
View File
@@ -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 | [ ] | [ ] |
| 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] |
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "oebb_planner",
"name": "time-to-leave",
"version": "0.1.0",
"private": true,
"scripts": {
+5 -35
View File
@@ -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 });
+15 -39
View File
@@ -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 });
+3 -6
View File
@@ -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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
+23 -7
View File
@@ -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<BikeRoute | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(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) {
+89 -9
View File
@@ -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<Journey[]>([]);
@@ -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);
+44 -8
View File
@@ -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<Station | null>(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" });
}
+6 -6
View File
@@ -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";
+1 -1
View File
@@ -1,4 +1,4 @@
// Countdown utilities for ÖBB Planner
// Countdown utilities for TimeToLeave
import { CountdownInfo } from "@/types";
export function calculateCountdown(targetDate: Date): CountdownInfo {
+10 -10
View File
@@ -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,
+13 -13
View File
@@ -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",
});
}
+2 -20
View File
@@ -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<Journey[]> {
+40
View File
@@ -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 };
}
+2 -1
View File
@@ -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";
+1 -1
View File
@@ -1,4 +1,4 @@
// Live status utilities for ÖBB Planner
// Live status utilities for TimeToLeave
import type { LiveStatus } from "@/types";
export class LiveStatusUtils {
+1 -1
View File
@@ -1,4 +1,4 @@
// Status utilities for ÖBB Planner
// Status utilities for TimeToLeave
import type { ServerStatus } from "@/types";
export class StatusUtils {
+1 -1
View File
@@ -1,4 +1,4 @@
// Types for ÖBB Planner - Next.js Rewrite
// Types for TimeToLeave - Next.js Rewrite
// ── HAFAS / Train ──────────────────────────────────────────────