Accurate distances toggle, Approx. distance markers, and Closest-mode speedup

Distance confidence
- Every distance surface takes the figure AND its "Approx." marker from one
  FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget,
  Live Activity, watch, Siri), so a surface can never mark one figure approximate
  while showing a routed one. Radius filters and Closest ordering test that same
  displayed distance, so no row can read a distance outside the radius that
  filtered it (the reported "14.6 mi inside a 10-mile list").

Accurate distances (Settings -> Distances, default off)
- Off: the browse layer serves the straight line, marked Approx., for every row,
  so two installs agree to the metre and the app makes no route requests at all.
- On: routed distances for the whole search area, unmarked, served only from a
  COMPLETE set anchored within 600 m — a partial fill reverts every row at once
  rather than mixing road and straight-line bases.
- Best Value maths sits deliberately outside the toggle: it keeps using measured
  road routes and its own confidence marking, so money figures never move with
  the switch.
- Fills run only while the toggle is on and the app is frontmost; capacity raised
  to 600 stations per pass; completeness tolerates pins Apple Maps has no route
  for, which otherwise left an area permanently incomplete and the whole list
  stuck on estimates.

Performance (the reported slowdown switching to Closest / launching with it)
- FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per
  call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per
  Closest sort. Memoised, with the setter keeping it in step.
- Both keyed sorts decorate their keys — one distance per station, carried as an
  index instead of copying whole stations into the decorated array — and the
  comparison baseline plus per-station journeys now come from ONE pass instead of
  two full scans per tab. The Stations tab also reuses its single sorted list for
  the count and the TOP badge instead of sorting the country three times.
- Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per
  list, Cheapest 6 ms, Best Value 11 ms.
- DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and
  FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when
  a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where
  devicectl eats a leading-dash argument.

Tests: 198, 0 failures. Device and simulator Debug builds green.
This commit is contained in:
FuelBoard Contributor
2026-10-13 18:01:45 +01:00
parent e0a997ecb0
commit 2a2823c180
21 changed files with 1009 additions and 131 deletions
+6
View File
@@ -32,6 +32,12 @@ struct FuelBoardLiveActivityAttributes: ActivityAttributes {
/// re-reading storage that may not be shared on sideloads.
var priceDisplayStyle: PriceDisplayStyle
var distanceKM: Double
/// Whether `distanceKM` came from the straight-line fallback instead of a
/// routed road distance, so the extension renders the "Approx." marker
/// without re-reading storage the extension may not share.
/// Optional so a state pushed by an earlier build still decodes — nil
/// reads as "not marked", i.e. the previous behaviour.
var distanceIsApproximate: Bool?
var lat: Double
var lng: Double
var updatedAt: Date
+8 -2
View File
@@ -89,7 +89,10 @@ struct FuelPriceWidgetContent: View {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
}
if let location = entry.location {
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
Text(distance.label(unit: entry.unit) + " away")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
@@ -149,7 +152,10 @@ struct FuelPriceWidgetContent: View {
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
Text(distance.label(unit: entry.unit))
.font(.caption2)
.foregroundStyle(.secondary)
}
+43 -16
View File
@@ -126,20 +126,39 @@ enum FuelSavingsFormat {
}
/// Row label for the extra driving a saving has to beat. The stored figure is
/// always MILES while the row's distance line is one-way, so the wording says
/// "more driving": the extra is an out-and-back figure and can legitimately
/// exceed the one-way distance, which read as a contradiction before.
/// always MILES while the row's distance line is one-way, so the wording names
/// the RETURN journey: the figure is an out-and-back DELTA between two
/// journeys and can legitimately exceed the one-way distance, which read as a
/// contradiction when it said "more driving than closest".
static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit)
switch confidence {
case .confirmed:
return "\(distanceText) more driving than closest"
case .estimated:
return "Approx. \(distanceText) additional travel"
}
return confidence == .estimated
? "Approx. \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
}
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
/// are spoken as words ("Approximately 6.4 miles extra return travel") because
/// synthesizers render "Approx." and "mi" inconsistently.
static func extraTravelSpokenLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = spokenDistance(extraDistanceMiles, unit: unit)
return confidence == .estimated
? "Approximately \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
}
/// A distance stored in MILES, spoken: "6.4 miles" / "10.3 kilometres". The
/// miles→km hop is deliberate: the stored figure is miles while
/// `DistanceUnit.spoken` speaks a KM value, the same boundary `distance(_:unit:)`
/// crosses.
static func spokenDistance(_ miles: Double, unit: DistanceUnit) -> String {
unit.spoken(miles * FuelSavingsCalculator.kilometresPerMile)
}
}
@@ -164,26 +183,34 @@ extension FuelSavingsResult {
)
}
/// Row headline, e.g. "Save £0.52 after travel", "£0.31 worse off after
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station".
///
/// Estimated comparisons are prefixed with `~` for positive/negative money
/// results. Break-even/baseline remain words, with estimate context carried
/// by the extra-distance caption or detail explanation.
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
/// tech may skip it) instead of saying "this is an estimate". Break-even and
/// baseline stay plain words, with the estimate context carried by the
/// extra-distance caption or the detail explanation.
func headline(compact: Bool = false) -> String {
let prefix = journeyConfidence.isEstimated ? "~" : ""
let prefix = journeyConfidence.isEstimated ? "Approx. " : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP)) after travel"
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
case .notWorthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
}
}
/// VoiceOver form of `headline()`: "Approx." is spelled out so a synthesizer
/// cannot read the abbreviation as "approx" or drop it entirely.
func spokenHeadline() -> String {
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
}
/// Row capsule copy: the headline, plus what the baseline row IS. Without the
/// suffix the baseline capsule just repeated the status word above it
/// ("Closest station" twice) and said nothing about the saving being measured
@@ -400,7 +427,7 @@ enum FuelSavingsEstimator {
/// Baseline station for Best Value: the station with the shortest journey
/// among stations that sell the selected fuel, using the SAME journey source
/// the savings calculation will use. This keeps the row labelled "Closest
/// station" and every "more driving than closest" value tied to one
/// station" and every "extra return travel" value tied to one
/// reference, instead of mixing a separate straight-line baseline with
/// road-route savings.
static func baselineStation(_ stations: [FuelStation],
+143 -5
View File
@@ -192,6 +192,20 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
func format(_ km: Double) -> String {
String(format: "%.1f %@", fromKM(km), shortName)
}
/// Spoken form of a KM distance ("6.4 miles", "10.3 kilometres"), for
/// VoiceOver. `format(_:)` returns the compact "6.4 mi"/"1.2 km" suffix that a
/// synthesizer may read as letters or skip, and "Approx." needs a word too, so
/// every spoken distance goes through here.
func spoken(_ km: Double) -> String {
switch self {
case .miles:
let miles = fromKM(km)
return String(format: "%.1f %@", miles, label(for: miles))
case .kilometers:
return String(format: "%.1f kilometres", km)
}
}
}
/// Display style for fuel prices. Internally prices are always stored and
@@ -1282,6 +1296,63 @@ struct FuelStore {
/// catches any real pin change.
static let roadDistancePinToleranceDegrees: Double = 1e-4
// MARK: Accurate distances (browse-layer toggle)
/// "Accurate distances". OFF (default) = the browse layer never uses a
/// routed road distance: every surface shows the straight-line value marked
/// "Approx.", so two devices agree exactly and the app makes no route
/// requests at all. ON = routed distances are shown, but only once the whole
/// search area has been routed from an anchor we are still standing near, so
/// a list is never a mix of road and straight-line values.
static let accurateDistancesKey = "fuelboard.accurateDistances"
/// Memoised, because this is read from the DISPLAY hot path.
///
/// `UserDefaults(suiteName:)` builds a fresh suite object and forces a
/// synchronous cfprefsd read, and `stationDistanceDisplay` consults this
/// property on every call — which the Closest sort used to make twice per
/// comparison (~200k reads for the whole-country pool), so switching to
/// Closest or launching with it selected stalled the main thread for seconds
/// on XPC alone. Same failure class as the keychain read that once tripped
/// the launch watchdog. Only this app writes the key, and it writes it
/// through `setAccurateDistancesEnabled`, which keeps the memo in step;
/// each PROCESS reads the stored value once (a widget extension has its own).
private static var accurateDistancesMemo: Bool?
static var accurateDistancesEnabled: Bool {
if let memo = accurateDistancesMemo { return memo }
let stored = UserDefaults(suiteName: appGroupSuite)?.bool(forKey: accurateDistancesKey) ?? false
accurateDistancesMemo = stored
return stored
}
static func setAccurateDistancesEnabled(_ enabled: Bool) {
accurateDistancesMemo = enabled
UserDefaults(suiteName: appGroupSuite)?.set(enabled, forKey: accurateDistancesKey)
}
/// Whether routed distances may be shown right now: the toggle is on AND the
/// cached routes cover the whole search area from a location we are still
/// near. Anything else serves straight-line + "Approx." for EVERY row, which
/// is what stops a list from mixing bases (a row reading "14.6 mi" inside a
/// "10 miles" list) and stops membership changing row by row as a fill lands.
static func accurateDistancesReady(userLat: Double, userLng: Double) -> Bool {
guard accurateDistancesEnabled else { return false }
guard let cache = loadRoadDistances(), cache.complete == true else { return false }
return haversineMetres(cache.sourceLat, cache.sourceLng, userLat, userLng)
<= roadDistanceOriginToleranceMeters
}
private static func haversineMetres(_ 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))
}
/// In-memory copy of the road-distance cache.
///
/// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC
@@ -1294,9 +1365,12 @@ struct FuelStore {
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]) {
static func saveRoadDistances(sourceLat: Double, sourceLng: Double,
entries: [String: CachedRoadDistance],
complete: Bool = true) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries)
updatedAt: Date().timeIntervalSince1970, entries: entries,
complete: complete)
if let data = try? JSONEncoder().encode(cache) {
saveString(data.base64EncodedString(), service: roadDistancesKey)
}
@@ -1389,10 +1463,36 @@ struct FuelStore {
/// Distance (km) to display for a station: cached ROAD distance when
/// available (matches Apple Maps), else straight-line haversine.
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
return meters / 1000.0
stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km
}
/// Distance to show for a station AND whether it came from a routed road
/// distance or the straight-line fallback. Both come out of ONE cache read so
/// a surface can never mark a figure "Approx." while showing a routed value
/// (or the reverse) — the failure mode when the number and its label are
/// computed separately.
static func stationDistanceDisplay(station: FuelStation, userLat: Double, userLng: Double) -> StationDistanceDisplay {
// Accurate-distances mode: a routed value is served ONLY when the whole
// search area has been routed from a nearby anchor. Otherwise every row
// falls back to straight-line together, so a list never mixes bases and
// the radius filter (which reads this same function) always agrees with
// what the rows show.
if accurateDistancesEnabled,
accurateDistancesReady(userLat: userLat, userLng: userLng),
let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
return StationDistanceDisplay(km: meters / 1000.0, isApproximate: false)
}
return station.distanceKM(to: userLat, lng2: userLng)
return StationDistanceDisplay(km: station.distanceKM(to: userLat, lng2: userLng), isApproximate: true)
}
/// Radius test on the SAME distance the UI shows. Filtering straight-line
/// while rows display a cached ROAD distance let a station read "14.6 mi"
/// inside a list set to "within 10 miles" — both numbers were individually
/// right (9.8 mi straight-line, 14.6 mi by road) but the list looked broken.
/// Uncached stations still test straight-line, i.e. their displayed value, so
/// nothing changes for them.
static func isWithinDisplayRadius(station: FuelStation, radiusKM: Double, userLat: Double, userLng: Double) -> Bool {
stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km <= radiusKM
}
// MARK: Low-level keychain helpers
@@ -1467,6 +1567,29 @@ struct RoadDistanceDiagnostics: Equatable {
let hasValidCacheEntry: Bool
}
/// The station distance a surface should show, plus whether it came from a routed
/// road distance or the straight-line fallback. The confidence travels WITH the
/// value, so a label can never claim a confidence the figure doesn't have.
struct StationDistanceDisplay: Equatable {
let km: Double
let isApproximate: Bool
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
/// that is NOT a routed road distance can never be read as one, and a cached
/// road distance stays a bare figure. It updates by itself once routing lands,
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
func label(unit: DistanceUnit) -> String {
let text = unit.format(km)
return isApproximate ? "Approx. \(text)" : text
}
/// VoiceOver form of `label(unit:)`.
func spokenLabel(unit: DistanceUnit) -> String {
let text = unit.spoken(km)
return isApproximate ? "Approximately \(text)" : text
}
}
/// Cached Apple-Maps road distances for nearby stations (see
/// A single cached road distance plus the station pin it was routed to. Keeping
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
@@ -1493,4 +1616,19 @@ struct RoadDistanceCache: Codable {
let sourceLng: Double
let updatedAt: TimeInterval
let entries: [String: CachedRoadDistance]
/// True when `entries` covers EVERY station within
/// `RoadDistanceService.maxRadiusKM` of the source — only then may the browse
/// layer serve routed distances (see `FuelStore.accurateDistancesReady`).
/// Absent in caches written before the accurate-distances toggle existed,
/// which were 12-per-pass sets and never complete: absent = not complete.
let complete: Bool?
init(sourceLat: Double, sourceLng: Double, updatedAt: TimeInterval,
entries: [String: CachedRoadDistance], complete: Bool? = nil) {
self.sourceLat = sourceLat
self.sourceLng = sourceLng
self.updatedAt = updatedAt
self.entries = entries
self.complete = complete
}
}
+7 -1
View File
@@ -22,8 +22,14 @@ enum SiriCheapestLookup {
withinMiles: Double
) -> FuelStation? {
let radiusKM = withinMiles * 1.60934
// Same radius rule as the app list: the distance the answer would SHOW
// (cached road route where there is one).
return stations
.filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
.filter {
$0.prices[fuel] != nil
&& FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
userLat: lat, userLng: lng)
}
.min { lhs, rhs in
guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else {
return false