| 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` struct | Defines the `SubwayArrivalsService` struct. |