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),