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:
+53
-130
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user