import { DataSourceError } from "../common/errors"; import type { Diagnostics } from "../common/types"; import type { TflData, TflDataSource, TflDataSourceConfig, TflDisruption, TflRequest, TflStatusSeverity, } from "./types"; import { TFL_STATUS_SEVERITY } from "./types"; type CacheEntry = { timestamp: number; data: TflData; }; const defaultConfig: Required = { cacheValiditySec: 120, ttlSec: 300, maxDisruptions: 3, }; const ignoredSeverities = new Set([ TFL_STATUS_SEVERITY.PlannedClosure, TFL_STATUS_SEVERITY.PartClosure, TFL_STATUS_SEVERITY.GoodService, ]); const majorSeverities = new Set([ TFL_STATUS_SEVERITY.Closed, TFL_STATUS_SEVERITY.Suspended, TFL_STATUS_SEVERITY.PartSuspended, TFL_STATUS_SEVERITY.SevereDelays, ]); let cache: CacheEntry | null = null; const resolveConfig = ( base: TflDataSourceConfig, override?: TflDataSourceConfig, ): Required => ({ ...defaultConfig, ...base, ...(override ?? {}), }); const fetchTflData = async ( request: TflRequest, config: TflDataSourceConfig, ): Promise<{ data: TflData; diagnostics: Diagnostics }> => { const resolvedConfig = resolveConfig(config, request.config); const diagnostics: Diagnostics = { now: String(request.now), cache_validity_sec: String(resolvedConfig.cacheValiditySec), }; if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) { diagnostics.source = "cache"; diagnostics.cache_age_sec = String(request.now - cache.timestamp); return { data: cache.data, diagnostics }; } const url = "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status"; const response = await fetch(url, { headers: { Accept: "application/json", }, }); diagnostics.http_status = String(response.status); if (!response.ok) { throw new DataSourceError( "network_failed", `HTTP ${response.status}`, diagnostics, ); } const lines = (await response.json()) as Array<{ id: string; name: string; lineStatuses?: Array<{ statusSeverity: number; statusSeverityDescription: string; reason?: string | null; disruption?: { description?: string | null } | null; }>; }>; diagnostics.source = "network"; diagnostics.lines_returned = String(lines.length); const disruptions: TflDisruption[] = []; const seenLines = new Set(); for (const line of lines) { if (seenLines.has(line.id)) { continue; } const statuses = line.lineStatuses ?? []; for (const status of statuses) { if (ignoredSeverities.has(status.statusSeverity)) { continue; } seenLines.add(line.id); const isMajor = majorSeverities.has(status.statusSeverity); disruptions.push({ id: `${line.id}:${status.statusSeverity}`, lineName: line.name, lineId: line.id, severity: status.statusSeverity, severityDescription: status.statusSeverityDescription, reason: status.reason ?? status.disruption?.description ?? null, isMajor, }); break; } } disruptions.sort((a, b) => a.severity - b.severity); const limited = disruptions.slice(0, resolvedConfig.maxDisruptions); diagnostics.disruptions_found = String(disruptions.length); diagnostics.disruptions_returned = String(limited.length); const data = { disruptions: limited }; cache = { timestamp: request.now, data }; return { data, diagnostics }; }; export const createTflDataSource = ( config: TflDataSourceConfig = {}, ): TflDataSource => ({ dataWithDiagnostics: (request) => fetchTflData(request, config), }); export const formatTflDisruptionTitle = (disruption: TflDisruption) => { let name = disruption.lineName; name = name.replace(" & City", ""); name = name.replace("Hammersmith", "H'smith"); name = name.replace("Metropolitan", "Met"); name = name.replace("Waterloo", "W'loo"); name = name.replace("Elizabeth line", "Eliz."); let severity = disruption.severityDescription; severity = severity.replace("Minor Delays", "Delays"); severity = severity.replace("Severe Delays", "Severe"); severity = severity.replace("Part Closure", "Part Closed"); severity = severity.replace("Part Suspended", "Part Susp."); return `${name}: ${severity}`; }; export const formatTflDisruptionSubtitle = (disruption: TflDisruption) => { if (!disruption.reason) { return "Check TFL for details"; } const first = disruption.reason .split(".") .map((part) => part.trim()) .find((part) => part.length > 0); return first && first.length > 0 ? first : "Check TFL for details"; };