| 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` struct | Defines the `SubwayAlert` struct. Conforms to Identifiable. |
| @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 block | See source code for full implementation. |