// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for // nearby stations and caches them (keychain) so widgets + Live Activity can // show real driving distance instead of straight-line haversine. // // Runs only in the APP: MKDirections is network-bound and the widget extension // has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs // here, not in the widget. The widget/Live Activity just read the cache. // // Throttling: recompute at most every `throttleMinutes`, or when the user has // moved `moveThresholdMeters` from where the cache was built. In // "Accurate distances" mode a pass routes the WHOLE search area (up to // `fillCapacity` stations) so the browse layer can serve routed distances for // every row at once; the mode must also be ON for any pass to run at all. import Foundation import CoreLocation import MapKit import UIKit import WidgetKit struct RoadDistanceFillDebugStatus: Codable, Equatable { var state: State = .idle var reason: String = "Waiting" var updatedAt: Date = Date() var originLat: Double? var originLng: Double? var stationPoolCount: Int = 0 var priorityCount: Int = 0 var candidateCount: Int = 0 var routedCount: Int = 0 var cachedCount: Int = 0 var failedCount: Int = 0 var complete: Bool = false var capped: Bool = false var elapsedSeconds: Double? var movedMeters: Double? var throttleSecondsRemaining: Double? enum State: String, Codable { case idle case gated case running case completed } var summary: String { switch state { case .idle: return "Idle — \(reason)" case .gated: return "Gated — \(reason)" case .running: return "Running — \(reason)" case .completed: return complete ? "Complete — routed set ready" : "Incomplete — still Approx." } } static let idle = RoadDistanceFillDebugStatus() } enum RoadDistanceService { /// Upper bound on stations routed per pass. Accurate-distances mode routes the /// whole search area in ONE pass, because a 12-per-pass budget could never /// make a list fully routed — that is exactly why two devices (and two /// radii) disagreed about the same station. Capped so a pathological dataset /// can't turn one pass into thousands of route requests; a capped pass stays /// incomplete, and the browse layer keeps showing straight-line + "Approx." /// until a later pass finishes the job. Measured on device: ~3.2 route calls /// per second, so 600 stations (1,200 calls) is ~6 min at the very worst. static let fillCapacity = 600 /// How many Worth-the-Drive candidates may claim slots in a pass. Those /// rows are ranked by NET SAVING and are typically FURTHER out than the /// nearest stations — routing only the nearest stations (the old behaviour) /// meant they never got a measured route and stayed on straight-line /// estimates forever. static let priorityCandidates = 6 /// Only route stations within this straight-line radius (km). Covers the /// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so /// every station a widget/Live Activity/list can actually show gets routed. static let maxRadiusKM: Double = 25 /// Don't route again more often than this (minutes) once the set is whole. static let throttleMinutes: Double = 10 /// Retry window for a set that is still INCOMPLETE — a pass cut short by /// backgrounding the app must be continued promptly, not in ten minutes. static let incompleteRetrySeconds: Double = 30 /// Recompute when the user moves more than this (metres) from the last /// source location. static let moveThresholdMeters: Double = 400 static private(set) var debugStatus = RoadDistanceFillDebugStatus.idle @MainActor static func currentDebugStatus() -> RoadDistanceFillDebugStatus { debugStatus } @MainActor private static func publishDebug(_ status: RoadDistanceFillDebugStatus) { debugStatus = status NotificationCenter.default.post(name: .fuelBoardRoadDistanceDebugUpdated, object: nil) } private static func updateDebug(_ status: RoadDistanceFillDebugStatus) async { await MainActor.run { publishDebug(status) } } private static func baseDebugStatus(state: RoadDistanceFillDebugStatus.State, reason: String, stations: [FuelStation], lat: Double, lng: Double, priorityCount: Int) -> RoadDistanceFillDebugStatus { RoadDistanceFillDebugStatus( state: state, reason: reason, updatedAt: Date(), originLat: lat, originLng: lng, stationPoolCount: stations.count, priorityCount: priorityCount ) } /// Refreshes the cached road distances for the in-radius stations around /// `lat`/`lng`. Throttled by time + distance; safe to call on every fix. /// /// - Parameter priorityStations: stations to route BEFORE the nearest ones /// (the Worth-the-Drive candidates and the comparison baseline), so the /// numbers the user actually reads are road-routed rather than estimated. static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double, prioritising priorityStations: [FuelStation] = []) async { // "Accurate distances" OFF (the default) means the browse layer never // serves a routed value, so any routing would be pure cost with nothing // visible to show for it: no pass, no requests, no widget reloads. guard FuelStore.accurateDistancesEnabled else { await updateDebug(baseDebugStatus(state: .gated, reason: "Driving distances off", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count)) return } // Only fill while the app is on screen. The browse layer is the only // consumer, so a background fill would spend radio and battery routing a // position nothing is displaying — worst case, a long drive. guard await appIsActive() else { await updateDebug(baseDebugStatus(state: .gated, reason: "App not active", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count)) return } guard await refreshGate.begin() else { await updateDebug(baseDebugStatus(state: .gated, reason: "Fill already running", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count)) return } defer { Task { await refreshGate.end() } } guard !stations.isEmpty else { await updateDebug(baseDebugStatus(state: .gated, reason: "No stations loaded", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count)) return } let existing = FuelStore.loadRoadDistances() // Entries can only be merged when they were routed from (near enough) // this same origin — the read path rejects anything else anyway. let originCompatible = existing.map { haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters } ?? false // Throttle: keep cached values when fresh and the user hasn't moved far. // `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook). #if DEBUG let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1" #else let forcePass = false #endif if !forcePass, let cache = existing { let elapsed = Date().timeIntervalSince1970 - cache.updatedAt let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng) // An INCOMPLETE set still has work to do, so it is not throttled for // the full ten minutes. The browse layer refuses to serve a partial // set (every row reads straight-line + "Approx." until the set is // whole), and a pass is routinely cut short by backgrounding the app // — so a ten-minute throttle on an incomplete set is what made // "Approx." look permanent: reopen the app inside the window and the // pass that would finish the job never even started, and refreshing // the prices could not help. Retry soon instead. let incomplete = cache.complete != true let throttleWindow = incomplete ? incompleteRetrySeconds : throttleMinutes * 60 if elapsed < throttleWindow && movedMeters < moveThresholdMeters { var status = baseDebugStatus(state: .gated, reason: incomplete ? "Throttled — incomplete set, retrying" : "Throttled", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count) status.cachedCount = cache.entries.count status.complete = !incomplete status.movedMeters = movedMeters status.throttleSecondsRemaining = throttleWindow - elapsed if incomplete, await repairCompletenessIfNothingLeftToRoute(cache, stations: stations, lat: lat, lng: lng) { status.complete = true status.reason = "Throttled — stale incomplete flag repaired" } await updateDebug(status) if status.complete { await notifyDistanceSurfaces() } return } } // Candidates: Worth-the-Drive priorities first, then the nearest // stations. Stations that already have a route from this origin are // skipped so the per-pass budget goes on the ones still missing one. var candidates: [FuelStation] = [] var seen = Set() func consider(_ station: FuelStation) { guard candidates.count < fillCapacity else { return } guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return } guard seen.insert(station.id).inserted else { return } guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return } candidates.append(station) } for station in priorityStations.prefix(priorityCandidates) { consider(station) } let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } for station in nearest { if candidates.count >= fillCapacity { break } consider(station) } guard !candidates.isEmpty else { var status = baseDebugStatus(state: .gated, reason: "No unrouted candidates", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count) status.cachedCount = existing?.entries.count ?? 0 var complete = existing?.complete == true && FuelStore.accurateDistancesReady(userLat: lat, userLng: lng) if !complete, let existing, await repairCompletenessIfNothingLeftToRoute(existing, stations: stations, lat: lat, lng: lng) { complete = true status.reason = "No unrouted candidates — stale incomplete flag repaired" } status.complete = complete await updateDebug(status) if status.complete { await notifyDistanceSurfaces() } return } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:] var running = baseDebugStatus(state: .running, reason: "Routing \(candidates.count) stations", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count) running.candidateCount = candidates.count running.cachedCount = entries.count running.capped = candidates.count >= fillCapacity await updateDebug(running) let passStart = Date() var routedCount = 0 for station in candidates { let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) async let outward = roadMeters(from: origin, to: dest) async let back = roadMeters(from: dest, to: origin) if let meters = await outward { routedCount += 1 // Store the exact pin that was routed so the display layer can // refuse to serve this value if the station later appears with // a different coordinate (corrected pin / other data source). let returnMeters = await back entries[station.id] = CachedRoadDistance(meters: meters, lat: station.lat, lng: station.lng, returnMeters: returnMeters) } } guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else { var status = baseDebugStatus(state: .gated, reason: "Route pass added no distances", stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count) status.candidateCount = candidates.count status.routedCount = routedCount status.failedCount = candidates.count - routedCount status.cachedCount = entries.count status.elapsedSeconds = Date().timeIntervalSince(passStart) await updateDebug(status) return } // A pass is complete when nothing in the search area is left UNRESOLVED: // every station either has a route from this anchor, or was attempted in // THIS pass and Apple Maps returned none. A few pins genuinely have no // route (offshore, deleted forecourt, bad snapshot) and one of those must // never leave the area permanently incomplete — that would pin the whole // list on straight-line estimates forever. Those stations show their // straight line, marked Approx., and the radius filter uses that same // distance, so nothing is ever out of step with what a row displays. let attempted = Set(candidates.map(\.id)) let capped = candidates.count >= fillCapacity let unresolved = capped || stations.contains { station in station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM && !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) && !attempted.contains(station.id) } FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries, complete: !unresolved) var completed = baseDebugStatus(state: .completed, reason: !unresolved ? "Fill complete" : (capped ? "Capacity cap hit" : "Unresolved stations remain"), stations: stations, lat: lat, lng: lng, priorityCount: priorityStations.count) completed.candidateCount = candidates.count completed.routedCount = routedCount completed.failedCount = candidates.count - routedCount completed.cachedCount = entries.count completed.complete = !unresolved completed.capped = capped completed.elapsedSeconds = Date().timeIntervalSince(passStart) await updateDebug(completed) #if DEBUG // `FB-ROUTE` prints what a pass actually cost (stations, route calls, // wall-clock, unroutable pins, whether the set is now complete) — the // evidence for any "how taxing is routing?" answer, and how a bigger pass // gets scaled from measurement instead of guesswork. print("FB-ROUTE pass stations=\(candidates.count) calls=\(candidates.count * 2) " + "elapsed=\(String(format: "%.2f", Date().timeIntervalSince(passStart)))s " + "cached=\(entries.count) failed=\(candidates.count - routedCount) " + "complete=\(!unresolved)") #endif await notifyDistanceSurfaces() } /// A cache can hold a route for EVERY station in the search area and still be /// flagged incomplete — a previous pass hit the capacity cap, or was /// interrupted between routing and writing. Nothing would ever clear that /// flag on its own (the next pass finds nothing left to route, and the /// throttle path returns without writing), and every row would stay on /// straight-line + "Approx." while its routed value sat in the cache. When /// nothing in the area is left unrouted, the set IS complete: correct the /// flag so the routed values can be served. private static func repairCompletenessIfNothingLeftToRoute(_ cache: RoadDistanceCache, stations: [FuelStation], lat: Double, lng: Double) async -> Bool { guard cache.complete != true, !stations.isEmpty else { return false } let nothingLeftToRoute = !stations.contains { station in station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM && !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) } guard nothingLeftToRoute else { return false } FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng, entries: cache.entries, complete: true) #if DEBUG print("FB-COMPLETE stale incomplete flag repaired cached=\(cache.entries.count) " + "(every in-radius station already routed — no re-routing needed)") #endif return true } @MainActor private static func notifyDistanceSurfacesOnMain() { WidgetCenter.shared.reloadAllTimelines() NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil) } private static func notifyDistanceSurfaces() async { await MainActor.run { notifyDistanceSurfacesOnMain() } } private static let refreshGate = RoadDistanceRefreshGate() /// Accurate mode fills only while the app is on screen: the browse layer is /// the only consumer of routed distances, so a background fill would spend /// radio and battery routing a position nothing is looking at. private static func appIsActive() async -> Bool { await MainActor.run { UIApplication.shared.applicationState == .active } } /// Driving distance (metres) between two coordinates via Apple Maps routing. private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? { let request = MKDirections.Request() request.source = MKMapItem( location: CLLocation(latitude: from.latitude, longitude: from.longitude), address: nil ) request.destination = MKMapItem( location: CLLocation(latitude: to.latitude, longitude: to.longitude), address: nil ) request.transportType = .automobile request.requestsAlternateRoutes = false do { let response = try await MKDirections(request: request).calculate() return response.routes.first?.distance } catch { return nil } } /// Straight-line haversine distance between two coordinates, in metres. private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double { let r = 6371000.0 let dLat = (lat2 - lat1) * .pi / 180 let dLng = (lng2 - lng1) * .pi / 180 let a = sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) * sin(dLng / 2) * sin(dLng / 2) return r * 2 * atan2(sqrt(a), sqrt(1 - a)) } } private actor RoadDistanceRefreshGate { private var isRefreshing = false func begin() -> Bool { guard !isRefreshing else { return false } isRefreshing = true return true } func end() { isRefreshing = false } }