Files
aris-old/IrisCompanion/iris/DataSources/TFLDataSource.swift
christophergyman d8929d3776 Refactor TFL severity filtering to use Sets
Replace magic numbers with documented static Sets for clarity.
ignoredSeverities and majorSeverities make the filtering logic
self-documenting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:54:21 +00:00

194 lines
6.4 KiB
Swift

//
// 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"
}
}
}
// Severity 4 = Planned Closure, 5 = Part Closure, 10 = Good Service
private static let ignoredSeverities: Set<Int> = [4, 5, 10]
// Severity 1 = Closed, 2 = Suspended, 3 = Part Suspended, 6 = Severe Delays
private static let majorSeverities: Set<Int> = [1, 2, 3, 6]
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<String> = []
for line in lines {
guard !seenLines.contains(line.id) else { continue }
for status in line.lineStatuses {
guard !Self.ignoredSeverities.contains(status.statusSeverity) else { continue }
seenLines.insert(line.id)
let isMajor = Self.majorSeverities.contains(status.statusSeverity)
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?
}
}