2026-01-16 23:57:29 +00:00
|
|
|
import { type } from "arktype"
|
2026-01-18 20:23:54 +00:00
|
|
|
|
2026-01-25 14:14:06 +00:00
|
|
|
import type { StationLocation, TflAlertSeverity, TflLineStatus } from "./types.ts"
|
2026-01-16 23:57:29 +00:00
|
|
|
|
|
|
|
|
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<number, TflAlertSeverity | null> = {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 14:14:06 +00:00
|
|
|
export class TflApi {
|
2026-01-16 23:57:29 +00:00
|
|
|
private apiKey: string
|
|
|
|
|
private stationsCache: StationLocation[] | null = null
|
|
|
|
|
|
|
|
|
|
constructor(apiKey: string) {
|
|
|
|
|
this.apiKey = apiKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async fetch<T>(path: string): Promise<T> {
|
|
|
|
|
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<T>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
|
|
|
|
const lineIds = lines ?? ALL_LINE_IDS
|
|
|
|
|
const data = await this.fetch<unknown>(`/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<StationLocation[]> {
|
|
|
|
|
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<unknown>(`/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<string, StationLocation>()
|
|
|
|
|
|
|
|
|
|
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()
|