← Back to index

SubwayAlertsService

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Foundation import CombineFramework importsImports Foundation, Combine.
▶ MODEL
struct SubwayAlert: Identifiable { let id: String let alertType: String // e.g. "Delays", "Planned - Stops Skipped", "No Scheduled Service" let header: String // short English summary let description: String // longer English body (may be empty) let affectedRoutes: Set<String> // from informed_entity[].route_id let affectedStopIDs: Set<String> // from informed_entity[].stop_id /// Each tuple is (start, optionalEnd) in Unix seconds from active_period array let activePeriods: [(start: TimeInterval, end: TimeInterval?)] }`SubwayAlert` structDefines the `SubwayAlert` struct. Conforms to Identifiable.
▶ SERVICE
@MainActor final class SubwayAlertsService: ObservableObject { static let shared = SubwayAlertsService() private init() {} @Published private(set) var allAlerts: [SubwayAlert] = [] private var allParsedAlerts: [SubwayAlert] = [] // full set, not filtered by active_period private var lastFetched: Date? private let cacheTTL: TimeInterval = 300 // 5 minutes private static let endpoint = URL(string: "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/camsys%2Fsubway-alerts.json")! // MARK: - Fetch func fetchIfNeeded() async { if let last = lastFetched, Date().timeIntervalSince(last) < cacheTTL { return } await fetch() } func fetch() async { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 8 cfg.timeoutIntervalForResource = 12 let session = URLSession(configuration: cfg) defer { session.invalidateAndCancel() } guard let (data, _) = try? await session.data(from: Self.endpoint), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let entities = json["entity"] as? [[String: Any]] else { return } let now = Date() let parsed = entities .compactMap { parseEntity($0) } .filter { !$0.affectedRoutes.isEmpty || !$0.affectedStopIDs.isEmpty } allParsedAlerts = parsed allAlerts = parsed.filter { isActive($0, at: now) } lastFetched = Date() } // MARK: - Query /// Returns currently-active alerts that affect one of `station`'s lines OR one of its stop IDs. func alerts(for station: SubwayStation) -> [SubwayAlert] { let lines = Set(station.lines) let stopIDs = Set(station.stopIDs) return allAlerts.filter { !$0.affectedRoutes.isDisjoint(with: lines) || !$0.affectedStopIDs.isDisjoint(with: stopIDs) } } /// Returns alerts active at any point during the next calendar day (midnight–midnight local time) /// that affect one of `station`'s lines OR one of its stop IDs, /// excluding any alerts already active right now (those appear in Today's section). func tomorrowAlerts(for station: SubwayStation) -> [SubwayAlert] { let cal = Calendar.current let todayStart = cal.startOfDay(for: Date()) guard let tomorrowStart = cal.date(byAdding: .day, value: 1, to: todayStart), let tomorrowEnd = cal.date(byAdding: .day, value: 1, to: tomorrowStart) else { return [] } let s = tomorrowStart.timeIntervalSince1970 let e = tomorrowEnd.timeIntervalSince1970 let lines = Set(station.lines) let stopIDs = Set(station.stopIDs) // Build set of alert IDs already shown in Today section to avoid duplication let todayIDs = Set(alerts(for: station).map(\.id)) return allParsedAlerts.filter { alert in (!alert.affectedRoutes.isDisjoint(with: lines) || !alert.affectedStopIDs.isDisjoint(with: stopIDs)) && isActiveOnDay(alert, dayStart: s, dayEnd: e) && !todayIDs.contains(alert.id) } } /// True if `alert` has any active_period that overlaps the half-open interval [dayStart, dayEnd). private func isActiveOnDay(_ alert: SubwayAlert, dayStart: TimeInterval, dayEnd: TimeInterval) -> Bool { guard !alert.activePeriods.isEmpty else { return true } return alert.activePeriods.contains { period in period.start < dayEnd && (period.end == nil || period.end! > dayStart) } } // MARK: - Parsing private func parseEntity(_ entity: [String: Any]) -> SubwayAlert? { guard let id = entity["id"] as? String, let alert = entity["alert"] as? [String: Any] else { return nil } let header = translatedText(alert["header_text"]) ?? "" let desc = translatedText(alert["description_text"]) ?? "" guard !header.isEmpty else { return nil } // "transit_realtime.mercury_alert".alert_type → "Delays", "Planned - Stops Skipped", etc. let mercury = alert["transit_realtime.mercury_alert"] as? [String: Any] let alertType = mercury?["alert_type"] as? String ?? "" let ie = alert["informed_entity"] as? [[String: Any]] ?? [] let routes = Set(ie.compactMap { $0["route_id"] as? String }.filter { !$0.isEmpty }) let stopIDs = Set(ie.compactMap { $0["stop_id"] as? String }.filter { !$0.isEmpty }) let activePeriods: [(start: TimeInterval, end: TimeInterval?)] = { guard let periods = alert["active_period"] as? [[String: Any]] else { return [] } return periods.compactMap { p in // Values arrive as either Number or String depending on JSON encoder func ti(_ key: String) -> TimeInterval? { if let n = p[key] as? TimeInterval { return n } if let s = p[key] as? String { return TimeInterval(s) } return nil } guard let start = ti("start") else { return nil } return (start: start, end: ti("end")) } }() return SubwayAlert(id: id, alertType: alertType, header: header, description: desc, affectedRoutes: routes, affectedStopIDs: stopIDs, activePeriods: activePeriods) } /// True if `alert` has at least one active_period that contains `date`. /// An alert with no active_periods is treated as always active (ongoing). private func isActive(_ alert: SubwayAlert, at date: Date) -> Bool { guard !alert.activePeriods.isEmpty else { return true } let t = date.timeIntervalSince1970 return alert.activePeriods.contains { period in t >= period.start && (period.end == nil || t <= period.end!) } } /// Extracts the English translation from a GTFS-RT TranslatedString object. /// Format: { "translation": [ { "text": "…", "language": "en" } ] } private func translatedText(_ value: Any?) -> String? { guard let dict = value as? [String: Any], let translations = dict["translation"] as? [[String: Any]] else { return nil } let hit = translations.first { ($0["language"] as? String)?.hasPrefix("en") == true } ?? translations.first return hit?["text"] as? String } }Code blockSee source code for full implementation.