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
+67
View File
@@ -275,6 +275,73 @@ struct FuelStation: Identifiable, Codable, Equatable {
let prices: [FuelType: Double]
/// Updated timestamp (seconds since 1970) — nil for sample data.
let priceUpdated: TimeInterval?
/// Per-grade timestamps when the source provides them. A station-wide
/// `price_updated` must not be copied into this map: it does not prove that
/// every grade changed at that time.
let priceUpdatedByFuel: [FuelType: TimeInterval]?
/// Original per-grade timestamp text, retained so malformed source values
/// cannot silently fall back to a station-wide timestamp.
let priceUpdatedByFuelRaw: [FuelType: String]?
init(id: String, name: String, brand: String, address: String, postcode: String,
lat: Double, lng: Double, prices: [FuelType: Double],
priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]? = nil,
priceUpdatedByFuelRaw: [FuelType: String]? = nil) {
self.id = id
self.name = name
self.brand = brand
self.address = address
self.postcode = postcode
self.lat = lat
self.lng = lng
self.prices = prices
self.priceUpdated = priceUpdated
self.priceUpdatedByFuel = priceUpdatedByFuel
self.priceUpdatedByFuelRaw = priceUpdatedByFuelRaw
}
private enum CodingKeys: String, CodingKey {
case id, name, brand, address, postcode, lat, lng, prices
case priceUpdated = "priceUpdated"
case priceUpdatedSnake = "price_updated"
case priceUpdatedByFuel = "priceUpdatedByFuel"
case priceUpdatedByFuelSnake = "price_updated_by_fuel"
case priceUpdatedByFuelRaw = "priceUpdatedByFuelRaw"
case priceUpdatedByFuelRawSnake = "price_updated_by_fuel_raw"
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
name = try values.decode(String.self, forKey: .name)
brand = try values.decode(String.self, forKey: .brand)
address = try values.decode(String.self, forKey: .address)
postcode = try values.decode(String.self, forKey: .postcode)
lat = try values.decode(Double.self, forKey: .lat)
lng = try values.decode(Double.self, forKey: .lng)
prices = try values.decode([FuelType: Double].self, forKey: .prices)
priceUpdated = try values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdated)
?? values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdatedSnake)
priceUpdatedByFuel = try values.decodeIfPresent([FuelType: TimeInterval].self, forKey: .priceUpdatedByFuel)
?? values.decodeIfPresent([FuelType: TimeInterval].self, forKey: .priceUpdatedByFuelSnake)
priceUpdatedByFuelRaw = try values.decodeIfPresent([FuelType: String].self, forKey: .priceUpdatedByFuelRaw)
?? values.decodeIfPresent([FuelType: String].self, forKey: .priceUpdatedByFuelRawSnake)
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try values.encode(name, forKey: .name)
try values.encode(brand, forKey: .brand)
try values.encode(address, forKey: .address)
try values.encode(postcode, forKey: .postcode)
try values.encode(lat, forKey: .lat)
try values.encode(lng, forKey: .lng)
try values.encode(prices, forKey: .prices)
try values.encodeIfPresent(priceUpdated, forKey: .priceUpdated)
try values.encodeIfPresent(priceUpdatedByFuel, forKey: .priceUpdatedByFuel)
try values.encodeIfPresent(priceUpdatedByFuelRaw, forKey: .priceUpdatedByFuelRaw)
}
/// Cheap haversine distance to a location, in km.
func distanceKM(to lat2: Double, lng2: Double) -> Double {