import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createRequestHandler } from "./lib/request-handler"; export interface Station { id: string; name: string; } export interface TimetableEntry { timestamp: Date; delay: number; line: { name: string; color: string | null; } | null; location: string; platform: { name: string; hasChanged: boolean; } | null; } export interface Timetable { stationId: string; timestamp: Date; entries: TimetableEntry[]; } const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; export const timetableSearchStationsRequestHandler = createRequestHandler({ async requestAsync(input) { return await searchStationsAsync(input.baseUrl, input.query); }, cacheTtlMs: 24 * 60 * 60 * 1000, }); export const timetableGetTimetableRequestHandler = createRequestHandler({ async requestAsync(input) { return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit }); }, cacheTtlMs: 60_000, }); const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => { const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`); for (const [key, value] of Object.entries(queryParams)) { url.searchParams.set(key, value.toString()); } return url; }; const searchStationsAsync = async (baseUrl: string, query: string): Promise => { const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }), ); if (!response.ok) throw new ResponseError(response); const data = await searchSchema.parseAsync(await response.json()); return data .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) .filter((item) => item !== null); }; const getTimetableAsync = async ( baseUrl: string, options: { stationId: string; limit: number }, ): Promise => { const now = new Date(); const date = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Zurich", month: "2-digit", day: "2-digit", year: "numeric", }).format(now); const time = new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Zurich", hour: "2-digit", minute: "2-digit", hour12: false, }).format(now); const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, "/timetable/api/stationboard.json", { stop: options.stationId, limit: options.limit, show_delays: 1, show_tracks: 1, date, time, }), ); if (!response.ok) throw new ResponseError(response); const data = await timetableSchema.parseAsync(await response.json()); return { stationId: options.stationId, timestamp: now, entries: data.connections.map((connection) => { const color = connection.color.split("~")[0]; return { timestamp: connection.time, line: connection.line ? { name: connection.line, color: color && color.length >= 1 ? `#${color}` : null, } : null, location: connection.terminal.name, delay: connection.dep_delay, platform: connection.track ? { name: connection.track.replace("!", ""), hasChanged: connection.track.includes("!"), } : null, }; }), }; }; const timetableSchema = z.object({ connections: z.array( z.object({ time: z.string().transform((str) => new Date(str)), line: z.string().optional(), color: z.string(), terminal: z.object({ name: z.string() }), track: z.string().optional(), dep_delay: z .string() .optional() .transform((str) => (str ? parseInt(str) : 0)), }), ), }); const searchSchema = z.array( z.object({ id: z.string().optional(), label: z.string(), iconclass: z.string(), }), );