Best Value: baseline on the closest eligible price and gate on freshness
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared comparison confidence, so a saving is presented as verified only when both the candidate and the baseline pass the gate for the fuel on screen. - Rank against the closest station with an eligible selected-fuel price instead of the physically closest station, so a single stale neighbour can no longer invalidate every comparison in the list. Ordinary Closest sorting still includes every priced station. - Keep ineligible prices visible, fall back to pump-price order, and label Best Value unavailable when no comparison qualifies. - Widget: same baseline and confidence path as the app. - Tests: stale-closest baseline, alert baseline, eligibility policy, boundary and missing-timestamp regressions.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
enum FuelPriceFreshness {
|
||||
static let warningAge: TimeInterval = 7 * 24 * 60 * 60
|
||||
static let staleAge: TimeInterval = 30 * 24 * 60 * 60
|
||||
static let futureTolerance: TimeInterval = 5 * 60
|
||||
|
||||
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 }
|
||||
|
||||
struct Assessment: Equatable {
|
||||
let source: Source
|
||||
let status: Status
|
||||
let age: TimeInterval?
|
||||
let confidence: Confidence
|
||||
let recommendationEligibility: RecommendationEligibility
|
||||
let warning: Bool
|
||||
|
||||
var isEligibleForBestValue: Bool {
|
||||
recommendationEligibility == .eligible
|
||||
}
|
||||
|
||||
var stationReportAgeLabel: String? {
|
||||
guard source == .stationWide, let age else { return nil }
|
||||
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
||||
Self.ageText(age))
|
||||
}
|
||||
|
||||
var selectedFuelUpdateLabel: String {
|
||||
guard source == .gradeSpecific, let age else {
|
||||
return String(localized: "Not provided", comment: "Fuel-specific update time is missing")
|
||||
}
|
||||
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
private 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"
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
var warningText: String? {
|
||||
switch self {
|
||||
case .verified: return nil
|
||||
case .provisional: return String(localized: "Price not individually dated — saving is an estimate.")
|
||||
case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func comparisonWarning(station: FuelStation, fuel: FuelType,
|
||||
baseline: FuelStation?, now: Date = Date()) -> String? {
|
||||
let confidence = comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
|
||||
guard confidence == .provisional, let baseline else { return confidence.warningText }
|
||||
let candidate = assessment(for: station, fuel: fuel, now: now)
|
||||
let reference = assessment(for: baseline, fuel: fuel, now: now)
|
||||
let candidateNeedsWarning = candidate.confidence != .verified || candidate.status != .fresh
|
||||
let cause = candidateNeedsWarning ? candidate : reference
|
||||
if cause.source == .stationWide {
|
||||
let format = candidateNeedsWarning
|
||||
? String(localized: "Estimated saving · %@ price not individually dated")
|
||||
: String(localized: "Estimated saving · closest eligible station's %@ price not individually dated")
|
||||
return String(format: format, fuel.displayName)
|
||||
}
|
||||
let format = candidateNeedsWarning
|
||||
? String(localized: "Estimated saving · %@ price over 7 days old")
|
||||
: String(localized: "Estimated saving · closest eligible station's %@ price over 7 days old")
|
||||
return String(format: format, fuel.displayName)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ enum FuelPriceProvider {
|
||||
lat: relay.lat ?? 0,
|
||||
lng: relay.lng ?? 0,
|
||||
prices: relay.allPrices,
|
||||
priceUpdated: nil
|
||||
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970,
|
||||
priceUpdatedByFuel: relay.priceUpdatedByFuel,
|
||||
priceUpdatedByFuelRaw: relay.priceUpdatedByFuelRaw
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +67,15 @@ enum FuelPriceProvider {
|
||||
return result
|
||||
}
|
||||
|
||||
static func fuelType(for grade: String) -> FuelType? {
|
||||
switch grade.uppercased() {
|
||||
case "E10": return .e10
|
||||
case "E5": return .e5
|
||||
case "DIESEL", "B7", "B7S", "B7P", "B10": return .diesel
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
||||
/// About section shows these so the user can see which data source is
|
||||
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
||||
@@ -106,6 +117,41 @@ private struct MirrorResponse: Codable {
|
||||
let lng: Double?
|
||||
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:
|
||||
|
||||
@@ -35,6 +35,11 @@ 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.
|
||||
@@ -92,10 +97,31 @@ struct FuelPriceWidgetContent: View {
|
||||
Text(result.headline(compact: true))
|
||||
.font(.title3.weight(.bold))
|
||||
.lineLimit(1)
|
||||
Text(result.status == .baseline ? "your baseline" : "vs closest station")
|
||||
Text(result.status == .baseline ? "your baseline" : "vs closest eligible 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)
|
||||
}
|
||||
if entry.location == nil {
|
||||
Text("Best Value unavailable · location needed")
|
||||
.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)
|
||||
}
|
||||
@@ -137,7 +163,9 @@ struct FuelPriceWidgetContent: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(entry.isFavourites ? "cheapest first"
|
||||
Text(entry.sort == .worthTheDrive && entry.bestValuePriceConfidence == .ineligible
|
||||
? "price order · Best Value unavailable"
|
||||
: entry.isFavourites ? "your order"
|
||||
: (entry.locationSource == "none" ? "by price" : "near you"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
+76
-23
@@ -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 station", never "Break even".
|
||||
/// measured against. Shown as "Closest eligible 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 station"
|
||||
status == .baseline ? "your baseline" : "vs closest eligible 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 station"
|
||||
static let baselineHeadline = "Closest eligible 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 station".
|
||||
/// travel", "Break even", "Closest eligible station".
|
||||
///
|
||||
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||||
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||||
@@ -343,6 +343,19 @@ enum FuelSavingsCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing between a per-candidate monetary budget and a fixed litre
|
||||
/// quantity has no lossless conversion across all stations. Start a new
|
||||
/// comparison at that mode's stated default; the UI must tell the user
|
||||
/// explicitly that the comparison basis changed and invite adjustment.
|
||||
static func newComparisonAmount(afterSwitching amount: FuelSavingsFillAmount,
|
||||
to unit: FuelSavingsFillUnit) -> FuelSavingsFillAmount {
|
||||
guard amount.unit != unit else { return amount }
|
||||
switch unit {
|
||||
case .pounds: return FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
case .litres: return FuelSavingsFillAmount(value: 30, unit: .litres)
|
||||
}
|
||||
}
|
||||
|
||||
static func convert(amount: FuelSavingsFillAmount,
|
||||
to unit: FuelSavingsFillUnit,
|
||||
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
|
||||
@@ -439,21 +452,24 @@ enum FuelSavingsCalculator {
|
||||
settings: FuelSavingsSettings,
|
||||
amount: FuelSavingsFillAmount,
|
||||
minimumNetSavingGBP: Double,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||
now: Date = Date()
|
||||
) -> 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.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
|
||||
let baseline = FuelSavingsEstimator.bestValueBaselineStation(sellers, fuel: fuel,
|
||||
journeyMiles: journeyMiles, now: now)
|
||||
guard let baseline else { return nil }
|
||||
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)
|
||||
@@ -497,12 +513,8 @@ enum FuelSavingsCalculator {
|
||||
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
||||
/// rules are unit tested directly.
|
||||
enum FuelSavingsEstimator {
|
||||
/// 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.
|
||||
/// Closest priced station, irrespective of evidence age. Used for ordinary
|
||||
/// Closest sorting, which must continue to include older prices.
|
||||
static func baselineStation(_ stations: [FuelStation],
|
||||
fuel: FuelType,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
|
||||
@@ -520,6 +532,17 @@ 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,
|
||||
@@ -561,20 +584,26 @@ enum FuelSavingsEstimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
static func ranked(_ stations: [FuelStation],
|
||||
fuel: FuelType,
|
||||
baseline: FuelStation?,
|
||||
settings: FuelSavingsSettings,
|
||||
amount: FuelSavingsFillAmount,
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
|
||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||
now: Date = Date()) -> [FuelStation] {
|
||||
var journeys: [String: FuelSavingsJourney] = [:]
|
||||
journeys.reserveCapacity(stations.count)
|
||||
for station in stations {
|
||||
@@ -582,7 +611,21 @@ enum FuelSavingsEstimator {
|
||||
journeys[station.id] = journey
|
||||
}
|
||||
}
|
||||
let baselineJourney = baseline.flatMap { journeys[$0.id] }
|
||||
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,
|
||||
@@ -605,7 +648,17 @@ 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 }
|
||||
|
||||
@@ -275,6 +275,73 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
||||
let prices: [FuelType: Double]
|
||||
/// Updated timestamp (seconds since 1970) — nil for 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) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.brand = brand
|
||||
self.address = address
|
||||
self.postcode = postcode
|
||||
self.lat = lat
|
||||
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 {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
name = try values.decode(String.self, forKey: .name)
|
||||
brand = try values.decode(String.self, forKey: .brand)
|
||||
address = try values.decode(String.self, forKey: .address)
|
||||
postcode = try values.decode(String.self, forKey: .postcode)
|
||||
lat = try values.decode(Double.self, forKey: .lat)
|
||||
lng = try values.decode(Double.self, forKey: .lng)
|
||||
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 {
|
||||
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||
try values.encode(id, forKey: .id)
|
||||
try values.encode(name, forKey: .name)
|
||||
try values.encode(brand, forKey: .brand)
|
||||
try values.encode(address, forKey: .address)
|
||||
try values.encode(postcode, forKey: .postcode)
|
||||
try values.encode(lat, forKey: .lat)
|
||||
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.
|
||||
func distanceKM(to lat2: Double, lng2: Double) -> Double {
|
||||
|
||||
Reference in New Issue
Block a user