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:
FuelBoard Contributor
2026-10-11 14:34:53 +01:00
parent 1044d009ae
commit d7a0ecb79a
17 changed files with 1865 additions and 46 deletions
+135 -10
View File
@@ -45,6 +45,7 @@ struct FavouriteAlertSnapshot: Codable, Equatable {
enum SortMode: String, Codable, CaseIterable, Identifiable {
case cheapest
case closest
case worthTheDrive
var id: String { rawValue }
@@ -52,6 +53,7 @@ enum SortMode: String, Codable, CaseIterable, Identifiable {
switch self {
case .cheapest: return "Cheapest"
case .closest: return "Closest"
case .worthTheDrive: return "Worth the Drive"
}
}
}
@@ -419,15 +421,47 @@ struct FuelStore {
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time
static let fuelSavingsEnabledKey = "fuelboard.fuelSavings.enabled"
static let fuelSavingsMPGKey = "fuelboard.fuelSavings.mpg"
static let fuelSavingsAmountKey = "fuelboard.fuelSavings.amount"
static let fuelSavingsUnitKey = "fuelboard.fuelSavings.unit"
// Large shared payloads belong in the App Group container, not
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
static let stationsFileName = "fuelboard.stations.json"
/// Where a shared payload is written: the App Group container when the
/// provisioning profile grants it, otherwise the app's OWN container.
///
/// This fallback is essential in the field. Free/personal-team and SideStore
/// profiles do NOT include the `com.apple.security.application-groups`
/// entitlement, so `containerURL(forSecurityApplicationGroupIdentifier:)`
/// returns nil at runtime. Without this fallback NOTHING persisted on those
/// installs: `loadStations()` was permanently empty (so the freshness gate
/// could skip the fetch for ever and leave a blank list) and the ~2.8 MB
/// dump was re-downloaded on every refresh. The app's own container is not
/// shared with the widget/watch extension, but the app itself must keep a
/// working cache.
static func appGroupFileURL(_ name: String) -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite)?
.appendingPathComponent(name, isDirectory: false)
let fm = FileManager.default
if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite),
fm.fileExists(atPath: group.path) {
// Only use the App Group container when the system actually
// provisioned it at install time (i.e. the profile carries the
// `com.apple.security.application-groups` entitlement). Never try to
// create it: when the entitlement is missing the path either does
// not exist or is not writable, and probing it that way failed every
// write instead of falling back.
return group.appendingPathComponent(name, isDirectory: false)
}
guard let dir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
if !fm.fileExists(atPath: dir.path),
(try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) == nil {
return nil
}
return dir.appendingPathComponent(name, isDirectory: false)
}
static func loadAppGroupFile(_ name: String) -> Data? {
@@ -467,6 +501,10 @@ struct FuelStore {
}
static func loadStations() -> [FuelStation] {
#if DEBUG
let probe = appGroupFileURL(stationsFileName)
print("FB-STORAGE load \(stationsFileName) url=\(probe?.path ?? "nil") present=\(probe.map { FileManager.default.fileExists(atPath: $0.path) } ?? false)")
#endif
if let data = loadAppGroupFile(stationsFileName),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
@@ -500,7 +538,10 @@ struct FuelStore {
static func saveStations(_ stations: [FuelStation]) {
if let data = try? JSONEncoder().encode(stations) {
_ = saveAppGroupFile(data, name: stationsFileName)
let ok = saveAppGroupFile(data, name: stationsFileName)
#if DEBUG
print("FB-STORAGE save \(stationsFileName) bytes=\(data.count) ok=\(ok) url=\(appGroupFileURL(stationsFileName)?.path ?? "nil")")
#endif
}
}
@@ -632,6 +673,41 @@ struct FuelStore {
saveString(action.rawValue, service: stationTapActionKey)
}
// MARK: Fuel savings — Worth the Drive?
static func loadFuelSavingsSettings() -> FuelSavingsSettings {
let enabled = loadString(service: fuelSavingsEnabledKey) == "1"
let mpg = loadString(service: fuelSavingsMPGKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultMPG
let amount = loadString(service: fuelSavingsAmountKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultPreferredAmount.value
let unit = loadString(service: fuelSavingsUnitKey).flatMap(FuelSavingsFillUnit.init(rawValue:)) ?? FuelSavingsSettings.defaultPreferredAmount.unit
#if DEBUG
// The two stores are mirrors and can DISAGREE (the keychain survives
// app deletion, the app-group defaults do not). Print both raw copies
// plus which one won, so a setting that flips between launches is
// diagnosable instead of guesswork.
func raw(_ service: String) -> (keychain: String, appGroup: String) {
let keychain = keychainData(service: service).flatMap { String(data: $0, encoding: .utf8) } ?? "-"
let appGroup = UserDefaults(suiteName: appGroupSuite)?.string(forKey: service) ?? "-"
return (keychain, appGroup)
}
let enabledRaw = raw(fuelSavingsEnabledKey)
let mpgRaw = raw(fuelSavingsMPGKey)
print("FB-SAVINGS read enabled=\(enabled ? 1 : 0) [kc=\(enabledRaw.keychain) ag=\(enabledRaw.appGroup)] mpg=\(mpg) [kc=\(mpgRaw.keychain) ag=\(mpgRaw.appGroup)]")
#endif
return FuelSavingsSettings(
enabled: enabled,
mpg: mpg,
preferredAmount: FuelSavingsFillAmount(value: amount, unit: unit)
)
}
static func saveFuelSavingsSettings(_ settings: FuelSavingsSettings) {
saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
saveString(String(settings.mpg), service: fuelSavingsMPGKey)
saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
}
/// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29⁹").
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
@@ -1148,20 +1224,41 @@ struct FuelStore {
/// catches any real pin change.
static let roadDistancePinToleranceDegrees: Double = 1e-4
/// In-memory copy of the road-distance cache.
///
/// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC
/// round-trip plus a full base64+JSON decode — and `displayDistanceKM` is
/// called PER STATION inside comparisons (`min(by:)`, `sorted`). Re-reading
/// it on every call meant tens of thousands of synchronous XPC round-trips
/// on the main thread during scene creation, which stalled past the 20 s iOS
/// launch watchdog and got the app SIGKILLed (0x8BADF00D). Decode once per
/// process, reuse, and refresh on save.
private static var roadDistanceMemo: RoadDistanceCache?
/// Distinguishes "not loaded yet" from "loaded, and there is no cache".
private static var roadDistanceMemoLoaded = false
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries)
if let data = try? JSONEncoder().encode(cache) {
saveString(data.base64EncodedString(), service: roadDistancesKey)
}
roadDistanceMemo = cache
roadDistanceMemoLoaded = true
}
static func loadRoadDistances() -> RoadDistanceCache? {
guard let raw = loadString(service: roadDistancesKey),
let data = Data(base64Encoded: raw),
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
else { return nil }
return cache
if roadDistanceMemoLoaded { return roadDistanceMemo }
let decoded: RoadDistanceCache?
if let raw = loadString(service: roadDistancesKey),
let data = Data(base64Encoded: raw),
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) {
decoded = cache
} else {
decoded = nil
}
roadDistanceMemo = decoded
roadDistanceMemoLoaded = true
return decoded
}
/// Cached road distance (metres) to a station from the user's location, or
@@ -1169,6 +1266,26 @@ struct FuelStore {
/// is now / the station's own pin doesn't match the coordinate that was
/// routed.
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.meters
}
/// Complete out-and-back routed journey distance (metres). New caches store
/// the return route separately because Apple Maps may choose a different
/// return path; legacy one-way caches safely fall back to doubling outward.
static func roadJourneyMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
guard let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) else { return nil }
return entry.meters + (entry.returnMeters ?? entry.meters)
}
/// Whether a usable routed distance is already cached for this station from
/// (near enough) this origin. The routing pass uses it to spend its per-pass
/// budget on stations that still need a route instead of re-routing the
/// same nearest handful forever.
static func hasRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil
}
private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? {
guard let cache = loadRoadDistances(),
let entry = cache.entries[station.id] else { return nil }
// The cache is only valid near the location it was built from.
@@ -1185,7 +1302,7 @@ struct FuelStore {
// live / cached station sets from injecting each other's road metres.
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
return entry.meters
return entry
}
/// Distance (km) to display for a station: cached ROAD distance when
@@ -1266,6 +1383,14 @@ struct CachedRoadDistance: Codable {
let meters: Double
let lat: Double
let lng: Double
let returnMeters: Double?
init(meters: Double, lat: Double, lng: Double, returnMeters: Double? = nil) {
self.meters = meters
self.lat = lat
self.lng = lng
self.returnMeters = returnMeters
}
}
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +