← Back to index

SubwayArrivalsService

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import FoundationFramework importsImports Foundation.
▶ MODEL
struct ArrivalInfo: Identifiable { let id = UUID() let route: String let headsign: String let seconds: Double // next train let secondSeconds: Double? // 2nd train let thirdSeconds: Double? // 3rd train let directionId: Bool // false = first direction, true = second var minuteShort: String { Self.format(seconds) } var secondMinuteShort: String? { secondSeconds.map { Self.format($0) } } var thirdMinuteShort: String? { thirdSeconds.map { Self.format($0) } } private static func format(_ secs: Double) -> String { secs < 30 ? "Now" : "\(Int(secs / 60))" } }`ArrivalInfo` structDefines the `ArrivalInfo` struct. Conforms to Identifiable.
▶ SERVICE
struct SubwayArrivalsService { private static let baseURL = "https://realtimerail.nyc/transiter/v0.6/systems/us-ny-subway/stops" // MARK: Public — returns sorted arrivals for display in the bubble /// Fetches arrivals for every stop ID concurrently and merges the results. static func fetch(for station: SubwayStation) async -> [ArrivalInfo] { let now = Date().timeIntervalSince1970 // Fresh session per fetch — avoids connection-reuse hangs when switching // between nearby stations. Disable HTTP/3 (QUIC) to prevent the QUIC // packet errors seen in the console that cause long stalls before TCP // fallback; timeouts are enforced both at the session and request level. let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 8 cfg.timeoutIntervalForResource = 10 let session = URLSession(configuration: cfg) defer { session.invalidateAndCancel() } // Shuttle-platform special cases: the GS shuttle stops sit outside the // 100 m proximity-merge radius for both Grand Central and Times Square, so // they form separate dots. Whichever dot the user taps, inject all stops // for that complex so every line's arrivals appear together. // Grand Central: 631 (4/5/6), 723 (7), 901 (GS shuttle – GC end) // Times Square: 127 (1/2/3), 725 (7), R16 (N/Q/R/W), 902 (GS shuttle – TS end) var stopIDs = Array(station.stopIDs.prefix(6)) let gcStops: Set<String> = ["631", "723", "901"] let tsStops: Set<String> = ["127", "725", "R16", "902"] if stopIDs.contains(where: { gcStops.contains($0) }) { for sid in gcStops where !stopIDs.contains(sid) { stopIDs.append(sid) } } if stopIDs.contains(where: { tsStops.contains($0) }) { for sid in tsStops where !stopIDs.contains(sid) { stopIDs.append(sid) } } // Fetch all stops concurrently — each request races independently so a // single timeout doesn't stall the others. let rawResults: [[(route: String, headsign: String, secs: Double, directionId: Bool)]] = await withTaskGroup(of: [(route: String, headsign: String, secs: Double, directionId: Bool)].self) { group in for stopID in stopIDs { guard let url = URL(string: "\(baseURL)/\(stopID)") else { continue } let req = URLRequest(url: url, timeoutInterval: 8) group.addTask { guard let (data, _) = try? await session.data(for: req), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let times = json["stopTimes"] as? [[String: Any]] else { return [] } var entries: [(route: String, headsign: String, secs: Double, directionId: Bool)] = [] for entry in times { guard let trip = entry["trip"] as? [String: Any], let routeObj = trip["route"] as? [String: Any], let route = routeObj["id"] as? String, let arrObj = entry["arrival"] as? [String: Any], let timeStr = arrObj["time"] as? String, let t = Double(timeStr) else { continue } let secs = t - now guard secs >= -30 else { continue } let headsign = entry["headsign"] as? String ?? "" let directionId = trip["directionId"] as? Bool ?? false entries.append((route: route, headsign: headsign, secs: secs, directionId: directionId)) } return entries } } var collected: [[(route: String, headsign: String, secs: Double, directionId: Bool)]] = [] for await result in group { collected.append(result) } return collected } let all: [ArrivalInfo] = rawResults.flatMap { $0 }.map { ArrivalInfo(route: $0.route, headsign: $0.headsign, seconds: $0.secs, secondSeconds: nil, thirdSeconds: nil, directionId: $0.directionId) } // Deduplicate: keep the next two trains per route+direction pair. // Sort by time so first hit = next train, second hit = train after. var first = [String: Double]() var second = [String: Double]() var third = [String: Double]() var meta = [String: (route: String, headsign: String, directionId: Bool)]() for arrival in all.sorted(by: { $0.seconds < $1.seconds }) { let key = "\(arrival.route)-\(arrival.directionId)" if first[key] == nil { first[key] = arrival.seconds meta[key] = (arrival.route, arrival.headsign, arrival.directionId) } else if second[key] == nil { second[key] = arrival.seconds } else if third[key] == nil { third[key] = arrival.seconds } } let deduped: [ArrivalInfo] = meta.map { key, m in ArrivalInfo(route: m.route, headsign: m.headsign, seconds: first[key]!, secondSeconds: second[key], thirdSeconds: third[key], directionId: m.directionId) } // Group by direction (false first), sort each group by first-train time. let dir0 = deduped.filter { !$0.directionId }.sorted { $0.seconds < $1.seconds } let dir1 = deduped.filter { $0.directionId }.sorted { $0.seconds < $1.seconds } return dir0 + dir1 } // MARK: Public — also prints to console static func fetchAndPrint(for station: SubwayStation) async { let arrivals = await fetch(for: station) print("🚇 \(station.name) [\(station.lines.joined(separator: " "))]") if arrivals.isEmpty { print("🚇 no upcoming trains") } else { for a in arrivals { let next = a.secondMinuteShort.map { " then \($0)" } ?? "" print("🚇 \(a.route) \(a.headsign) · \(a.minuteShort)\(next)") } } } }`SubwayArrivalsService` structDefines the `SubwayArrivalsService` struct.