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:
@@ -15,6 +15,7 @@
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
"date-fns": "^4.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"next": "^16.2.6",
|
||||
"node-ical": "^0.26.1",
|
||||
"react": "19.1.0",
|
||||
@@ -22,8 +23,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "~19.1.10",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
|
||||
import { hafasDateTime } from "@timetoleave/core";
|
||||
import { readBodyWithLimit } from "@/lib/api-guards";
|
||||
import { enrichHafasResponseWithGtfs } from "@/lib/oebb-gtfs";
|
||||
|
||||
/** HAFAS protocol version identifier sent in every request envelope. */
|
||||
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. */
|
||||
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
|
||||
type HafasMethod = (typeof ALLOWED_METHODS)[number];
|
||||
|
||||
/** Single service request entry inside a HAFAS envelope. */
|
||||
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.
|
||||
*
|
||||
@@ -120,6 +128,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
await tryEnrichWithGtfs(data, dateObj);
|
||||
return NextResponse.json(data);
|
||||
} catch (error: unknown) {
|
||||
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);
|
||||
}
|
||||
|
||||
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 timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
||||
|
||||
@@ -203,6 +217,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (tripSearchDate && !Number.isNaN(tripSearchDate.getTime())) {
|
||||
await tryEnrichWithGtfs(data, tripSearchDate);
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch (error: unknown) {
|
||||
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"]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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();
|
||||
|
||||
/** Wiener Linien stop IDs can be numeric or in WL:format */
|
||||
const STOP_ID_RE = /^(?:\d+|WL:\d+)$/;
|
||||
/** Wiener Linien stop IDs can be numeric, WL:numeric, or WL:StopPoint:numeric. */
|
||||
const STOP_ID_RE = /^(?:\d+|WL:\d+|WL:StopPoint:\d+)$/;
|
||||
|
||||
/** Maximum stop IDs to batch-request in a single call. */
|
||||
const MAX_STOP_IDS = 10;
|
||||
|
||||
@@ -7,6 +7,9 @@ import CalendarView from "./CalendarView";
|
||||
import DayEvents from "./DayEvents";
|
||||
import CalendarPanel from "./CalendarPanel";
|
||||
import BatchEditPanel from "./BatchEditPanel";
|
||||
import Button from "@/app/ui/Button";
|
||||
|
||||
type ActivePanel = "import" | "edit" | null;
|
||||
|
||||
/**
|
||||
* Calendar page layout.
|
||||
@@ -18,21 +21,39 @@ export default function CalendarPage() {
|
||||
const { events } = useEventsStore();
|
||||
const { station: originStation } = useOriginStation();
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
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 className="mb-6 space-y-4">
|
||||
<CalendarPanel />
|
||||
<BatchEditPanel />
|
||||
</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">
|
||||
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
|
||||
</div>
|
||||
@@ -40,6 +61,11 @@ export default function CalendarPage() {
|
||||
<DayEvents events={events} date={selectedDate} originStation={originStation} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{activePanel === "import" && <CalendarPanel />}
|
||||
{activePanel === "edit" && <BatchEditPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { Journey } from "@timetoleave/core";
|
||||
import { formatTime } from "@timetoleave/core";
|
||||
import { formatTime, rankJourneys } from "@timetoleave/core";
|
||||
import LeaveByBadge from "./LeaveByBadge";
|
||||
import { calculateCountdown } from "@timetoleave/core";
|
||||
|
||||
@@ -33,23 +33,42 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
walkDurationSeconds = 0,
|
||||
className = "",
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
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) {
|
||||
return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={`space-y-3 p-4 ${className}`}>
|
||||
{journeys.map((journey) => {
|
||||
<div
|
||||
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 arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime();
|
||||
const departure = journey.rD ?? journey.sD;
|
||||
const arrival = journey.rA ?? journey.sA;
|
||||
const durationMinutes = Math.max(0, Math.round((journey.rA.getTime() - journey.rD.getTime()) / 60000));
|
||||
|
||||
return (
|
||||
<li
|
||||
<div
|
||||
key={journey.id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
arrivesTooLate || journey.cancelled
|
||||
@@ -67,8 +86,13 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<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>
|
||||
{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 className="flex-1 text-right">
|
||||
<LeaveByBadge countdown={calculateCountdown(departure)} />
|
||||
@@ -80,16 +104,22 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
Arrives {formatTime(arrival)}
|
||||
{walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""}
|
||||
</span>
|
||||
<span>{durationMinutes} min</span>
|
||||
{arrivesTooLate && (
|
||||
<span className="font-medium text-brand-pink">
|
||||
misses {arrivalBufferMinutes} min buffer
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function Home() {
|
||||
const upcoming = events
|
||||
.filter((e) => e.eventTime >= new Date())
|
||||
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
|
||||
const nextEvent = upcoming[0] ?? null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
Know when to leave before the clock turns hostile.
|
||||
Leave before the clock turns against you.
|
||||
</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:min-w-60">
|
||||
@@ -41,7 +42,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{upcoming.length === 0 ? (
|
||||
{!nextEvent ? (
|
||||
<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)]">
|
||||
T
|
||||
@@ -53,9 +54,7 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{upcoming.map((event) => (
|
||||
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||
))}
|
||||
<EventCard key={nextEvent.id} event={nextEvent} originStation={originStation} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -10,6 +10,9 @@ const makeLocationResponse = (locations: object[]) =>
|
||||
const makeTripResponse = (journeys: object[]) =>
|
||||
JSON.stringify({ svcResL: [{ res: { outConL: journeys } }] });
|
||||
|
||||
const makeTripResponseWithCommon = (journeys: object[], common: object) =>
|
||||
JSON.stringify({ svcResL: [{ res: { common, outConL: journeys } }] });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -100,6 +103,42 @@ describe("HafasClient.fetchJourneys", () => {
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -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 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 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_STATION = DEFAULT_ORIGIN_STATION;
|
||||
export const DEFAULT_STATION_NAME = DEFAULT_ORIGIN_STATION_NAME;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user