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.
102 lines
4.5 KiB
Swift
102 lines
4.5 KiB
Swift
import Foundation
|
|
|
|
enum FuelPriceTimestamp {
|
|
static func parse(_ value: String?) -> Date? {
|
|
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter.date(from: value) ?? {
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: value)
|
|
}()
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
/// 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 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 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?
|
|
|
|
var isOutdated: Bool { status == .outdated }
|
|
|
|
/// The one age More info reports. Missing or unreadable timestamps read
|
|
/// "Not provided" rather than implying freshness.
|
|
var updateAgeLabel: String {
|
|
guard let age else {
|
|
return String(localized: "Not provided", comment: "Price update time is missing")
|
|
}
|
|
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
|
Self.ageText(age))
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
/// 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
|
|
if hours < 48 { return "\(hours)h" }
|
|
return "\(hours / 24)d"
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
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)
|
|
return Assessment(status: boundedAge > outdatedAge ? .outdated : .fresh, age: boundedAge)
|
|
}
|
|
}
|