import { type } from "arktype" import type { StationLocation, TflAlertSeverity } from "./types.ts" const TFL_API_BASE = "https://api.tfl.gov.uk" const ALL_LINE_IDS: TflLineId[] = [ "bakerloo", "central", "circle", "district", "hammersmith-city", "jubilee", "metropolitan", "northern", "piccadilly", "victoria", "waterloo-city", "lioness", "mildmay", "windrush", "weaver", "suffragette", "liberty", "elizabeth", ] // TfL severity codes: https://api.tfl.gov.uk/Line/Meta/Severity // 0 = Special Service, 1 = Closed, 6 = Severe Delays, 9 = Minor Delays, 10 = Good Service const SEVERITY_MAP: Record = { 1: "closure", 2: "closure", // Suspended 3: "closure", // Part Suspended 4: "closure", // Planned Closure 5: "closure", // Part Closure 6: "major-delays", // Severe Delays 7: "major-delays", // Reduced Service 8: "major-delays", // Bus Service 9: "minor-delays", // Minor Delays 10: null, // Good Service 11: null, // Part Closed 12: null, // Exit Only 13: null, // No Step Free Access 14: null, // Change of frequency 15: null, // Diverted 16: null, // Not Running 17: null, // Issues Reported 18: null, // No Issues 19: null, // Information 20: null, // Service Closed } export interface TflLineStatus { lineId: TflLineId lineName: string severity: TflAlertSeverity description: string } export interface ITflApi { fetchLineStatuses(lines?: TflLineId[]): Promise fetchStations(): Promise } export class TflApi implements ITflApi { private apiKey: string private stationsCache: StationLocation[] | null = null constructor(apiKey: string) { this.apiKey = apiKey } private async fetch(path: string): Promise { const url = new URL(path, TFL_API_BASE) url.searchParams.set("app_key", this.apiKey) const response = await fetch(url.toString()) if (!response.ok) { throw new Error(`TfL API error: ${response.status} ${response.statusText}`) } return response.json() as Promise } async fetchLineStatuses(lines?: TflLineId[]): Promise { const lineIds = lines ?? ALL_LINE_IDS const data = await this.fetch(`/Line/${lineIds.join(",")}/Status`) const parsed = lineResponseArray(data) if (parsed instanceof type.errors) { throw new Error(`Invalid TfL API response: ${parsed.summary}`) } const statuses: TflLineStatus[] = [] for (const line of parsed) { for (const status of line.lineStatuses) { const severity = SEVERITY_MAP[status.statusSeverity] if (severity) { statuses.push({ lineId: line.id, lineName: line.name, severity, description: status.reason ?? status.statusSeverityDescription, }) } } } return statuses } async fetchStations(): Promise { if (this.stationsCache) { return this.stationsCache } // Fetch stations for all lines in parallel const responses = await Promise.all( ALL_LINE_IDS.map(async (id) => { const data = await this.fetch(`/Line/${id}/StopPoints`) const parsed = lineStopPointsArray(data) if (parsed instanceof type.errors) { throw new Error(`Invalid TfL API response for line ${id}: ${parsed.summary}`) } return { lineId: id, stops: parsed } }), ) // Merge stations, combining lines for shared stations const stationMap = new Map() for (const { lineId: currentLineId, stops } of responses) { for (const stop of stops) { const existing = stationMap.get(stop.naptanId) if (existing) { if (!existing.lines.includes(currentLineId)) { existing.lines.push(currentLineId) } } else { stationMap.set(stop.naptanId, { id: stop.naptanId, name: stop.commonName, lat: stop.lat, lng: stop.lon, lines: [currentLineId], }) } } } this.stationsCache = Array.from(stationMap.values()) return this.stationsCache } } // Schemas const lineId = type( "'bakerloo' | 'central' | 'circle' | 'district' | 'hammersmith-city' | 'jubilee' | 'metropolitan' | 'northern' | 'piccadilly' | 'victoria' | 'waterloo-city' | 'lioness' | 'mildmay' | 'windrush' | 'weaver' | 'suffragette' | 'liberty' | 'elizabeth'", ) export type TflLineId = typeof lineId.infer const lineStatus = type({ statusSeverity: "number", statusSeverityDescription: "string", "reason?": "string", }) const lineResponse = type({ id: lineId, name: "string", lineStatuses: lineStatus.array(), }) const lineResponseArray = lineResponse.array() const lineStopPoint = type({ naptanId: "string", commonName: "string", lat: "number", lon: "number", }) const lineStopPointsArray = lineStopPoint.array()