Add GTFS enrichment to HAFAS response

Implements enrichment for HAFAS API responses by cross-referencing
section data against an indexed ÖBB GTFS feed.

This involves adding logic to fetch, parse, and index the GTFS data
from the specified URL. The enrichment function now uses GTFS time
and station data to populate `gtfsName` and `gtfsDirection` fields
for missing journey data in HAFAS responses.

Updates are also made to:
- Update `apps/web/src/lib/constants.ts` with the GTFS URL.
- Create `apps/web/src/lib/oebb-gtfs.ts` to handle GTFS fetching and indexing.
- Enhance `apps/web/src/app/api/hafas/route.ts` to utilize the new
  enrichment function.
  -Package updates include `fflate` and minor fixes to other packages.
This commit is contained in:
2026-05-14 18:53:41 +02:00
parent 04e793cbb8
commit e52f2497e3
14 changed files with 551 additions and 33 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"expo": {
"extra": {
"eas": {
"projectId": "78e8f448-5ce1-4d2e-b589-481c67cb7aef"
}
},
"ios": {
"bundleIdentifier": "com.floegger.timetoleave",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
}
}
}
+2 -1
View File
@@ -15,6 +15,7 @@
"@timetoleave/api-client": "*", "@timetoleave/api-client": "*",
"@timetoleave/core": "*", "@timetoleave/core": "*",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fflate": "^0.8.2",
"next": "^16.2.6", "next": "^16.2.6",
"node-ical": "^0.26.1", "node-ical": "^0.26.1",
"react": "19.1.0", "react": "19.1.0",
@@ -22,8 +23,8 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "~19.1.10", "@types/react": "~19.1.10",
+18 -1
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants"; import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core"; import { hafasDateTime } from "@timetoleave/core";
import { readBodyWithLimit } from "@/lib/api-guards"; import { readBodyWithLimit } from "@/lib/api-guards";
import { enrichHafasResponseWithGtfs } from "@/lib/oebb-gtfs";
/** HAFAS protocol version identifier sent in every request envelope. */ /** HAFAS protocol version identifier sent in every request envelope. */
const HAFAS_VER = process.env.HAFAS_VER || "1.36"; const HAFAS_VER = process.env.HAFAS_VER || "1.36";
@@ -18,7 +19,6 @@ const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
/** Only these HAFAS service methods are allowed through the proxy. */ /** Only these HAFAS service methods are allowed through the proxy. */
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const; const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
type HafasMethod = (typeof ALLOWED_METHODS)[number];
/** Single service request entry inside a HAFAS envelope. */ /** Single service request entry inside a HAFAS envelope. */
interface HafasServiceRequest { interface HafasServiceRequest {
@@ -53,6 +53,14 @@ function injectHafasAuth(
}; };
} }
async function tryEnrichWithGtfs(data: unknown, date: Date) {
try {
await enrichHafasResponseWithGtfs(data, date);
} catch (error) {
console.warn("ÖBB GTFS train-info fallback failed; returning raw HAFAS result.", error);
}
}
/** /**
* GET handler — convenience endpoint for simple trip searches. * GET handler — convenience endpoint for simple trip searches.
* *
@@ -120,6 +128,7 @@ export async function GET(request: NextRequest) {
} }
const data = await response.json(); const data = await response.json();
await tryEnrichWithGtfs(data, dateObj);
return NextResponse.json(data); return NextResponse.json(data);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
@@ -184,6 +193,11 @@ export async function POST(request: NextRequest) {
svcReq.req.numF = Math.min(Number(svcReq.req.numF), 5); svcReq.req.numF = Math.min(Number(svcReq.req.numF), 5);
} }
const tripSearchDate =
svcReq.meth === "TripSearch" && typeof svcReq.req?.outDate === "string"
? new Date(`${svcReq.req.outDate.slice(0, 4)}-${svcReq.req.outDate.slice(4, 6)}-${svcReq.req.outDate.slice(6, 8)}T00:00:00`)
: null;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
@@ -203,6 +217,9 @@ export async function POST(request: NextRequest) {
} }
const data = await response.json(); const data = await response.json();
if (tripSearchDate && !Number.isNaN(tripSearchDate.getTime())) {
await tryEnrichWithGtfs(data, tripSearchDate);
}
return NextResponse.json(data); return NextResponse.json(data);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
@@ -103,6 +103,16 @@ describe("GET /api/wienerlinien/monitor", () => {
expect(mockGetMonitor).toHaveBeenCalledWith(["WL:2000001", "WL:2000002", "WL:2000003"]); expect(mockGetMonitor).toHaveBeenCalledWith(["WL:2000001", "WL:2000002", "WL:2000003"]);
}); });
it("accepts WL:StopPoint stop IDs returned by nearby stops", async () => {
mockGetMonitor.mockResolvedValue({ stops: [] });
const request = new NextRequest(
"http://localhost/api/wienerlinien/monitor?stopIds=WL:StopPoint:2000001&stopIds=WL:StopPoint:2000002",
);
await GET(request);
expect(mockGetMonitor).toHaveBeenCalledWith(["WL:StopPoint:2000001", "WL:StopPoint:2000002"]);
});
it("returns 500 with correlationId when client throws", async () => { it("returns 500 with correlationId when client throws", async () => {
mockGetMonitor.mockRejectedValue(new Error("upstream timeout")); mockGetMonitor.mockRejectedValue(new Error("upstream timeout"));
@@ -6,7 +6,7 @@ import { WienerLinienClient } from "@/lib/wienerlinien-client";
* Fetches real-time departure information for given WienerLinien stops. * Fetches real-time departure information for given WienerLinien stops.
* *
* Accepts a comma-separated or repeated `stopIds` query parameter. * Accepts a comma-separated or repeated `stopIds` query parameter.
* Stop IDs can be numeric or in `WL:12345` format. * Stop IDs can be numeric, `WL:12345`, or `WL:StopPoint:12345`.
* Results are flattened into a single departures list regardless of stop grouping. * Results are flattened into a single departures list regardless of stop grouping.
* *
* Caps the batch at `MAX_STOP_IDS` to avoid oversized API payloads. * Caps the batch at `MAX_STOP_IDS` to avoid oversized API payloads.
@@ -14,8 +14,8 @@ import { WienerLinienClient } from "@/lib/wienerlinien-client";
const client = new WienerLinienClient(); const client = new WienerLinienClient();
/** Wiener Linien stop IDs can be numeric or in WL:format */ /** Wiener Linien stop IDs can be numeric, WL:numeric, or WL:StopPoint:numeric. */
const STOP_ID_RE = /^(?:\d+|WL:\d+)$/; const STOP_ID_RE = /^(?:\d+|WL:\d+|WL:StopPoint:\d+)$/;
/** Maximum stop IDs to batch-request in a single call. */ /** Maximum stop IDs to batch-request in a single call. */
const MAX_STOP_IDS = 10; const MAX_STOP_IDS = 10;
+33 -7
View File
@@ -7,6 +7,9 @@ import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents"; import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel"; import CalendarPanel from "./CalendarPanel";
import BatchEditPanel from "./BatchEditPanel"; import BatchEditPanel from "./BatchEditPanel";
import Button from "@/app/ui/Button";
type ActivePanel = "import" | "edit" | null;
/** /**
* Calendar page layout. * Calendar page layout.
@@ -18,21 +21,39 @@ export default function CalendarPage() {
const { events } = useEventsStore(); const { events } = useEventsStore();
const { station: originStation } = useOriginStation(); const { station: originStation } = useOriginStation();
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date()); const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
const [activePanel, setActivePanel] = React.useState<ActivePanel>(null);
const togglePanel = (panel: Exclude<ActivePanel, null>) => {
setActivePanel((current) => (current === panel ? null : panel));
};
return ( return (
<div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8"> <div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8">
<div className="mb-6 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl"> <div className="mb-6 flex flex-col gap-4 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Calendar sync</p> <p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Calendar sync</p>
<h1 className="text-3xl font-extrabold text-white sm:text-4xl">Import, inspect, and time your day.</h1> <h1 className="text-3xl font-extrabold text-white sm:text-4xl">Calendar</h1>
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p> <p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
</div> </div>
<div className="flex flex-wrap gap-3">
<div className="mb-6 space-y-4"> <Button
<CalendarPanel /> type="button"
<BatchEditPanel /> variant={activePanel === "import" ? "primary" : "secondary"}
onClick={() => togglePanel("import")}
>
Import Calendar
</Button>
<Button
type="button"
variant={activePanel === "edit" ? "primary" : "secondary"}
onClick={() => togglePanel("edit")}
>
Edit
</Button>
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} /> <CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
</div> </div>
@@ -40,6 +61,11 @@ export default function CalendarPage() {
<DayEvents events={events} date={selectedDate} originStation={originStation} /> <DayEvents events={events} date={selectedDate} originStation={originStation} />
</div> </div>
</div> </div>
<div className="space-y-4">
{activePanel === "import" && <CalendarPanel />}
{activePanel === "edit" && <BatchEditPanel />}
</div>
</div> </div>
); );
} }
+38 -8
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import React from "react"; import React, { useMemo, useState } from "react";
import type { Journey } from "@timetoleave/core"; import type { Journey } from "@timetoleave/core";
import { formatTime } from "@timetoleave/core"; import { formatTime, rankJourneys } from "@timetoleave/core";
import LeaveByBadge from "./LeaveByBadge"; import LeaveByBadge from "./LeaveByBadge";
import { calculateCountdown } from "@timetoleave/core"; import { calculateCountdown } from "@timetoleave/core";
@@ -33,23 +33,42 @@ const JourneyList: React.FC<JourneyListProps> = ({
walkDurationSeconds = 0, walkDurationSeconds = 0,
className = "", className = "",
}) => { }) => {
const [expanded, setExpanded] = useState(false);
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000); const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
const walkDurationMs = walkDurationSeconds * 1000; const walkDurationMs = walkDurationSeconds * 1000;
const rankedJourneys = useMemo(
() => rankJourneys(journeys, targetArrivalTime, walkDurationMs),
[journeys, targetArrivalTime, walkDurationMs],
);
const visibleJourneys = expanded ? rankedJourneys : rankedJourneys.slice(0, 1);
if (journeys.length === 0) { if (journeys.length === 0) {
return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>; return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
} }
return ( return (
<ul className={`space-y-3 p-4 ${className}`}> <div
{journeys.map((journey) => { className={`space-y-3 p-4 ${className}`}
role={rankedJourneys.length > 1 ? "button" : undefined}
tabIndex={rankedJourneys.length > 1 ? 0 : undefined}
onClick={() => rankedJourneys.length > 1 && setExpanded((current) => !current)}
onKeyDown={(event) => {
if (rankedJourneys.length > 1 && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setExpanded((current) => !current);
}
}}
aria-label={expanded ? "Show fewer train connections" : "Show all train connections"}
>
{visibleJourneys.map(({ journey }, index) => {
const finalArrival = new Date(journey.rA.getTime() + walkDurationMs); const finalArrival = new Date(journey.rA.getTime() + walkDurationMs);
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime(); const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime();
const departure = journey.rD ?? journey.sD; const departure = journey.rD ?? journey.sD;
const arrival = journey.rA ?? journey.sA; const arrival = journey.rA ?? journey.sA;
const durationMinutes = Math.max(0, Math.round((journey.rA.getTime() - journey.rD.getTime()) / 60000));
return ( return (
<li <div
key={journey.id} key={journey.id}
className={`rounded-xl border p-3 ${ className={`rounded-xl border p-3 ${
arrivesTooLate || journey.cancelled arrivesTooLate || journey.cancelled
@@ -67,8 +86,13 @@ const JourneyList: React.FC<JourneyListProps> = ({
</div> </div>
<div className="flex-1 text-right"> <div className="flex-1 text-right">
<span className={`font-medium ${journey.cancelled ? "text-brand-pink" : "text-brand-light"}`}> <span className={`font-medium ${journey.cancelled ? "text-brand-pink" : "text-brand-light"}`}>
{journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")} {journey.cancelled ? "Cancelled" : journey.trains.join(", ")}
</span> </span>
{index === 0 && (
<span className="ml-2 rounded-full bg-brand-fuchsia/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
Top
</span>
)}
</div> </div>
<div className="flex-1 text-right"> <div className="flex-1 text-right">
<LeaveByBadge countdown={calculateCountdown(departure)} /> <LeaveByBadge countdown={calculateCountdown(departure)} />
@@ -80,16 +104,22 @@ const JourneyList: React.FC<JourneyListProps> = ({
Arrives {formatTime(arrival)} Arrives {formatTime(arrival)}
{walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""} {walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""}
</span> </span>
<span>{durationMinutes} min</span>
{arrivesTooLate && ( {arrivesTooLate && (
<span className="font-medium text-brand-pink"> <span className="font-medium text-brand-pink">
misses {arrivalBufferMinutes} min buffer misses {arrivalBufferMinutes} min buffer
</span> </span>
)} )}
</div> </div>
</li> </div>
); );
})} })}
</ul> {rankedJourneys.length > 1 && (
<p className="text-center text-sm font-semibold text-brand-fuchsia">
{expanded ? "Show fewer connections" : `Show ${rankedJourneys.length - 1} more connections`}
</p>
)}
</div>
); );
}; };
+4 -5
View File
@@ -18,6 +18,7 @@ export default function Home() {
const upcoming = events const upcoming = events
.filter((e) => e.eventTime >= new Date()) .filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime()); .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
const nextEvent = upcoming[0] ?? null;
return ( return (
<main className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6 lg:px-8"> <main className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
@@ -26,7 +27,7 @@ export default function Home() {
<div> <div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Departure desk</p> <p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Departure desk</p>
<h1 className="max-w-2xl text-3xl font-extrabold leading-tight text-white sm:text-5xl"> <h1 className="max-w-2xl text-3xl font-extrabold leading-tight text-white sm:text-5xl">
Know when to leave before the clock turns hostile. Leave before the clock turns against you.
</h1> </h1>
</div> </div>
<div className="grid grid-cols-2 gap-3 sm:min-w-60"> <div className="grid grid-cols-2 gap-3 sm:min-w-60">
@@ -41,7 +42,7 @@ export default function Home() {
</div> </div>
</div> </div>
</section> </section>
{upcoming.length === 0 ? ( {!nextEvent ? (
<div className="brand-panel mx-auto max-w-2xl rounded-2xl px-6 py-14 text-center"> <div className="brand-panel mx-auto max-w-2xl rounded-2xl px-6 py-14 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#8B5CF6] to-brand-pink text-2xl font-black text-white shadow-[0_16px_44px_rgba(178,60,255,0.4)]"> <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#8B5CF6] to-brand-pink text-2xl font-black text-white shadow-[0_16px_44px_rgba(178,60,255,0.4)]">
T T
@@ -53,9 +54,7 @@ export default function Home() {
</div> </div>
) : ( ) : (
<div className="space-y-5"> <div className="space-y-5">
{upcoming.map((event) => ( <EventCard key={nextEvent.id} event={nextEvent} originStation={originStation} />
<EventCard key={event.id} event={event} originStation={originStation} />
))}
</div> </div>
)} )}
</main> </main>
@@ -10,6 +10,9 @@ const makeLocationResponse = (locations: object[]) =>
const makeTripResponse = (journeys: object[]) => const makeTripResponse = (journeys: object[]) =>
JSON.stringify({ svcResL: [{ res: { outConL: journeys } }] }); JSON.stringify({ svcResL: [{ res: { outConL: journeys } }] });
const makeTripResponseWithCommon = (journeys: object[], common: object) =>
JSON.stringify({ svcResL: [{ res: { common, outConL: journeys } }] });
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -100,6 +103,42 @@ describe("HafasClient.fetchJourneys", () => {
expect(j.delay).toBe(5); expect(j.delay).toBe(5);
}); });
it("uses HAFAS common product metadata when section train labels are missing", async () => {
const now = new Date("2024-11-14T13:00:00");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
JSON.parse(
makeTripResponseWithCommon(
[
{
ctxRecon: "ctx-product",
secL: [
{
dep: { dTimeS: "130000", dTimeR: "130000", dPltfS: { txt: "4" } },
arr: { aTimeS: "140000", aTimeR: "140000" },
jny: { prodX: 0, dirTxt: "Wr. Neustadt Hbf" },
},
],
},
],
{ prodL: [{ nameS: "REX 1", prodCtx: { name: "REX 1" } }] },
)
)
),
})
);
const client = new HafasClient();
const journeys = await client.fetchJourneys(WIEN, GRAZ, now);
expect(journeys[0].platform).toBe("4");
expect(journeys[0].trains).toEqual(["REX 1 -> Wr. Neustadt Hbf"]);
});
it("returns empty array when no journeys found", async () => { it("returns empty array when no journeys found", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
+2
View File
@@ -10,6 +10,8 @@ export const NOMINATIM_URL = process.env.NOMINATIM_URL || "https://nominatim.ope
export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToLeave/2.0"; 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 WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2"; export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
export const OEBB_GTFS_URL =
process.env.OEBB_GTFS_URL || "https://static.web.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_Fahrplan_2026.zip";
export const DEFAULT_DAYS = 14; export const DEFAULT_DAYS = 14;
export const DEFAULT_STATION = DEFAULT_ORIGIN_STATION; export const DEFAULT_STATION = DEFAULT_ORIGIN_STATION;
export const DEFAULT_STATION_NAME = DEFAULT_ORIGIN_STATION_NAME; export const DEFAULT_STATION_NAME = DEFAULT_ORIGIN_STATION_NAME;
+341
View File
@@ -0,0 +1,341 @@
import { strFromU8, unzipSync } from "fflate";
import { OEBB_GTFS_URL } from "@/lib/constants";
type RawJson = Record<string, unknown>;
type TripInfo = {
serviceId: string;
routeId: string;
shortName: string;
headsign: string;
};
type StopTime = {
tripId: string;
stationKey: string;
arrivalSec: number;
departureSec: number;
sequence: number;
};
type ServiceCalendar = {
days: string[];
startDate: string;
endDate: string;
};
type GtfsIndex = {
trips: Map<string, TripInfo>;
routeNames: Map<string, string>;
tripStops: Map<string, StopTime[]>;
departures: Map<string, StopTime[]>;
calendars: Map<string, ServiceCalendar>;
exceptions: Map<string, "1" | "2">;
};
const REQUIRED_FILES = new Set([
"calendar.txt",
"calendar_dates.txt",
"routes.txt",
"stops.txt",
"stop_times.txt",
"trips.txt",
]);
let gtfsIndexPromise: Promise<GtfsIndex> | null = null;
function normalizeStationName(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/\([^)]*\)/g, " ")
.replace(/\bhbf\b/g, "hauptbahnhof")
.replace(/\bbahnhst\b/g, "bahnhof")
.replace(/\bbf\b/g, "bahnhof")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function parseCsvLine(line: string): string[] {
const values: string[] = [];
let value = "";
let quoted = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === "\"") {
if (quoted && line[i + 1] === "\"") {
value += "\"";
i++;
} else {
quoted = !quoted;
}
} else if (char === "," && !quoted) {
values.push(value);
value = "";
} else {
value += char;
}
}
values.push(value);
return values;
}
function parseCsvRows(text: string, onRow: (row: Record<string, string>) => void) {
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
const headers = parseCsvLine(lines[0] ?? "").map((header) => header.replace(/^\uFEFF/, ""));
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const values = parseCsvLine(line);
const row: Record<string, string> = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = values[j] ?? "";
}
onRow(row);
}
}
function parseGtfsTime(value: string): number | null {
const match = /^(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
if (!match) return null;
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
}
function parseHafasTime(value: unknown): number | null {
if (typeof value !== "string" || !/^\d{6}$/.test(value)) return null;
return Number(value.slice(0, 2)) * 3600 + Number(value.slice(2, 4)) * 60 + Number(value.slice(4, 6));
}
function dateToGtfsDate(date: Date): string {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
function weekdayIndex(date: Date): number {
return (date.getDay() + 6) % 7;
}
async function loadGtfsIndex(): Promise<GtfsIndex> {
if (!gtfsIndexPromise) {
gtfsIndexPromise = buildGtfsIndex();
}
return gtfsIndexPromise;
}
async function buildGtfsIndex(): Promise<GtfsIndex> {
const response = await fetch(OEBB_GTFS_URL, { next: { revalidate: 86_400 } });
if (!response.ok) {
throw new Error(`Failed to fetch ÖBB GTFS: ${response.status}`);
}
const zip = unzipSync(new Uint8Array(await response.arrayBuffer()), {
filter: (file) => REQUIRED_FILES.has(file.name.split("/").pop() ?? file.name),
});
const readFile = (name: string) => {
const data = Object.entries(zip).find(([path]) => path === name || path.endsWith(`/${name}`))?.[1];
if (!data) throw new Error(`GTFS file missing: ${name}`);
return strFromU8(data);
};
const stopStationKeys = new Map<string, string>();
parseCsvRows(readFile("stops.txt"), (row) => {
const stopId = row.stop_id;
const stopName = row.stop_name;
if (stopId && stopName) {
stopStationKeys.set(stopId, normalizeStationName(stopName));
}
});
const routeNames = new Map<string, string>();
parseCsvRows(readFile("routes.txt"), (row) => {
if (row.route_id) {
routeNames.set(row.route_id, row.route_short_name || row.route_long_name || "");
}
});
const trips = new Map<string, TripInfo>();
parseCsvRows(readFile("trips.txt"), (row) => {
if (!row.trip_id) return;
trips.set(row.trip_id, {
serviceId: row.service_id,
routeId: row.route_id,
shortName: row.trip_short_name,
headsign: row.trip_headsign,
});
});
const calendars = new Map<string, ServiceCalendar>();
parseCsvRows(readFile("calendar.txt"), (row) => {
if (!row.service_id) return;
calendars.set(row.service_id, {
days: [row.monday, row.tuesday, row.wednesday, row.thursday, row.friday, row.saturday, row.sunday],
startDate: row.start_date,
endDate: row.end_date,
});
});
const exceptions = new Map<string, "1" | "2">();
parseCsvRows(readFile("calendar_dates.txt"), (row) => {
if (row.service_id && row.date && (row.exception_type === "1" || row.exception_type === "2")) {
exceptions.set(`${row.date}:${row.service_id}`, row.exception_type);
}
});
const tripStops = new Map<string, StopTime[]>();
const departures = new Map<string, StopTime[]>();
parseCsvRows(readFile("stop_times.txt"), (row) => {
const tripId = row.trip_id;
const stationKey = stopStationKeys.get(row.stop_id);
const arrivalSec = parseGtfsTime(row.arrival_time);
const departureSec = parseGtfsTime(row.departure_time);
const sequence = Number(row.stop_sequence);
if (!tripId || !stationKey || arrivalSec === null || departureSec === null || !Number.isFinite(sequence)) {
return;
}
const stopTime = { tripId, stationKey, arrivalSec, departureSec, sequence };
const stops = tripStops.get(tripId) ?? [];
stops.push(stopTime);
tripStops.set(tripId, stops);
const key = `${stationKey}:${departureSec}`;
const candidates = departures.get(key) ?? [];
candidates.push(stopTime);
departures.set(key, candidates);
});
return { trips, routeNames, tripStops, departures, calendars, exceptions };
}
function serviceRunsOn(index: GtfsIndex, serviceId: string, date: Date): boolean {
const gtfsDate = dateToGtfsDate(date);
const exception = index.exceptions.get(`${gtfsDate}:${serviceId}`);
if (exception === "1") return true;
if (exception === "2") return false;
const calendar = index.calendars.get(serviceId);
if (!calendar) return false;
if (gtfsDate < calendar.startDate || gtfsDate > calendar.endDate) return false;
return calendar.days[weekdayIndex(date)] === "1";
}
function findGtfsTrain(
index: GtfsIndex,
depName: string,
arrName: string,
departureSec: number,
arrivalSec: number | null,
serviceDate: Date,
) {
const depKey = normalizeStationName(depName);
const arrKey = normalizeStationName(arrName);
const offsets = [0, -60, 60, -120, 120, -180, 180];
for (const offset of offsets) {
const candidates = index.departures.get(`${depKey}:${departureSec + offset}`) ?? [];
for (const candidate of candidates) {
const trip = index.trips.get(candidate.tripId);
if (!trip || !serviceRunsOn(index, trip.serviceId, serviceDate)) continue;
const stops = index.tripStops.get(candidate.tripId) ?? [];
const arrivalStop = stops.find((stop) => {
if (stop.sequence <= candidate.sequence || stop.stationKey !== arrKey) return false;
return arrivalSec === null || Math.abs(stop.arrivalSec - arrivalSec) <= 300;
});
if (!arrivalStop) continue;
const name = trip.shortName || index.routeNames.get(trip.routeId) || "";
if (!name) continue;
return { name, direction: trip.headsign };
}
}
return null;
}
function commonLocName(common: RawJson, locX: unknown): string {
if (typeof locX !== "number") return "";
const locL = common.locL;
if (!Array.isArray(locL)) return "";
const loc = locL[locX] as RawJson | undefined;
return typeof loc?.name === "string" ? loc.name : "";
}
function commonProductName(common: RawJson, prodX: unknown): string {
if (typeof prodX !== "number") return "";
const prodL = common.prodL;
if (!Array.isArray(prodL)) return "";
const product = prodL[prodX] as RawJson | undefined;
const prodCtx = product?.prodCtx as RawJson | undefined;
return [
typeof product?.nameS === "string" ? product.nameS : "",
typeof prodCtx?.name === "string" ? prodCtx.name.trim() : "",
typeof product?.name === "string" ? product.name : "",
].find(Boolean) ?? "";
}
export async function enrichHafasResponseWithGtfs(raw: unknown, serviceDate: Date): Promise<unknown> {
const response = raw as RawJson;
const res = ((response.svcResL as RawJson[] | undefined)?.[0]?.res ?? {}) as RawJson;
const common = (res.common ?? {}) as RawJson;
const outConL = res.outConL;
if (!Array.isArray(outConL)) return raw;
const missingSections: Array<{ section: RawJson; depName: string; arrName: string; depSec: number; arrSec: number | null }> = [];
for (const connection of outConL as RawJson[]) {
const secL = connection.secL;
if (!Array.isArray(secL)) continue;
for (const section of secL as RawJson[]) {
const jny = section.jny as RawJson | undefined;
if (!jny) continue;
if (commonProductName(common, jny.prodX) || typeof jny.name === "string" || typeof jny.gtfsName === "string") {
continue;
}
const dep = (section.dep ?? {}) as RawJson;
const arr = (section.arr ?? {}) as RawJson;
const depSec = parseHafasTime(dep.dTimeS);
if (depSec === null) continue;
const depName = commonLocName(common, dep.locX);
const arrName = commonLocName(common, arr.locX);
if (!depName || !arrName) continue;
missingSections.push({
section,
depName,
arrName,
depSec,
arrSec: parseHafasTime(arr.aTimeS),
});
}
}
if (missingSections.length === 0) return raw;
const index = await loadGtfsIndex();
for (const missing of missingSections) {
const match = findGtfsTrain(index, missing.depName, missing.arrName, missing.depSec, missing.arrSec, serviceDate);
if (!match) continue;
const jny = missing.section.jny as RawJson;
jny.gtfsName = match.name;
jny.gtfsDirection = match.direction;
}
return raw;
}
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 18.12.2",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
+7
View File
@@ -53,6 +53,7 @@
"@timetoleave/api-client": "*", "@timetoleave/api-client": "*",
"@timetoleave/core": "*", "@timetoleave/core": "*",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fflate": "^0.8.2",
"next": "^16.2.6", "next": "^16.2.6",
"node-ical": "^0.26.1", "node-ical": "^0.26.1",
"react": "19.1.0", "react": "19.1.0",
@@ -9413,6 +9414,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+15 -5
View File
@@ -21,7 +21,9 @@ type RawJson = any;
* @param queryDate - The original JavaScript Date used for the query (fallback for missing times) * @param queryDate - The original JavaScript Date used for the query (fallback for missing times)
*/ */
export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate: Date): Journey[] { export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate: Date): Journey[] {
const outConL: RawJson[] = json?.svcResL?.[0]?.res?.outConL ?? []; const res = json?.svcResL?.[0]?.res;
const outConL: RawJson[] = res?.outConL ?? [];
const prodL: RawJson[] = res?.common?.prodL ?? [];
return outConL.map((con: RawJson, i: number): Journey => { return outConL.map((con: RawJson, i: number): Journey => {
const first = con.secL?.[0]; const first = con.secL?.[0];
@@ -38,16 +40,24 @@ export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate:
const trains: string[] = (con.secL ?? []) const trains: string[] = (con.secL ?? [])
.filter((s: RawJson) => s.jny) .filter((s: RawJson) => s.jny)
.map((s: RawJson) => { .map((s: RawJson) => {
const name = s.jny?.stopL?.[0]?.name ?? ""; const product = prodL[s.jny?.prodX];
const direction = s.jny?.dirTxt ?? s.jny?.dir ?? ""; const name =
return name && direction ? `${name} -> ${direction}` : name; s.jny?.gtfsName ??
s.jny?.name ??
product?.nameS ??
product?.prodCtx?.name?.trim() ??
product?.name ??
s.jny?.stopL?.[0]?.name ??
"";
const direction = s.jny?.gtfsDirection ?? s.jny?.dirTxt ?? s.jny?.dir ?? "";
return name && direction ? `${name.trim()} -> ${direction}` : name.trim();
}) })
.filter(Boolean); .filter(Boolean);
return { return {
id: con.ctxRecon ?? `journey-${i}`, id: con.ctxRecon ?? `journey-${i}`,
sD, rD, sA, rA, delay, sD, rD, sA, rA, delay,
platform: dep?.dPlatfS ?? "", platform: dep?.dPlatfS ?? dep?.dPltfS?.txt ?? "",
changes: Math.max(0, (con.secL ?? []).filter((s: RawJson) => s.jny).length - 1), changes: Math.max(0, (con.secL ?? []).filter((s: RawJson) => s.jny).length - 1),
trains, trains,
cancelled: (con.secL ?? []).some((s: RawJson) => s.jny?.isCncl === true), cancelled: (con.secL ?? []).some((s: RawJson) => s.jny?.isCncl === true),