From e15be9ddc448de109890e69e30da016227608a37 Mon Sep 17 00:00:00 2001 From: christophergyman Date: Sat, 10 Jan 2026 21:46:23 +0000 Subject: [PATCH 1/3] Add TFL train disruption alerts integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query TFL API for Tube and Elizabeth Line status, displaying disruptions as feed cards. Major disruptions (severity 1-6) appear as RIGHT_NOW spotlight cards, minor delays (7-9) as FYI items. - Add TFLDataSource with 2-min cache and severity classification - Add .transitAlert FeedItemType with 0.85 base weight - Wire up async fetch in ContextOrchestrator pipeline - Handle timeout and failure cases gracefully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../iris/DataSources/TFLDataSource.swift | 188 ++++++++++++++++++ .../iris/Models/HeuristicRanker.swift | 1 + IrisCompanion/iris/Models/Winner.swift | 1 + .../Orchestrator/ContextOrchestrator.swift | 67 ++++++- 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 IrisCompanion/iris/DataSources/TFLDataSource.swift diff --git a/IrisCompanion/iris/DataSources/TFLDataSource.swift b/IrisCompanion/iris/DataSources/TFLDataSource.swift new file mode 100644 index 0000000..d4012e1 --- /dev/null +++ b/IrisCompanion/iris/DataSources/TFLDataSource.swift @@ -0,0 +1,188 @@ +// +// 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? + } +} diff --git a/IrisCompanion/iris/Models/HeuristicRanker.swift b/IrisCompanion/iris/Models/HeuristicRanker.swift index bd09752..cbb7304 100644 --- a/IrisCompanion/iris/Models/HeuristicRanker.swift +++ b/IrisCompanion/iris/Models/HeuristicRanker.swift @@ -86,6 +86,7 @@ final class HeuristicRanker { switch type { case .weatherWarning: return 1.0 case .weatherAlert: return 0.9 + case .transitAlert: return 0.85 case .calendarEvent: return 0.8 case .transit: return 0.75 case .poiNearby: return 0.6 diff --git a/IrisCompanion/iris/Models/Winner.swift b/IrisCompanion/iris/Models/Winner.swift index 618780e..0987168 100644 --- a/IrisCompanion/iris/Models/Winner.swift +++ b/IrisCompanion/iris/Models/Winner.swift @@ -17,5 +17,6 @@ enum FeedItemType: String, Codable, CaseIterable { case currentWeather = "CURRENT_WEATHER" case calendarEvent = "CALENDAR_EVENT" case stock = "STOCK" + case transitAlert = "TRANSIT_ALERT" case allQuiet = "ALL_QUIET" } diff --git a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift index 73f6c04..efc8603 100644 --- a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift +++ b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift @@ -24,6 +24,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { @Published private(set) var lastCalendarDiagnostics: [String: String] = [:] @Published private(set) var lastPipelineElapsedMs: Int? = nil @Published private(set) var lastFetchFailed: Bool = false + @Published private(set) var lastTFLDiagnostics: [String: String] = [:] @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined @Published private(set) var nowPlaying: NowPlayingSnapshot? = nil @Published private(set) var spotifyNowPlaying: SpotifyNowPlaying? = nil @@ -43,6 +44,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { private let calendarDataSource = CalendarDataSource() private let poiDataSource = POIDataSource() private let stockDataSource = StockDataSource() + private let tflDataSource = TFLDataSource() private let ranker: HeuristicRanker private let store: FeedStore private let server: LocalServer @@ -258,11 +260,15 @@ final class ContextOrchestrator: NSObject, ObservableObject { async let stockResult = withTimeoutResult(seconds: 6) { try await self.stockDataSource.dataWithDiagnostics(symbols: stockSymbols, now: nowEpoch) } + async let tflResult = withTimeoutResult(seconds: 6) { + try await self.tflDataSource.dataWithDiagnostics(now: nowEpoch) + } let wxRes = await weatherResult let calRes = await calendarResult let poiRes = await poiResult let stockRes = await stockResult + let tflRes = await tflResult func calendarTTL(endAt: Int, now: Int) -> Int { let ttl = endAt - now @@ -279,6 +285,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { var calendarItems: [FeedItem] = [] var poiItems: [FeedItem] = [] var stockItems: [FeedItem] = [] + var tflItems: [FeedItem] = [] var weatherNowItem: FeedItem? = nil var fetchFailed = false var wxDiagnostics: [String: String] = [:] @@ -454,13 +461,49 @@ final class ContextOrchestrator: NSObject, ObservableObject { logger.warning("stock fetch failed: \(String(describing: error), privacy: .public)") } + var tflDiagnostics: [String: String] = [:] + switch tflRes { + case .success(let snapshot): + tflDiagnostics = snapshot.diagnostics + for disruption in snapshot.data.disruptions { + let confidence: Double = disruption.isMajor ? 0.9 : 0.6 + let item = FeedItem( + id: "tfl:\(disruption.lineId):\(nowEpoch / 300)", + type: .transitAlert, + title: tflDataSource.disruptionTitle(disruption).truncated(maxLength: TextConstraints.titleMax), + subtitle: tflDataSource.disruptionSubtitle(disruption).truncated(maxLength: TextConstraints.subtitleMax), + priority: confidence, + ttlSec: tflDataSource.ttlSec, + condition: nil, + startsAt: nil, + poiType: nil, + bucket: disruption.isMajor ? .rightNow : .fyi, + actions: ["DISMISS"] + ) + tflItems.append(item) + if disruption.isMajor { + rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) + } + } + if !snapshot.data.disruptions.isEmpty { + logger.info("tfl disruptions fetched count=\(snapshot.data.disruptions.count)") + } + case .failure(let error): + if case TimeoutError.timedOut = error { + logger.warning("tfl fetch timeout") + } else { + logger.warning("tfl fetch failed: \(String(describing: error), privacy: .public)") + } + } + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) lastPipelineElapsedMs = elapsedMs lastFetchFailed = fetchFailed lastWeatherDiagnostics = wxDiagnostics lastCalendarDiagnostics = calDiagnostics + lastTFLDiagnostics = tflDiagnostics - logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") + logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) stock_items=\(stockItems.count) tfl_items=\(tflItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil { let fallbackFeed = store.getFeed(now: nowEpoch) @@ -556,6 +599,28 @@ final class ContextOrchestrator: NSObject, ObservableObject { ) }) + let fyiTFL = tflItems + .filter { $0.bucket == .fyi } + .filter { $0.id != winnerItem.id } + .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } + .prefix(2) + + fyi.append(contentsOf: fyiTFL.map { item in + FeedItem( + id: item.id, + type: item.type, + title: item.title.truncated(maxLength: TextConstraints.titleMax), + subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: min(max(item.priority, 0.0), 1.0), + ttlSec: max(1, item.ttlSec), + condition: item.condition, + startsAt: item.startsAt, + poiType: item.poiType, + bucket: .fyi, + actions: ["DISMISS"] + ) + }) + let items = [winnerItem] + fyi let feedEnvelope = FeedEnvelope( schema: 1, -- 2.49.1 From 2860ab078682fb649f09a823a19d2550391f1c69 Mon Sep 17 00:00:00 2001 From: christophergyman Date: Sat, 10 Jan 2026 21:52:55 +0000 Subject: [PATCH 2/3] Filter out planned and part closures from TFL alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only show active disruptions (delays, suspensions), not planned closures which aren't relevant for real-time commute decisions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- IrisCompanion/iris/DataSources/TFLDataSource.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/IrisCompanion/iris/DataSources/TFLDataSource.swift b/IrisCompanion/iris/DataSources/TFLDataSource.swift index d4012e1..b2b05b7 100644 --- a/IrisCompanion/iris/DataSources/TFLDataSource.swift +++ b/IrisCompanion/iris/DataSources/TFLDataSource.swift @@ -99,10 +99,12 @@ final class TFLDataSource { guard !seenLines.contains(line.id) else { continue } for status in line.lineStatuses { + // Skip: 10 = Good Service, 4 = Planned Closure, 5 = Part Closure guard status.statusSeverity < 10 else { continue } + guard status.statusSeverity != 4 && status.statusSeverity != 5 else { continue } seenLines.insert(line.id) - let isMajor = status.statusSeverity <= 6 + let isMajor = status.statusSeverity <= 3 || status.statusSeverity == 6 let disruption = Disruption( id: "\(line.id):\(status.statusSeverity)", -- 2.49.1 From d8929d37767c7cc80f3843a74f310f6b69fe104d Mon Sep 17 00:00:00 2001 From: christophergyman Date: Sat, 10 Jan 2026 21:54:21 +0000 Subject: [PATCH 3/3] Refactor TFL severity filtering to use Sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- IrisCompanion/iris/DataSources/TFLDataSource.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/IrisCompanion/iris/DataSources/TFLDataSource.swift b/IrisCompanion/iris/DataSources/TFLDataSource.swift index b2b05b7..f128005 100644 --- a/IrisCompanion/iris/DataSources/TFLDataSource.swift +++ b/IrisCompanion/iris/DataSources/TFLDataSource.swift @@ -48,6 +48,11 @@ final class TFLDataSource { } } + // Severity 4 = Planned Closure, 5 = Part Closure, 10 = Good Service + private static let ignoredSeverities: Set = [4, 5, 10] + // Severity 1 = Closed, 2 = Suspended, 3 = Part Suspended, 6 = Severe Delays + private static let majorSeverities: Set = [1, 2, 3, 6] + private let config: TFLDataSourceConfig private var cache: (timestamp: Int, data: TFLData)? @@ -99,12 +104,10 @@ final class TFLDataSource { guard !seenLines.contains(line.id) else { continue } for status in line.lineStatuses { - // Skip: 10 = Good Service, 4 = Planned Closure, 5 = Part Closure - guard status.statusSeverity < 10 else { continue } - guard status.statusSeverity != 4 && status.statusSeverity != 5 else { continue } + guard !Self.ignoredSeverities.contains(status.statusSeverity) else { continue } seenLines.insert(line.id) - let isMajor = status.statusSeverity <= 3 || status.statusSeverity == 6 + let isMajor = Self.majorSeverities.contains(status.statusSeverity) let disruption = Disruption( id: "\(line.id):\(status.statusSeverity)", -- 2.49.1