Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.). The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving. Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
This commit is contained in:
@@ -16,9 +16,16 @@ import WidgetKit
|
||||
|
||||
enum RoadDistanceService {
|
||||
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||
/// still get real road distances instead of a straight-line fallback.
|
||||
static let candidatesPerPass = 40
|
||||
/// route calls. Worth-the-Drive now asks for outward AND return routes, so
|
||||
/// this must stay conservative; a larger value can make first launch feel
|
||||
/// unresponsive while MapKit drains dozens of route requests.
|
||||
static let candidatesPerPass = 12
|
||||
/// 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.
|
||||
@@ -31,11 +38,34 @@ enum RoadDistanceService {
|
||||
|
||||
/// Refreshes the cached road distances for the in-radius stations around
|
||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||
///
|
||||
/// - 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 {
|
||||
guard await refreshGate.begin() else { return }
|
||||
defer { Task { await refreshGate.end() } }
|
||||
|
||||
guard !stations.isEmpty else { 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.
|
||||
if let cache = FuelStore.loadRoadDistances() {
|
||||
// `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)
|
||||
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||
@@ -43,33 +73,55 @@ enum RoadDistanceService {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||
// could actually display, capped so a pass stays bounded.
|
||||
let nearest = stations
|
||||
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||
.prefix(candidatesPerPass)
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||
// 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<String>()
|
||||
func consider(_ station: FuelStation) {
|
||||
guard candidates.count < candidatesPerPass 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 >= candidatesPerPass { break }
|
||||
consider(station)
|
||||
}
|
||||
|
||||
guard !candidates.isEmpty else { return }
|
||||
|
||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
var entries: [String: CachedRoadDistance] = [:]
|
||||
for station in nearest {
|
||||
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
||||
for station in candidates {
|
||||
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||
if let meters = await roadMeters(from: origin, to: dest) {
|
||||
async let outward = roadMeters(from: origin, to: dest)
|
||||
async let back = roadMeters(from: dest, to: origin)
|
||||
if let meters = await outward {
|
||||
// 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)
|
||||
lng: station.lng,
|
||||
returnMeters: returnMeters)
|
||||
}
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
|
||||
return
|
||||
}
|
||||
|
||||
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||
// Wake the widgets so the new road distances surface immediately.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
private static let refreshGate = RoadDistanceRefreshGate()
|
||||
|
||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||
let request = MKDirections.Request()
|
||||
@@ -96,3 +148,17 @@ enum RoadDistanceService {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user