From e52f2497e3b77290b356c497b73fe784b9365384 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 14 May 2026 18:53:41 +0200 Subject: [PATCH] Add GTFS enrichment to HAFAS response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.json | 15 + apps/web/package.json | 3 +- apps/web/src/app/api/hafas/route.ts | 19 +- .../monitor/__tests__/route.test.ts | 10 + .../src/app/api/wienerlinien/monitor/route.ts | 6 +- apps/web/src/app/calendar/page.tsx | 46 ++- apps/web/src/app/event/JourneyList.tsx | 46 ++- apps/web/src/app/page.tsx | 9 +- .../src/lib/__tests__/hafas-client.test.ts | 39 ++ apps/web/src/lib/constants.ts | 2 + apps/web/src/lib/oebb-gtfs.ts | 341 ++++++++++++++++++ eas.json | 21 ++ package-lock.json | 7 + packages/core/src/hafas-parser.ts | 20 +- 14 files changed, 551 insertions(+), 33 deletions(-) create mode 100644 app.json create mode 100644 apps/web/src/lib/oebb-gtfs.ts create mode 100644 eas.json diff --git a/app.json b/app.json new file mode 100644 index 0000000..ebfebab --- /dev/null +++ b/app.json @@ -0,0 +1,15 @@ +{ + "expo": { + "extra": { + "eas": { + "projectId": "78e8f448-5ce1-4d2e-b589-481c67cb7aef" + } + }, + "ios": { + "bundleIdentifier": "com.floegger.timetoleave", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 4a15c4a..ea79edb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/api/hafas/route.ts b/apps/web/src/app/api/hafas/route.ts index ac2df68..a1d5c06 100644 --- a/apps/web/src/app/api/hafas/route.ts +++ b/apps/web/src/app/api/hafas/route.ts @@ -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") { diff --git a/apps/web/src/app/api/wienerlinien/monitor/__tests__/route.test.ts b/apps/web/src/app/api/wienerlinien/monitor/__tests__/route.test.ts index 6145ad9..edcb27b 100644 --- a/apps/web/src/app/api/wienerlinien/monitor/__tests__/route.test.ts +++ b/apps/web/src/app/api/wienerlinien/monitor/__tests__/route.test.ts @@ -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")); diff --git a/apps/web/src/app/api/wienerlinien/monitor/route.ts b/apps/web/src/app/api/wienerlinien/monitor/route.ts index c27b398..18a735d 100644 --- a/apps/web/src/app/api/wienerlinien/monitor/route.ts +++ b/apps/web/src/app/api/wienerlinien/monitor/route.ts @@ -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; diff --git a/apps/web/src/app/calendar/page.tsx b/apps/web/src/app/calendar/page.tsx index d886360..d4a9ff1 100644 --- a/apps/web/src/app/calendar/page.tsx +++ b/apps/web/src/app/calendar/page.tsx @@ -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(new Date()); + const [activePanel, setActivePanel] = React.useState(null); + + const togglePanel = (panel: Exclude) => { + setActivePanel((current) => (current === panel ? null : panel)); + }; return (
-
-

Calendar sync

-

Import, inspect, and time your day.

-

View and manage every appointment from a single departure-focused calendar.

+
+
+

Calendar sync

+

Calendar

+

View and manage every appointment from a single departure-focused calendar.

+
+
+ + +
-
- - -
- -
+
@@ -40,6 +61,11 @@ export default function CalendarPage() {
+ +
+ {activePanel === "import" && } + {activePanel === "edit" && } +
); } diff --git a/apps/web/src/app/event/JourneyList.tsx b/apps/web/src/app/event/JourneyList.tsx index 8995e1c..89db160 100644 --- a/apps/web/src/app/event/JourneyList.tsx +++ b/apps/web/src/app/event/JourneyList.tsx @@ -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 = ({ 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
No journeys found
; } return ( -
    - {journeys.map((journey) => { +
    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 ( -
  • = ({
  • - {journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")} + {journey.cancelled ? "Cancelled" : journey.trains.join(", ")} + {index === 0 && ( + + Top + + )}
    @@ -80,16 +104,22 @@ const JourneyList: React.FC = ({ Arrives {formatTime(arrival)} {walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""} + {durationMinutes} min {arrivesTooLate && ( misses {arrivalBufferMinutes} min buffer )}
    - +
); })} - + {rankedJourneys.length > 1 && ( +

+ {expanded ? "Show fewer connections" : `Show ${rankedJourneys.length - 1} more connections`} +

+ )} + ); }; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 2fa003b..443908f 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -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 (
@@ -26,7 +27,7 @@ export default function Home() {

Departure desk

- Know when to leave before the clock turns hostile. + Leave before the clock turns against you.

@@ -41,7 +42,7 @@ export default function Home() {
- {upcoming.length === 0 ? ( + {!nextEvent ? (
T @@ -53,9 +54,7 @@ export default function Home() {
) : (
- {upcoming.map((event) => ( - - ))} +
)}
diff --git a/apps/web/src/lib/__tests__/hafas-client.test.ts b/apps/web/src/lib/__tests__/hafas-client.test.ts index 01e6f78..f1d2e1d 100644 --- a/apps/web/src/lib/__tests__/hafas-client.test.ts +++ b/apps/web/src/lib/__tests__/hafas-client.test.ts @@ -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", diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 8ab0b68..5bdb594 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -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; diff --git a/apps/web/src/lib/oebb-gtfs.ts b/apps/web/src/lib/oebb-gtfs.ts new file mode 100644 index 0000000..052cb5a --- /dev/null +++ b/apps/web/src/lib/oebb-gtfs.ts @@ -0,0 +1,341 @@ +import { strFromU8, unzipSync } from "fflate"; +import { OEBB_GTFS_URL } from "@/lib/constants"; + +type RawJson = Record; + +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; + routeNames: Map; + tripStops: Map; + departures: Map; + calendars: Map; + exceptions: Map; +}; + +const REQUIRED_FILES = new Set([ + "calendar.txt", + "calendar_dates.txt", + "routes.txt", + "stops.txt", + "stop_times.txt", + "trips.txt", +]); + +let gtfsIndexPromise: Promise | 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) => 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 = {}; + 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 { + if (!gtfsIndexPromise) { + gtfsIndexPromise = buildGtfsIndex(); + } + return gtfsIndexPromise; +} + +async function buildGtfsIndex(): Promise { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const departures = new Map(); + 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 { + 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; +} diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..4275b00 --- /dev/null +++ b/eas.json @@ -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": {} + } +} diff --git a/package-lock.json b/package-lock.json index ca43d97..1f74b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,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", @@ -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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/packages/core/src/hafas-parser.ts b/packages/core/src/hafas-parser.ts index b6c60b6..ece3ede 100644 --- a/packages/core/src/hafas-parser.ts +++ b/packages/core/src/hafas-parser.ts @@ -21,7 +21,9 @@ type RawJson = any; * @param queryDate - The original JavaScript Date used for the query (fallback for missing times) */ 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 => { const first = con.secL?.[0]; @@ -38,16 +40,24 @@ export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate: const trains: string[] = (con.secL ?? []) .filter((s: RawJson) => s.jny) .map((s: RawJson) => { - const name = s.jny?.stopL?.[0]?.name ?? ""; - const direction = s.jny?.dirTxt ?? s.jny?.dir ?? ""; - return name && direction ? `${name} -> ${direction}` : name; + const product = prodL[s.jny?.prodX]; + const 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); return { id: con.ctxRecon ?? `journey-${i}`, 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), trains, cancelled: (con.secL ?? []).some((s: RawJson) => s.jny?.isCncl === true),