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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ enum FuelPriceProvider {
|
||||
lat: relay.lat ?? 0,
|
||||
lng: relay.lng ?? 0,
|
||||
prices: relay.allPrices,
|
||||
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970,
|
||||
priceUpdatedByFuel: relay.priceUpdatedByFuel,
|
||||
priceUpdatedByFuelRaw: relay.priceUpdatedByFuelRaw
|
||||
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,41 +116,16 @@ private struct MirrorResponse: Codable {
|
||||
let price: Double?
|
||||
let prices: [String: Double]?
|
||||
let priceUpdated: String?
|
||||
let priceUpdatedByGrade: [String: String]?
|
||||
let priceUpdatedByFuelRawValues: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, brand, address, postcode, lat, lng, price, prices
|
||||
case priceUpdated = "price_updated"
|
||||
case priceUpdatedByGrade = "price_updated_by_grade"
|
||||
case priceUpdatedByFuelRawValues = "price_updated_by_fuel"
|
||||
}
|
||||
|
||||
var priceUpdatedDate: Date? {
|
||||
FuelPriceTimestamp.parse(priceUpdated)
|
||||
}
|
||||
|
||||
var priceUpdatedByFuelRaw: [FuelType: String]? {
|
||||
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
|
||||
guard let raw else { return nil }
|
||||
let parsed = raw.reduce(into: [FuelType: String]()) { result, item in
|
||||
guard let fuel = FuelPriceProvider.fuelType(for: item.key) else { return }
|
||||
result[fuel] = item.value
|
||||
}
|
||||
return parsed.isEmpty ? nil : parsed
|
||||
}
|
||||
|
||||
var priceUpdatedByFuel: [FuelType: TimeInterval]? {
|
||||
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
|
||||
guard let raw else { return nil }
|
||||
let parsed = raw.reduce(into: [FuelType: TimeInterval]()) { result, item in
|
||||
guard let fuel = FuelPriceProvider.fuelType(for: item.key),
|
||||
let date = FuelPriceTimestamp.parse(item.value) else { return }
|
||||
result[fuel] = date.timeIntervalSince1970
|
||||
}
|
||||
return parsed.isEmpty ? nil : parsed
|
||||
}
|
||||
|
||||
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
||||
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
||||
/// any grade outside the 50–500 pence/litre band is dropped, so a mirror
|
||||
|
||||
@@ -35,11 +35,6 @@ struct FuelPriceWidgetEntryData {
|
||||
let locationSource: String // "live" | "cached" | "none"
|
||||
let unit: DistanceUnit // user's display unit for distances
|
||||
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
|
||||
/// Both selected-fuel prices are assessed by the shared freshness policy.
|
||||
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||||
/// Candidate-aware warning copy (names the fuel, and the baseline when the
|
||||
/// baseline is the weak link). Falls back to the confidence's own text.
|
||||
var bestValuePriceWarning: String? = nil
|
||||
}
|
||||
|
||||
/// The widget face, parameterised by family so the app can preview it.
|
||||
@@ -97,16 +92,10 @@ struct FuelPriceWidgetContent: View {
|
||||
Text(result.headline(compact: true))
|
||||
.font(.title3.weight(.bold))
|
||||
.lineLimit(1)
|
||||
Text(result.status == .baseline ? "your baseline" : "vs closest eligible station")
|
||||
Text(result.status == .baseline ? "your baseline" : "vs closest station")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let warning = entry.bestValuePriceWarning ?? entry.bestValuePriceConfidence.warningText {
|
||||
Text(warning)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
}
|
||||
} else if entry.sort == .worthTheDrive {
|
||||
if let price = station.prices[entry.fuel] {
|
||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||
@@ -116,11 +105,6 @@ struct FuelPriceWidgetContent: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Best Value unavailable · price confidence insufficient")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
}
|
||||
} else if let price = station.prices[entry.fuel] {
|
||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||
@@ -163,9 +147,7 @@ struct FuelPriceWidgetContent: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(entry.sort == .worthTheDrive && entry.bestValuePriceConfidence == .ineligible
|
||||
? "price order · Best Value unavailable"
|
||||
: entry.isFavourites ? "your order"
|
||||
Text(entry.isFavourites ? "your order"
|
||||
: (entry.locationSource == "none" ? "by price" : "near you"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
+26
-62
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
|
||||
case breakEven
|
||||
case notWorthTheDrive
|
||||
/// The comparison baseline itself — the station every other saving is
|
||||
/// measured against. Shown as "Closest eligible station", never "Break even".
|
||||
/// measured against. Shown as "Closest station", never "Break even".
|
||||
case baseline
|
||||
|
||||
var displayName: String {
|
||||
@@ -147,7 +147,7 @@ enum FuelSavingsFormat {
|
||||
/// "Closest" refers to the existing shortest-journey baseline selection;
|
||||
/// it does not claim a straight-line comparison.
|
||||
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
||||
status == .baseline ? "your baseline" : "vs closest eligible station"
|
||||
status == .baseline ? "your baseline" : "vs closest station"
|
||||
}
|
||||
|
||||
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
||||
@@ -174,7 +174,7 @@ enum FuelSavingsFormat {
|
||||
|
||||
extension FuelSavingsResult {
|
||||
/// Baseline row label — required wording.
|
||||
static let baselineHeadline = "Closest eligible station"
|
||||
static let baselineHeadline = "Closest station"
|
||||
/// Neutral result label — required wording.
|
||||
static let breakEvenHeadline = "Break even"
|
||||
|
||||
@@ -195,7 +195,7 @@ extension FuelSavingsResult {
|
||||
}
|
||||
|
||||
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
||||
/// travel", "Break even", "Closest eligible station".
|
||||
/// travel", "Break even", "Closest station".
|
||||
///
|
||||
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||||
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||||
@@ -452,24 +452,21 @@ enum FuelSavingsCalculator {
|
||||
settings: FuelSavingsSettings,
|
||||
amount: FuelSavingsFillAmount,
|
||||
minimumNetSavingGBP: Double,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||
now: Date = Date()
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?
|
||||
) -> BestValueAlertCandidate? {
|
||||
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
|
||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||
guard !sellers.isEmpty else { return nil }
|
||||
let baseline = FuelSavingsEstimator.bestValueBaselineStation(sellers, fuel: fuel,
|
||||
journeyMiles: journeyMiles, now: now)
|
||||
guard let baseline else { return nil }
|
||||
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
|
||||
var best: BestValueAlertCandidate?
|
||||
var bestDistance = Double.greatestFiniteMagnitude
|
||||
var bestPrice = Double.infinity
|
||||
var baselineJourneyCache: FuelSavingsJourney?
|
||||
var baselineJourneyLoaded = false
|
||||
for station in sellers {
|
||||
guard FuelPriceFreshness.isEligibleForBestValue(station: station, fuel: fuel, now: now) else { continue }
|
||||
let journey = journeyMiles(station)
|
||||
let baselineJourney: FuelSavingsJourney? = {
|
||||
guard let baseline else { return nil }
|
||||
if baseline.id == station.id { return journey }
|
||||
if !baselineJourneyLoaded {
|
||||
baselineJourneyCache = journeyMiles(baseline)
|
||||
@@ -513,8 +510,12 @@ enum FuelSavingsCalculator {
|
||||
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
||||
/// rules are unit tested directly.
|
||||
enum FuelSavingsEstimator {
|
||||
/// Closest priced station, irrespective of evidence age. Used for ordinary
|
||||
/// Closest sorting, which must continue to include older prices.
|
||||
/// Baseline station for Best Value: the station with the shortest journey
|
||||
/// among stations that sell the selected fuel, using the SAME journey source
|
||||
/// the savings calculation will use. This keeps the row labelled "Closest
|
||||
/// station" and every "extra return travel" value tied to one
|
||||
/// reference, instead of mixing a separate straight-line baseline with
|
||||
/// road-route savings.
|
||||
static func baselineStation(_ stations: [FuelStation],
|
||||
fuel: FuelType,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
|
||||
@@ -532,17 +533,6 @@ enum FuelSavingsEstimator {
|
||||
return bestStation
|
||||
}
|
||||
|
||||
/// Closest selected-fuel seller with price evidence eligible for a Best Value
|
||||
/// comparison. The ordinary Closest sort still includes every priced station.
|
||||
static func bestValueBaselineStation(_ stations: [FuelStation],
|
||||
fuel: FuelType,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||
now: Date = Date()) -> FuelStation? {
|
||||
baselineStation(stations.filter {
|
||||
FuelPriceFreshness.comparisonConfidence(station: $0, fuel: fuel, baseline: $0, now: now) != .ineligible
|
||||
}, fuel: fuel, journeyMiles: journeyMiles)
|
||||
}
|
||||
|
||||
/// Savings for a single station against `baseline`, or nil when there is
|
||||
/// nothing to show at all (feature off).
|
||||
static func availability(station: FuelStation,
|
||||
@@ -584,26 +574,20 @@ enum FuelSavingsEstimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recommendation gate shared by Best Value order, widgets and alerts.
|
||||
/// The baseline and candidate must both pass the selected-fuel freshness
|
||||
/// policy. A recent station-wide timestamp remains provisional; missing,
|
||||
/// invalid and over-threshold timestamps cannot lead a recommendation.
|
||||
static func recommendationEligible(station: FuelStation, fuel: FuelType,
|
||||
baseline: FuelStation?, now: Date = Date()) -> Bool {
|
||||
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) != .ineligible
|
||||
}
|
||||
|
||||
/// Ranks the whole station pool by valid full-precision savings. Stations
|
||||
/// with an ineligible price stay visible behind eligible candidates; if
|
||||
/// none qualify, the fallback order is selected-fuel pump price (then id),
|
||||
/// not an untrustworthy savings order. The caller labels the fallback.
|
||||
/// Ranks a whole pool for the Best Value sort.
|
||||
///
|
||||
/// EVERY priced station in the pool gets an estimate, not just the handful
|
||||
/// whose road routes happen to have been measured: journeys that have no
|
||||
/// measured route fall back to a straight-line out-and-back estimate (see
|
||||
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
|
||||
/// stations that were a few miles further away — the exact stations the
|
||||
/// sort exists to surface — and left later pages with no savings at all.
|
||||
static func ranked(_ stations: [FuelStation],
|
||||
fuel: FuelType,
|
||||
baseline: FuelStation?,
|
||||
settings: FuelSavingsSettings,
|
||||
amount: FuelSavingsFillAmount,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||
now: Date = Date()) -> [FuelStation] {
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
|
||||
var journeys: [String: FuelSavingsJourney] = [:]
|
||||
journeys.reserveCapacity(stations.count)
|
||||
for station in stations {
|
||||
@@ -611,21 +595,11 @@ enum FuelSavingsEstimator {
|
||||
journeys[station.id] = journey
|
||||
}
|
||||
}
|
||||
// The baseline is normally in the pool, so this is the same lookup it has
|
||||
// always been; asking for its journey directly covers the case where the
|
||||
// list being ranked is filtered tighter than the baseline was chosen from,
|
||||
// which would otherwise mark every row unavailable.
|
||||
let baselineJourney = baseline.flatMap { journeys[$0.id] ?? journeyMiles($0) }
|
||||
let hasEligibleComparison = stations.contains {
|
||||
recommendationEligible(station: $0, fuel: fuel, baseline: baseline, now: now)
|
||||
}
|
||||
if !hasEligibleComparison {
|
||||
// A net-savings order cannot be offered without trustworthy price
|
||||
// evidence. Keep every station visible in an explicit price order;
|
||||
// the caller labels the fallback as unavailable.
|
||||
return stations.sorted { lhs, rhs in
|
||||
let left = lhs.prices[fuel] ?? .infinity
|
||||
let right = rhs.prices[fuel] ?? .infinity
|
||||
if left != right { return left < right }
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
||||
let journey = journeys[station.id]
|
||||
let availability = availability(station: station,
|
||||
@@ -648,17 +622,7 @@ enum FuelSavingsEstimator {
|
||||
order[rankedStation.stationID] = offset
|
||||
}
|
||||
}
|
||||
// Compute freshness once per station, never in the O(n log n)
|
||||
// comparator over the full UK pool.
|
||||
var eligibility: [String: Bool] = [:]
|
||||
eligibility.reserveCapacity(stations.count)
|
||||
for station in stations {
|
||||
eligibility[station.id] = recommendationEligible(station: station, fuel: fuel, baseline: baseline, now: now)
|
||||
}
|
||||
return stations.sorted { lhs, rhs in
|
||||
let leftEligible = eligibility[lhs.id] ?? false
|
||||
let rightEligible = eligibility[rhs.id] ?? false
|
||||
if leftEligible != rightEligible { return leftEligible }
|
||||
let left = order[lhs.id] ?? Int.max
|
||||
let right = order[rhs.id] ?? Int.max
|
||||
if left != right { return left < right }
|
||||
|
||||
+4
-23
@@ -273,20 +273,13 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
||||
let lng: Double
|
||||
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
|
||||
let prices: [FuelType: Double]
|
||||
/// Updated timestamp (seconds since 1970) — nil for sample data.
|
||||
/// Station-level price timestamp (seconds since 1970): the relay's
|
||||
/// `price_updated`, i.e. the newest price submission across the grades it
|
||||
/// maps — never a per-grade time. nil when the source omits it (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) {
|
||||
priceUpdated: TimeInterval?) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.brand = brand
|
||||
@@ -296,18 +289,12 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
||||
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 {
|
||||
@@ -322,10 +309,6 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
||||
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 {
|
||||
@@ -339,8 +322,6 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user