← Back to index

WatchArrivalsService

Spots Watch App
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Foundation import Combine import CoreLocationFramework importsImports Foundation, Combine, CoreLocation.
▶ MODELS
struct WatchNearbyStation: Identifiable { let id: String // GTFS parent stop ID (e.g. "D18") let name: String let lines: [String] // derived from arrivals let directions: [WatchDirectionArrivals] }`WatchNearbyStation` structDefines the `WatchNearbyStation` struct. Conforms to Identifiable.
struct WatchDirectionArrivals: Identifiable { let id: String let headsign: String // e.g. "Jamaica-179 St" let arrivals: [WatchArrival] }`WatchDirectionArrivals` structDefines the `WatchDirectionArrivals` struct. Conforms to Identifiable.
struct WatchArrival: Identifiable { let id = UUID() let route: String let minutes: Int var label: String { minutes <= 0 ? "Now" : "\(minutes)" } }`WatchArrival` structDefines the `WatchArrival` struct. Conforms to Identifiable.
▶ BUNDLE STATION RECORD
private struct BundleStation { let id: String let name: String let lat: Double let lon: Double }`BundleStation` structDefines the `BundleStation` struct.
▶ SERVICE
@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 blockSee source code for full implementation.