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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user