Make station price age informational only

The relay's price_updated is the maximum price_last_updated across the grades it maps, so it cannot vouch for the selected grade: stop using it to score, gate or warn about Best Value.

- FuelPriceFreshness is now station-level and display-only: one age per station, fresh/outdated/unknown/invalid, "May be outdated" past 30 days, with future-tolerance and non-finite guards.
- Best Value restores the closest-station-selling-the-selected-fuel baseline and its savings ranking; eligibility gating, eligible-first sorting and the unavailable banner are gone.
- Price age appears in the More info sheet only - no listing-row warnings, no extra banners.
- Copy reverted from "closest eligible station" to "closest station"; stale price-confidence strings removed, "May be outdated" added.
- Dormant per-grade timestamp plumbing removed, and regression tests added for every fuel type, missing/invalid/future stamps and the bundled offline dump.
This commit is contained in:
FuelBoard Contributor
2026-10-16 09:48:01 +01:00
parent 5900a81473
commit e960ef85c6
19 changed files with 530 additions and 732 deletions
+53 -130
View File
@@ -12,31 +12,48 @@ enum FuelPriceTimestamp {
}
}
/// Station-level price freshness — informational only.
///
/// The relay publishes ONE `price_updated` per station, derived from the
/// GOV.UK Fuel Finder field `price_last_updated` as the MAXIMUM across the
/// fuel grades it maps. It is therefore a station-level figure and never a
/// verified timestamp for the grade the user has selected, so every label
/// describes the station's price information rather than one fuel.
///
/// Age excludes nothing: a station with a valid price is ranked on that price
/// however old its timestamp is. `outdatedAge` only decides whether More info
/// adds a discreet "May be outdated" note.
enum FuelPriceFreshness {
static let warningAge: TimeInterval = 7 * 24 * 60 * 60
static let staleAge: TimeInterval = 30 * 24 * 60 * 60
/// Price information older than this is flagged as possibly out of date.
/// A display threshold only — it gates nothing.
static let outdatedAge: TimeInterval = 30 * 24 * 60 * 60
/// Tolerated clock skew. A timestamp further into the future than this is
/// unusable (a feed error), never silently treated as "just now".
static let futureTolerance: TimeInterval = 5 * 60
/// Ages beyond this are unusable rather than displayed. It also keeps the
/// day count inside `Int`, so an absurd epoch cannot trap.
static let maximumAge: TimeInterval = 365 * 24 * 60 * 60 * 10
enum Source: Equatable { case gradeSpecific, stationWide, unknown }
enum Status: Equatable { case fresh, warning, stale, unknown, invalid }
enum Confidence: Equatable { case verified, provisional, unavailable }
enum RecommendationEligibility: Equatable { case eligible, ineligible }
enum Status: Equatable {
/// Readable timestamp within `outdatedAge`.
case fresh
/// Readable timestamp older than `outdatedAge`.
case outdated
/// The record carries no timestamp.
case unknown
/// A timestamp is present but unreadable, or implausibly in the future.
case invalid
}
struct Assessment: Equatable {
let source: Source
let status: Status
/// Age in seconds. nil exactly when the timestamp is unusable, so no
/// caller can print an age that was never measured.
let age: TimeInterval?
let confidence: Confidence
let recommendationEligibility: RecommendationEligibility
let warning: Bool
var isEligibleForBestValue: Bool {
recommendationEligibility == .eligible
}
var isOutdated: Bool { status == .outdated }
/// The one age More info reports. Production data carries a single
/// station-wide timestamp per station, so there is no separate
/// grade-level age to show; an absent or unreadable timestamp reads
/// The one age More info reports. Missing or unreadable timestamps read
/// "Not provided" rather than implying freshness.
var updateAgeLabel: String {
guard let age else {
@@ -46,19 +63,16 @@ enum FuelPriceFreshness {
Self.ageText(age))
}
var displayLabel: String {
switch (source, status, age) {
case (_, .unknown, _): return "Price age unknown"
case (_, .invalid, _): return "Price timestamp invalid"
case (_, .stale, let age?): return "Price updated \(Self.ageText(age)) ago · may be outdated"
case (.stationWide, _, let age?): return "Price updated \(Self.ageText(age)) ago · station-wide · provisional"
case (_, .warning, let age?): return "Price updated \(Self.ageText(age)) ago"
case (.gradeSpecific, .fresh, let age?): return "Price updated \(Self.ageText(age)) ago"
default: return "Price age unknown"
}
/// Discreet flag for price information past `outdatedAge`. nil while the
/// information is fresh, and nil when there is nothing to measure.
var outdatedNotice: String? {
guard status == .outdated else { return nil }
return String(localized: "May be outdated",
comment: "Station price information older than 30 days")
}
private static func ageText(_ age: TimeInterval) -> String {
/// Compact age: minutes under an hour, hours under two days, then days.
static func ageText(_ age: TimeInterval) -> String {
let minutes = max(0, Int(age / 60))
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
@@ -67,112 +81,21 @@ enum FuelPriceFreshness {
}
}
static func assessment(for station: FuelStation, fuel: FuelType, now: Date = Date()) -> Assessment {
if station.priceUpdatedByFuelRaw?[fuel] != nil && station.priceUpdatedByFuel?[fuel] == nil {
return invalid(source: .gradeSpecific)
}
if let timestamp = station.priceUpdatedByFuel?[fuel] {
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
}
guard let timestamp = station.priceUpdated else { return unknown() }
return assess(timestamp: timestamp, source: .stationWide, now: now)
/// The station's own timestamp. Deliberately takes no fuel: there is one
/// figure per station and every grade reads the same one, so the policy
/// cannot differ between fuels.
static func assessment(for station: FuelStation, now: Date = Date()) -> Assessment {
assessment(priceUpdated: station.priceUpdated, now: now)
}
/// Recommendation gate for Best Value. A station-wide timestamp under
/// seven days can support a provisional estimate. Older station-wide
/// reports cannot establish grade recency; grade-specific timestamps may
/// remain eligible up to thirty days, but carry an age warning.
static func isEligibleForBestValue(station: FuelStation, fuel: FuelType,
now: Date = Date()) -> Bool {
assessment(for: station, fuel: fuel, now: now).isEligibleForBestValue
}
static func assessment(priceUpdated: String?, priceUpdatedByGrade: [String: String]?,
fuel: FuelType, now: Date = Date()) -> Assessment {
if let raw = priceUpdatedByGrade?[fuel.rawValue] {
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid(source: .gradeSpecific) }
return assess(timestamp: date.timeIntervalSince1970, source: .gradeSpecific, now: now)
}
guard let raw = priceUpdated else { return unknown() }
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid() }
return assess(timestamp: date.timeIntervalSince1970, source: .stationWide, now: now)
}
static func assessment(priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]?,
fuel: FuelType, now: Date = Date()) -> Assessment {
if let timestamp = priceUpdatedByFuel?[fuel] {
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
}
guard let timestamp = priceUpdated else { return unknown() }
return assess(timestamp: timestamp, source: .stationWide, now: now)
}
/// Confidence for a monetary comparison requires both selected-grade prices.
/// Keep the eligibility and label decision together for app and widgets.
enum ComparisonConfidence: Equatable {
case verified, provisional, ineligible
static func assess(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> Self {
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
}
/// Only an ineligible comparison needs a line of its own. The capsule
/// already presents a provisional saving as an estimate, so a second
/// line saying so crowded the rows and the detail sheet without
/// changing anything the user could do.
var warningText: String? {
switch self {
case .verified, .provisional: return nil
case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.")
}
}
}
/// A short line for a comparison that cannot be recommended at all; rows
/// stay quiet otherwise. Naming the fuel, and which side of the comparison
/// was weak, was noise once the saving is already labelled an estimate.
static func comparisonWarning(station: FuelStation, fuel: FuelType,
baseline: FuelStation?, now: Date = Date()) -> String? {
comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now).warningText
}
static func comparisonConfidence(station: FuelStation, fuel: FuelType,
baseline: FuelStation?, now: Date = Date()) -> ComparisonConfidence {
guard let baseline,
let candidatePrice = station.prices[fuel], candidatePrice.isFinite, candidatePrice > 0,
let baselinePrice = baseline.prices[fuel], baselinePrice.isFinite, baselinePrice > 0
else { return .ineligible }
let candidate = assessment(for: station, fuel: fuel, now: now)
let reference = assessment(for: baseline, fuel: fuel, now: now)
guard candidate.isEligibleForBestValue, reference.isEligibleForBestValue else { return .ineligible }
if candidate.confidence == .verified, reference.confidence == .verified,
candidate.status == .fresh, reference.status == .fresh { return .verified }
return .provisional
}
private static func unknown() -> Assessment {
Assessment(source: .unknown, status: .unknown, age: nil,
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
}
private static func invalid(source: Source = .unknown) -> Assessment {
Assessment(source: source, status: .invalid, age: nil,
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
}
private static func assess(timestamp: TimeInterval, source: Source, now: Date) -> Assessment {
let age = now.timeIntervalSince1970 - timestamp
guard age >= -futureTolerance else {
return Assessment(source: source, status: .invalid, age: nil,
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
static func assessment(priceUpdated: TimeInterval?, now: Date = Date()) -> Assessment {
guard let priceUpdated else { return Assessment(status: .unknown, age: nil) }
guard priceUpdated.isFinite else { return Assessment(status: .invalid, age: nil) }
let age = now.timeIntervalSince1970 - priceUpdated
guard age.isFinite, age >= -futureTolerance, age <= maximumAge else {
return Assessment(status: .invalid, age: nil)
}
let boundedAge = max(0, age)
let status: Status = boundedAge > staleAge ? .stale : boundedAge > warningAge ? .warning : .fresh
let confidence: Confidence = source == .gradeSpecific ? .verified : .provisional
let eligible = status == .fresh || (source == .gradeSpecific && status == .warning)
return Assessment(source: source, status: status, age: boundedAge,
confidence: confidence,
recommendationEligibility: eligible ? .eligible : .ineligible,
warning: source != .gradeSpecific || status != .fresh)
return Assessment(status: boundedAge > outdatedAge ? .outdated : .fresh, age: boundedAge)
}
}