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) } }