// // TFLDataSource.swift // iris // import Foundation struct TFLDataSourceConfig: Sendable { var cacheValiditySec: Int = 120 var ttlSec: Int = 300 var maxDisruptions: Int = 3 init() {} } @MainActor final class TFLDataSource { struct Disruption: Sendable, Equatable { let id: String let lineName: String let lineId: String let severity: Int let severityDescription: String let reason: String? let isMajor: Bool } struct TFLData: Sendable, Equatable { let disruptions: [Disruption] } struct Snapshot: Sendable { let data: TFLData let diagnostics: [String: String] } enum TFLError: Error, LocalizedError, Sendable { case networkFailed(message: String, diagnostics: [String: String]) case invalidResponse(diagnostics: [String: String]) var errorDescription: String? { switch self { case .networkFailed(let message, _): return message case .invalidResponse: return "Invalid TFL response" } } } private let config: TFLDataSourceConfig private var cache: (timestamp: Int, data: TFLData)? var ttlSec: Int { config.ttlSec } init(config: TFLDataSourceConfig = .init()) { self.config = config } func dataWithDiagnostics(now: Int) async throws -> Snapshot { var diagnostics: [String: String] = [ "now": String(now), "cache_validity_sec": String(config.cacheValiditySec), ] if let cache = cache, now - cache.timestamp < config.cacheValiditySec { diagnostics["source"] = "cache" diagnostics["cache_age_sec"] = String(now - cache.timestamp) return Snapshot(data: cache.data, diagnostics: diagnostics) } guard let url = URL(string: "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status") else { throw TFLError.invalidResponse(diagnostics: diagnostics) } var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { diagnostics["http_status"] = String(httpResponse.statusCode) guard (200..<300).contains(httpResponse.statusCode) else { throw TFLError.networkFailed( message: "HTTP \(httpResponse.statusCode)", diagnostics: diagnostics ) } } let lines = try JSONDecoder().decode([TFLLineStatus].self, from: data) diagnostics["source"] = "network" diagnostics["lines_returned"] = String(lines.count) var disruptions: [Disruption] = [] var seenLines: Set = [] for line in lines { guard !seenLines.contains(line.id) else { continue } for status in line.lineStatuses { guard status.statusSeverity < 10 else { continue } seenLines.insert(line.id) let isMajor = status.statusSeverity <= 6 let disruption = Disruption( id: "\(line.id):\(status.statusSeverity)", lineName: line.name, lineId: line.id, severity: status.statusSeverity, severityDescription: status.statusSeverityDescription, reason: status.reason ?? status.disruption?.description, isMajor: isMajor ) disruptions.append(disruption) break } } disruptions.sort { $0.severity < $1.severity } let limited = Array(disruptions.prefix(config.maxDisruptions)) diagnostics["disruptions_found"] = String(disruptions.count) diagnostics["disruptions_returned"] = String(limited.count) let tflData = TFLData(disruptions: limited) cache = (timestamp: now, data: tflData) return Snapshot(data: tflData, diagnostics: diagnostics) } func disruptionTitle(_ disruption: Disruption) -> String { var name = disruption.lineName name = name.replacingOccurrences(of: " & City", with: "") name = name.replacingOccurrences(of: "Hammersmith", with: "H'smith") name = name.replacingOccurrences(of: "Metropolitan", with: "Met") name = name.replacingOccurrences(of: "Waterloo", with: "W'loo") name = name.replacingOccurrences(of: "Elizabeth line", with: "Eliz.") var severity = disruption.severityDescription severity = severity.replacingOccurrences(of: "Minor Delays", with: "Delays") severity = severity.replacingOccurrences(of: "Severe Delays", with: "Severe") severity = severity.replacingOccurrences(of: "Part Closure", with: "Part Closed") severity = severity.replacingOccurrences(of: "Part Suspended", with: "Part Susp.") return "\(name): \(severity)" } func disruptionSubtitle(_ disruption: Disruption) -> String { guard let reason = disruption.reason else { return "Check TFL for details" } let phrases = reason.components(separatedBy: ".") if let first = phrases.first?.trimmingCharacters(in: .whitespacesAndNewlines), !first.isEmpty { return first } return "Check TFL for details" } } // MARK: - TFL API Response Models private struct TFLLineStatus: Codable { let id: String let name: String let modeName: String let lineStatuses: [LineStatus] struct LineStatus: Codable { let statusSeverity: Int let statusSeverityDescription: String let reason: String? let validityPeriods: [ValidityPeriod]? let disruption: Disruption? } struct ValidityPeriod: Codable { let fromDate: String? let toDate: String? } struct Disruption: Codable { let category: String? let description: String? let closureText: String? } }