Best Value: baseline on the closest eligible price and gate on freshness

- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared
  comparison confidence, so a saving is presented as verified only when
  both the candidate and the baseline pass the gate for the fuel on screen.
- Rank against the closest station with an eligible selected-fuel price
  instead of the physically closest station, so a single stale neighbour
  can no longer invalidate every comparison in the list. Ordinary Closest
  sorting still includes every priced station.
- Keep ineligible prices visible, fall back to pump-price order, and label
  Best Value unavailable when no comparison qualifies.
- Widget: same baseline and confidence path as the app.
- Tests: stale-closest baseline, alert baseline, eligibility policy,
  boundary and missing-timestamp regressions.
This commit is contained in:
FuelBoard Contributor
2026-10-15 21:37:24 +01:00
parent 39a97f4905
commit 7c2eeb2604
11 changed files with 895 additions and 48 deletions
+76 -23
View File
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
case breakEven
case notWorthTheDrive
/// The comparison baseline itself — the station every other saving is
/// measured against. Shown as "Closest station", never "Break even".
/// measured against. Shown as "Closest eligible station", never "Break even".
case baseline
var displayName: String {
@@ -147,7 +147,7 @@ enum FuelSavingsFormat {
/// "Closest" refers to the existing shortest-journey baseline selection;
/// it does not claim a straight-line comparison.
static func comparisonLabel(status: FuelSavingsStatus) -> String {
status == .baseline ? "your baseline" : "vs closest station"
status == .baseline ? "your baseline" : "vs closest eligible station"
}
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
@@ -174,7 +174,7 @@ enum FuelSavingsFormat {
extension FuelSavingsResult {
/// Baseline row label — required wording.
static let baselineHeadline = "Closest station"
static let baselineHeadline = "Closest eligible station"
/// Neutral result label — required wording.
static let breakEvenHeadline = "Break even"
@@ -195,7 +195,7 @@ extension FuelSavingsResult {
}
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station".
/// travel", "Break even", "Closest eligible station".
///
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
@@ -343,6 +343,19 @@ enum FuelSavingsCalculator {
}
}
/// Changing between a per-candidate monetary budget and a fixed litre
/// quantity has no lossless conversion across all stations. Start a new
/// comparison at that mode's stated default; the UI must tell the user
/// explicitly that the comparison basis changed and invite adjustment.
static func newComparisonAmount(afterSwitching amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit) -> FuelSavingsFillAmount {
guard amount.unit != unit else { return amount }
switch unit {
case .pounds: return FuelSavingsFillAmount(value: 40, unit: .pounds)
case .litres: return FuelSavingsFillAmount(value: 30, unit: .litres)
}
}
static func convert(amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit,
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
@@ -439,21 +452,24 @@ enum FuelSavingsCalculator {
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
minimumNetSavingGBP: Double,
journeyMiles: (FuelStation) -> FuelSavingsJourney?
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()
) -> BestValueAlertCandidate? {
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
let sellers = stations.filter { $0.prices[fuel] != nil }
guard !sellers.isEmpty else { return nil }
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
let baseline = FuelSavingsEstimator.bestValueBaselineStation(sellers, fuel: fuel,
journeyMiles: journeyMiles, now: now)
guard let baseline else { return nil }
var best: BestValueAlertCandidate?
var bestDistance = Double.greatestFiniteMagnitude
var bestPrice = Double.infinity
var baselineJourneyCache: FuelSavingsJourney?
var baselineJourneyLoaded = false
for station in sellers {
guard FuelPriceFreshness.isEligibleForBestValue(station: station, fuel: fuel, now: now) else { continue }
let journey = journeyMiles(station)
let baselineJourney: FuelSavingsJourney? = {
guard let baseline else { return nil }
if baseline.id == station.id { return journey }
if !baselineJourneyLoaded {
baselineJourneyCache = journeyMiles(baseline)
@@ -497,12 +513,8 @@ enum FuelSavingsCalculator {
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
/// rules are unit tested directly.
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 "extra return travel" value tied to one
/// reference, instead of mixing a separate straight-line baseline with
/// road-route savings.
/// Closest priced station, irrespective of evidence age. Used for ordinary
/// Closest sorting, which must continue to include older prices.
static func baselineStation(_ stations: [FuelStation],
fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
@@ -520,6 +532,17 @@ enum FuelSavingsEstimator {
return bestStation
}
/// Closest selected-fuel seller with price evidence eligible for a Best Value
/// comparison. The ordinary Closest sort still includes every priced station.
static func bestValueBaselineStation(_ stations: [FuelStation],
fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()) -> FuelStation? {
baselineStation(stations.filter {
FuelPriceFreshness.comparisonConfidence(station: $0, fuel: fuel, baseline: $0, now: now) != .ineligible
}, fuel: fuel, journeyMiles: journeyMiles)
}
/// Savings for a single station against `baseline`, or nil when there is
/// nothing to show at all (feature off).
static func availability(station: FuelStation,
@@ -561,20 +584,26 @@ enum FuelSavingsEstimator {
}
}
/// Ranks a whole pool for the Best Value sort.
///
/// EVERY priced station in the pool gets an estimate, not just the handful
/// whose road routes happen to have been measured: journeys that have no
/// measured route fall back to a straight-line out-and-back estimate (see
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
/// stations that were a few miles further away — the exact stations the
/// sort exists to surface — and left later pages with no savings at all.
/// Recommendation gate shared by Best Value order, widgets and alerts.
/// The baseline and candidate must both pass the selected-fuel freshness
/// policy. A recent station-wide timestamp remains provisional; missing,
/// invalid and over-threshold timestamps cannot lead a recommendation.
static func recommendationEligible(station: FuelStation, fuel: FuelType,
baseline: FuelStation?, now: Date = Date()) -> Bool {
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) != .ineligible
}
/// Ranks the whole station pool by valid full-precision savings. Stations
/// with an ineligible price stay visible behind eligible candidates; if
/// none qualify, the fallback order is selected-fuel pump price (then id),
/// not an untrustworthy savings order. The caller labels the fallback.
static func ranked(_ stations: [FuelStation],
fuel: FuelType,
baseline: FuelStation?,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()) -> [FuelStation] {
var journeys: [String: FuelSavingsJourney] = [:]
journeys.reserveCapacity(stations.count)
for station in stations {
@@ -582,7 +611,21 @@ enum FuelSavingsEstimator {
journeys[station.id] = journey
}
}
let baselineJourney = baseline.flatMap { journeys[$0.id] }
let baselineJourney = baseline.flatMap { journeys[$0.id] ?? journeyMiles($0) }
let hasEligibleComparison = stations.contains {
recommendationEligible(station: $0, fuel: fuel, baseline: baseline, now: now)
}
if !hasEligibleComparison {
// A net-savings order cannot be offered without trustworthy price
// evidence. Keep every station visible in an explicit price order;
// the caller labels the fallback as unavailable.
return stations.sorted { lhs, rhs in
let left = lhs.prices[fuel] ?? .infinity
let right = rhs.prices[fuel] ?? .infinity
if left != right { return left < right }
return lhs.id < rhs.id
}
}
let ranked = stations.map { station -> FuelSavingsRankedStation in
let journey = journeys[station.id]
let availability = availability(station: station,
@@ -605,7 +648,17 @@ enum FuelSavingsEstimator {
order[rankedStation.stationID] = offset
}
}
// Compute freshness once per station, never in the O(n log n)
// comparator over the full UK pool.
var eligibility: [String: Bool] = [:]
eligibility.reserveCapacity(stations.count)
for station in stations {
eligibility[station.id] = recommendationEligible(station: station, fuel: fuel, baseline: baseline, now: now)
}
return stations.sorted { lhs, rhs in
let leftEligible = eligibility[lhs.id] ?? false
let rightEligible = eligibility[rhs.id] ?? false
if leftEligible != rightEligible { return leftEligible }
let left = order[lhs.id] ?? Int.max
let right = order[rhs.id] ?? Int.max
if left != right { return left < right }