| @MainActor
final class WatchArrivalsService: ObservableObject {
static let shared = WatchArrivalsService()
private init() {}
@Published private(set) var stations: [WatchNearbyStation] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String? = nil
private let base = "https://realtimerail.nyc/transiter/v0.6/systems/us-ny-subway"
// Load and cache bundle stations on first use
private lazy var bundleStations: [BundleStation] = loadBundleStations()
// MARK: - Public
func refresh(near location: CLLocation) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
// 1. Find the 6 nearest stations by Euclidean distance
let userLat = location.coordinate.latitude
let userLon = location.coordinate.longitude
let nearest = bundleStations
.sorted { a, b in
let da = (a.lat - userLat) * (a.lat - userLat) + (a.lon - userLon) * (a.lon - userLon)
let db = (b.lat - userLat) * (b.lat - userLat) + (b.lon - userLon) * (b.lon - userLon)
return da < db
}
.prefix(6)
if nearest.isEmpty {
errorMessage = "Station data unavailable"
return
}
// 2. Fetch arrivals for each station concurrently
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 8
cfg.timeoutIntervalForResource = 10
let session = URLSession(configuration: cfg)
defer { session.invalidateAndCancel() }
let results: [WatchNearbyStation?] = await withTaskGroup(of: WatchNearbyStation?.self) { group in
for stop in nearest {
guard let url = URL(string: "\(base)/stops/\(stop.id)") else { continue }
let name = stop.name
let sid = stop.id
group.addTask {
let req = URLRequest(url: url, timeoutInterval: 8)
guard
let (data, _) = try? await session.data(for: req),
let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let times = j["stopTimes"] as? [[String: Any]]
else { return nil }
let now = Date().timeIntervalSince1970
struct Raw {
let route: String; let headsign: String
let secs: Double; let dirId: Bool
}
var raws: [Raw] = []
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 dirId = trip["directionId"] as? Bool ?? false
raws.append(Raw(route: route, headsign: headsign, secs: secs, dirId: dirId))
}
// Group by direction (false first), up to 4 arrivals each
var dir0: [WatchArrival] = []; var hs0 = ""
var dir1: [WatchArrival] = []; var hs1 = ""
var lines = Set<String>()
for r in raws.sorted(by: { $0.secs < $1.secs }) {
lines.insert(r.route)
let mins = max(0, Int(r.secs / 60))
if !r.dirId {
if dir0.count < 4 { dir0.append(WatchArrival(route: r.route, minutes: mins)) }
if hs0.isEmpty { hs0 = r.headsign }
} else {
if dir1.count < 4 { dir1.append(WatchArrival(route: r.route, minutes: mins)) }
if hs1.isEmpty { hs1 = r.headsign }
}
}
var directions: [WatchDirectionArrivals] = []
if !dir0.isEmpty { directions.append(.init(id: hs0, headsign: hs0, arrivals: dir0)) }
if !dir1.isEmpty { directions.append(.init(id: hs1, headsign: hs1, arrivals: dir1)) }
guard !directions.isEmpty else { return nil }
return WatchNearbyStation(id: sid, name: name,
lines: lines.sorted(),
directions: directions)
}
}
var out: [WatchNearbyStation?] = []
for await r in group { out.append(r) }
return out
}
// Sort by distance (nearest first) — preserve the original nearest order
let orderedIDs = nearest.map(\.id)
stations = results
.compactMap { $0 }
.sorted { a, b in
let ia = orderedIDs.firstIndex(of: a.id) ?? Int.max
let ib = orderedIDs.firstIndex(of: b.id) ?? Int.max
return ia < ib
}
if stations.isEmpty {
errorMessage = "No trains found nearby"
}
}
// MARK: - Bundle loader
private func loadBundleStations() -> [BundleStation] {
guard
let url = Bundle.main.url(forResource: "watch_stations", withExtension: "json"),
let data = try? Data(contentsOf: url),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
print("⚠️ WatchArrivalsService: could not load watch_stations.json")
return []
}
return arr.compactMap { d in
guard
let i = d["i"] as? String,
let n = d["n"] as? String,
let a = d["a"] as? Double,
let o = d["o"] as? Double
else { return nil }
return BundleStation(id: i, name: n, lat: a, lon: o)
}
}
} | Code block | See source code for full implementation. |