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:
FuelBoard Contributor
2026-10-15 21:37:24 +01:00
parent 39a97f4905
commit 7c2eeb2604
11 changed files with 895 additions and 48 deletions
@@ -0,0 +1 @@
../../../Shared/FuelPriceFreshness.swift
@@ -0,0 +1,243 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
final class FuelPriceFreshnessTests: XCTestCase {
private let now = Date(timeIntervalSince1970: 1_792_022_400)
private func station(
priceUpdated: TimeInterval? = nil,
priceUpdatedByFuel: [FuelType: TimeInterval]? = nil
) -> FuelStation {
FuelStation(
id: "freshness-station",
name: "Freshness Station",
brand: "Test",
address: "1 High Street",
postcode: "BD1 1AA",
lat: 53.79,
lng: -1.75,
prices: [.diesel: 129.9, .e10: 169.9],
priceUpdated: priceUpdated,
priceUpdatedByFuel: priceUpdatedByFuel
)
}
func testPerGradeTimestampIsDecodedAndEligibleWhenFresh() throws {
let json = """
{
"stations": [{
"id": "s1",
"name": "LISTER ARMS",
"brand": "Shell",
"address": "MANCHESTER ROAD, BRADFORD",
"postcode": "BD5 7JX",
"lat": 53.78,
"lng": -1.75,
"prices": {"E10": 169.9, "DIESEL": 129.9},
"price_updated": "2026-09-25T07:45:52Z",
"price_updated_by_grade": {
"E10": "2026-10-14T07:45:52Z",
"DIESEL": "2026-10-14T07:45:52Z"
}
}]
}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
let decoded = try XCTUnwrap(stations.first)
XCTAssertEqual(decoded.priceUpdatedByFuel?[.diesel],
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970)
let assessment = FuelPriceFreshness.assessment(for: decoded, fuel: .diesel, now: now)
XCTAssertEqual(assessment.source, .gradeSpecific)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertTrue(assessment.isEligibleForBestValue)
}
func testStationWideTimestampIsNotPresentedAsGradeSpecificOrEligible() {
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge / 2).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(
for: station(priceUpdated: updated), fuel: .diesel, now: now
)
XCTAssertEqual(assessment.source, .stationWide)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertEqual(assessment.confidence, .provisional)
XCTAssertTrue(assessment.recommendationEligibility == .eligible)
XCTAssertTrue(assessment.isEligibleForBestValue)
XCTAssertTrue(assessment.warning)
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
}
func testRecentStationReportExplainsSelectedFuelTimeIsMissing() {
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
XCTAssertEqual(assessment.stationReportAgeLabel, "28h ago")
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "Not provided")
XCTAssertEqual(assessment.source, .stationWide)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: station(priceUpdated: updated), fuel: .e10,
baseline: station(priceUpdated: updated), now: now),
"Estimated saving · Unleaded price not individually dated")
}
func testOlderFuelSpecificTimestampDoesNotClaimUndatedPrice() {
let updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
let priced = station(priceUpdatedByFuel: [.diesel: updated])
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
XCTAssertNil(assessment.stationReportAgeLabel)
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "10d ago")
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: priced, fuel: .diesel, baseline: priced, now: now),
"Estimated saving · Diesel price over 7 days old")
}
func testProvisionalWarningNamesBaselineWhenCandidateHasFuelSpecificTimestamp() {
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let candidate = station(priceUpdatedByFuel: [.e10: recent])
let baseline = station(priceUpdated: recent)
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: candidate, fuel: .e10, baseline: baseline, now: now),
"Estimated saving · closest eligible station's Unleaded price not individually dated")
}
func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() {
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge).timeIntervalSince1970
let onBoundary = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .diesel, now: now)
XCTAssertEqual(onBoundary.status, .fresh)
XCTAssertTrue(onBoundary.isEligibleForBestValue)
let pastBoundary = FuelPriceFreshness.assessment(
for: station(priceUpdated: updated - 1), fuel: .diesel, now: now)
XCTAssertEqual(pastBoundary.status, .warning)
XCTAssertFalse(pastBoundary.isEligibleForBestValue)
}
func testStationWideTimestampOlderThanWarningThresholdCannotRecommendSuspiciousDiesel() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let shell = station(priceUpdated: now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970)
let age = FuelPriceFreshness.assessment(for: shell, fuel: .diesel, now: now)
XCTAssertEqual(age.status, .warning)
XCTAssertFalse(age.isEligibleForBestValue)
XCTAssertEqual(shell.prices[.diesel], 129.9)
}
func testComparisonConfidenceRejectsMissingSelectedFuelPriceEvenWithFreshTimestamp() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let updated = now.addingTimeInterval(-86_400).timeIntervalSince1970
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: nil,
priceUpdatedByFuel: [.e10: updated])
let e10 = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.e10: 169.9], priceUpdated: nil,
priceUpdatedByFuel: [.e10: updated])
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: dieselOnly, fuel: .e10,
baseline: e10, now: now), .ineligible)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: e10, fuel: .e10,
baseline: dieselOnly, now: now), .ineligible)
}
func testComparisonConfidenceRequiresBothSelectedGradeTimestamps() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
let old = now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970
let verified = station(priceUpdatedByFuel: [.diesel: recent])
let provisional = station(priceUpdated: recent)
let oldStationWide = station(priceUpdated: old)
let oldGrade = station(priceUpdatedByFuel: [.diesel: old])
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
baseline: verified, now: now), .verified)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
baseline: provisional, now: now), .provisional)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: oldGrade, fuel: .diesel,
baseline: verified, now: now), .provisional)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
baseline: oldStationWide, now: now), .ineligible)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: provisional, fuel: .diesel,
baseline: verified, now: now), .provisional)
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .e10,
baseline: verified, now: now), .ineligible)
}
func testNonFiniteAndExtremeEpochsCannotBecomeTrusted() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
for value in [Double.nan, .infinity, -.infinity, Double.greatestFiniteMagnitude, -Double.greatestFiniteMagnitude] {
let assessment = FuelPriceFreshness.assessment(
priceUpdated: value, priceUpdatedByFuel: nil, fuel: .diesel, now: now)
XCTAssertFalse(assessment.isEligibleForBestValue)
XCTAssertTrue(assessment.warning)
}
}
func testMissingAndMalformedTimestampsAreUnknownAndIneligible() {
let missing = FuelPriceFreshness.assessment(for: station(), fuel: .diesel, now: now)
XCTAssertEqual(missing.status, .unknown)
XCTAssertEqual(missing.source, .unknown)
XCTAssertFalse(missing.isEligibleForBestValue)
let malformed = FuelPriceFreshness.assessment(
priceUpdated: "not-a-date", priceUpdatedByGrade: nil, fuel: .diesel, now: now
)
XCTAssertEqual(malformed.status, .invalid)
XCTAssertFalse(malformed.isEligibleForBestValue)
}
func testStaleDisplayLabelDoesNotCallIneligiblePriceProvisional() {
let stale = station(priceUpdatedByFuel: [
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
])
let assessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
XCTAssertFalse(assessment.isEligibleForBestValue)
XCTAssertFalse(assessment.displayLabel.contains("provisional"))
XCTAssertTrue(assessment.displayLabel.contains("outdated"))
}
func testStaleAndFutureGradeTimestampsAreRejected() {
let stale = station(priceUpdatedByFuel: [
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
])
let staleAssessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
XCTAssertEqual(staleAssessment.status, .stale)
XCTAssertFalse(staleAssessment.isEligibleForBestValue)
XCTAssertTrue(staleAssessment.warning)
let future = station(priceUpdatedByFuel: [
.diesel: now.addingTimeInterval(FuelPriceFreshness.futureTolerance + 1).timeIntervalSince1970
])
let futureAssessment = FuelPriceFreshness.assessment(for: future, fuel: .diesel, now: now)
XCTAssertEqual(futureAssessment.status, .invalid)
XCTAssertFalse(futureAssessment.isEligibleForBestValue)
}
func testMalformedAndFutureSourceValuesDoNotCrashOrProduceNegativeAge() {
let malformed = FuelPriceFreshness.assessment(
priceUpdated: "2026-99-99T99:99:99Z",
priceUpdatedByGrade: ["DIESEL": "2026-99-99T99:99:99Z"],
fuel: .diesel,
now: now
)
XCTAssertNil(malformed.age)
XCTAssertFalse(malformed.isEligibleForBestValue)
let future = FuelPriceFreshness.assessment(
priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970,
priceUpdatedByFuel: nil,
fuel: .diesel,
now: now
)
XCTAssertNil(future.age)
XCTAssertFalse(future.isEligibleForBestValue)
}
}
private extension ISO8601DateFormatter {
static func fuelBoardDate(_ string: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: string) ?? {
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: string)
}()
}
}
@@ -48,6 +48,42 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
// MARK: - 1. Unit conversion
func testBudgetExamplesAcrossAllFuelTypes() throws {
for fuel in FuelType.allCases {
let base = FuelStation(id: "base", name: "Base", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 199.9], priceUpdated: nil)
let other = FuelStation(id: "other", name: "Other", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 129.9], priceUpdated: nil)
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 40, unit: .pounds))
for budget in [20.0, 40, 60, 73, 100] {
let result = FuelSavingsEstimator.availability(station: other, fuel: fuel, baseline: base,
settings: settings, amount: .init(value: budget, unit: .pounds),
journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
let volume = budget / 1.299
XCTAssertEqual(result?.fillLitres ?? 0, volume, accuracy: 1e-9)
XCTAssertEqual(result?.grossSavingGBP ?? 0, volume * 0.7, accuracy: 1e-9)
XCTAssertEqual(result?.netSavingGBP ?? 0, volume * 0.7 - 7.4 / 30 * 4.54609 * 1.299, accuracy: 1e-9)
}
}
}
func testCandidateBudgetAndEqualQuantitySavingsForEveryFuel() throws {
for fuel in FuelType.allCases {
let candidate = FuelStation(id: "candidate-\(fuel.rawValue)", name: "Candidate", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 129.9], priceUpdated: nil)
let baseline = FuelStation(id: "baseline-\(fuel.rawValue)", name: "Nearest", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 199.9], priceUpdated: nil)
let configured = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
let money = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
amount: .init(value: 60, unit: .pounds), journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
let litres = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
amount: .init(value: 60 / 1.299, unit: .litres), journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
XCTAssertEqual(money?.fillLitres ?? 0, 60 / 1.299, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.grossSavingGBP ?? 0, litres?.grossSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.netSavingGBP ?? 0, litres?.netSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.netSavingGBP ?? 0, (money?.grossSavingGBP ?? 0) - (money?.extraDrivingCostGBP ?? 0), accuracy: 1e-8, fuel.rawValue)
}
}
func testImperialGallonIsUsedNotTheUSGallon() {
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
@@ -497,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
)?.result
XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest station")
XCTAssertEqual(result?.headline(), "Closest eligible station")
}
}
@@ -41,6 +41,20 @@ final class FuelSavingsEstimateTests: XCTestCase {
FuelSavingsJourney(miles: miles, basis: .straightLine)
}
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 199.9], priceUpdated: nil)
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.diesel: 129.9], priceUpdated: nil)
let nearest = FuelSavingsEstimator.baselineStation([petrolOnly, dieselOnly], fuel: .diesel) { station in
self.straightLine(station.id == "petrol" ? 0.5 : 1)
}
XCTAssertEqual(nearest?.id, "diesel")
let unavailable = FuelSavingsEstimator.availability(station: dieselOnly, fuel: .diesel, baseline: petrolOnly,
settings: settings, amount: amount, journey: straightLine(1), baselineJourney: straightLine(0.5))
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
}
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
// Nearest station: 1 mile away at 145.9p. The station the user can see
// in the Cheapest tab: 3.5 miles away at 138.9p.
@@ -85,6 +99,24 @@ final class FuelSavingsEstimateTests: XCTestCase {
"unmeasured routes are reported as straight-line estimates")
}
func testBaselineOutsideFilteredPoolStillRanksByNetSaving() {
let baseline = station("nearest-outside-radius", price: 199.9)
let cheap = station("z-cheap", price: 129.9)
let expensive = station("a-expensive", price: 198.9)
let ranked = FuelSavingsEstimator.ranked(
[expensive, cheap], fuel: .e10, baseline: baseline,
settings: settings, amount: amount,
journeyMiles: { s in self.straightLine(s.id == baseline.id ? 0.8 : 4.0) }
)
XCTAssertEqual(ranked.map(\.id), ["z-cheap", "a-expensive"])
let expected = FuelSavingsEstimator.availability(
station: cheap, fuel: .e10, baseline: baseline,
settings: settings, amount: amount,
journey: straightLine(4.0), baselineJourney: straightLine(0.8)
)?.result
XCTAssertGreaterThan(expected?.netSavingGBP ?? -1, 0)
}
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
// 40 stations, but only the first one has a measured road route — the
// old implementation capped the whole ranking to 12 shortlisted
@@ -159,6 +191,56 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(result?.distanceBasis, .straightLine)
}
func testBestValueBaselineSkipsClosestStalePriceButKeepsThatStationVisible() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
}
let staleClosest = priced("stale-closest", 199.9, 27)
let eligibleClosest = priced("eligible-closest", 170.9, 1)
let eligibleFurther = priced("eligible-further", 150.9, 1)
let pool = [staleClosest, eligibleClosest, eligibleFurther]
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
self.straightLine(station.id == "stale-closest" ? 0.8 : station.id == "eligible-closest" ? 1.6 : 3.0)
}
let baseline = FuelSavingsEstimator.bestValueBaselineStation(pool, fuel: .e10,
journeyMiles: journeys, now: now)
XCTAssertEqual(baseline?.id, "eligible-closest")
XCTAssertEqual(FuelSavingsEstimator.baselineStation(pool, fuel: .e10, journeyMiles: journeys)?.id,
"stale-closest", "non-Best-Value closest behaviour must stay unchanged")
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: baseline,
settings: briefSettings, amount: briefAmount, journeyMiles: journeys, now: now)
XCTAssertEqual(ranked.first?.id, "eligible-further")
XCTAssertTrue(ranked.contains(where: { $0.id == "stale-closest" }))
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: staleClosest, fuel: .e10,
baseline: baseline, now: now))
}
func testBestValueBaselineRequiresEligibleSelectedFuelAndUsableJourney() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
let stale = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
let stations = [
FuelStation(id: "wrong-fuel", name: "Wrong", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.diesel: 140], priceUpdated: recent),
FuelStation(id: "stale", name: "Stale", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.e10: 140], priceUpdated: stale),
FuelStation(id: "bad-journey", name: "Bad journey", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.e10: 141], priceUpdated: recent),
FuelStation(id: "eligible", name: "Eligible", brand: "", address: "", postcode: "", lat: 0, lng: 0,
prices: [.e10: 145], priceUpdated: recent)
]
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
self.straightLine(station.id == "wrong-fuel" ? 0.1 : station.id == "stale" ? 0.2 :
station.id == "bad-journey" ? .infinity : 2)
}
XCTAssertEqual(FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: .e10,
journeyMiles: journeys, now: now)?.id, "eligible")
XCTAssertNil(FuelSavingsEstimator.bestValueBaselineStation(Array(stations.prefix(2)), fuel: .e10,
journeyMiles: journeys, now: now))
}
func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
let geographicallyNearest = station("geographically-nearest", price: 145.9)
let roadNearest = station("road-nearest", price: 146.9)
@@ -350,8 +432,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
)
XCTAssertEqual(availability?.result?.status, .baseline)
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
}
@@ -403,7 +485,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// The exact strings the brief asks for.
func testHeadlineWordingMatchesTheBrief() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
let saving = FuelSavingsResult(fillLitres: 34.9,
@@ -427,10 +509,87 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// The sort must order by NET saving, not pump price: a pricier station can
/// win when the cheaper one is far enough away.
func testBestValueAlertUsesClosestEligiblePriceAsBaseline() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
}
let stations = [priced("stale-closest", 199.9, 27), priced("eligible-closest", 170.9, 1),
priced("eligible-cheaper", 150.9, 1)]
let settings = FuelSavingsSettings(enabled: true, mpg: 30,
preferredAmount: .init(value: 60, unit: .pounds))
let alert = FuelSavingsCalculator.bestValueAlertCandidate(
stations: stations, fuel: .e10, settings: settings, amount: settings.preferredAmount,
minimumNetSavingGBP: 1,
journeyMiles: { self.straightLine($0.id == "stale-closest" ? 0.8 : $0.id == "eligible-closest" ? 1.6 : 3) },
now: now)
XCTAssertEqual(alert?.station.id, "eligible-cheaper")
}
func testBestValueAlertDoesNotRecommendStaleCandidateOrStaleBaseline() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
}
let base = priced("base", 199.9, 1)
let stale = priced("stale", 129.9, 40)
let fresh = priced("fresh", 159.9, 1)
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
let choice = FuelSavingsCalculator.bestValueAlertCandidate(
stations: [base, stale, fresh], fuel: .e10, settings: settings,
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
XCTAssertEqual(choice?.station.id, "fresh")
let noTrustedBaseline = FuelSavingsCalculator.bestValueAlertCandidate(
stations: [priced("base", 199.9, 40), fresh], fuel: .e10, settings: settings,
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
XCTAssertNil(noTrustedBaseline)
}
func testStalePriceCannotLeadBestValueButRemainsVisible() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let baseline = FuelStation(id: "base", name: "Baseline", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 199.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
let stale = FuelStation(id: "stale-cheap", name: "Stale", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 129.9], priceUpdated: now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970)
let recent = FuelStation(id: "recent", name: "Recent", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 170.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
let ranked = FuelSavingsEstimator.ranked([stale, recent], fuel: .e10, baseline: baseline,
settings: briefSettings, amount: briefAmount,
journeyMiles: { _ in self.straightLine(0.8) }, now: now)
XCTAssertEqual(ranked.map(\.id), ["recent", "stale-cheap"])
}
func testNoFreshPriceComparisonUsesExplicitCheapestPriceFallback() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let staleTime = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
let baseline = FuelStation(id: "baseline", name: "Baseline", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 199.9], priceUpdated: staleTime)
let cheapest = FuelStation(id: "cheapest", name: "Cheapest", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: staleTime)
let near = FuelStation(id: "near", name: "Near", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.diesel: 170.9], priceUpdated: staleTime)
let ordered = FuelSavingsEstimator.ranked([near, baseline, cheapest], fuel: .diesel,
baseline: baseline, settings: briefSettings, amount: briefAmount,
journeyMiles: { station in self.straightLine(station.id == "cheapest" ? 250 : 1) }, now: now)
XCTAssertEqual(ordered.map(\.id), ["cheapest", "near", "baseline"])
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: ordered[0], fuel: .diesel,
baseline: baseline, now: now))
}
func testSortingRanksByNetSavingNotPumpPrice() {
let baseline = station("baseline", price: 171.9)
let cheapButFar = station("cheap-but-far", price: 167.9)
let pricierButNear = station("pricier-but-near", price: 169.9)
let now = Date(timeIntervalSince1970: 1_800_000_000)
func pricedStation(_ id: String, price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: 51.5, lng: -0.1, prices: [.e10: price],
priceUpdated: nil, priceUpdatedByFuel: [.e10: now.timeIntervalSince1970])
}
let baseline = pricedStation("baseline", price: 171.9)
let cheapButFar = pricedStation("cheap-but-far", price: 167.9)
let pricierButNear = pricedStation("pricier-but-near", price: 169.9)
let ranked = FuelSavingsEstimator.ranked(
[baseline, cheapButFar, pricierButNear],
@@ -444,7 +603,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
case "cheap-but-far": return self.straightLine(7.6)
default: return self.straightLine(1.2)
}
}
}, now: now
)
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
@@ -454,18 +613,18 @@ final class FuelSavingsEstimateTests: XCTestCase {
// MARK: Row copy
func testCompactComparisonLabelUsesGenericNearestStationWording() {
func testCompactComparisonLabelUsesClosestEligibleStationWording() {
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
"vs closest station"
"vs closest eligible station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
"vs closest station"
"vs closest eligible station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .breakEven),
"vs closest station"
"vs closest eligible station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .baseline),
@@ -494,7 +653,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
}
/// The baseline capsule has to say what it IS: it previously repeated the
/// status word above it ("Closest station" twice) and never explained that
/// status word above it ("Closest eligible station" twice) and never explained that
/// the savings are measured against it.
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
let baseline = FuelSavingsResult(
@@ -505,7 +664,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0,
status: .baseline
)
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
let saving = FuelSavingsResult(
fillLitres: 30,
@@ -110,10 +110,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
}
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let stations = [
station("baseline", lat: 0, lng: 0, price: 160),
station("small", lat: 0, lng: 0, price: 154),
station("best", lat: 0, lng: 0, price: 148),
station("baseline", lat: 0, lng: 0, price: 160, priceUpdated: now.timeIntervalSince1970),
station("small", lat: 0, lng: 0, price: 154, priceUpdated: now.timeIntervalSince1970),
station("best", lat: 0, lng: 0, price: 148, priceUpdated: now.timeIntervalSince1970),
]
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
@@ -123,7 +124,8 @@ final class FuelSavingsCalculatorTests: XCTestCase {
settings: settings,
amount: settings.preferredAmount,
minimumNetSavingGBP: 1.0,
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } },
now: now
)
XCTAssertEqual(candidate?.station.id, "best")
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
@@ -137,12 +139,23 @@ final class FuelSavingsCalculatorTests: XCTestCase {
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
}
private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
private func station(_ id: String, lat: Double, lng: Double, price: Double,
priceUpdated: TimeInterval? = nil) -> FuelStation {
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng,
prices: [.e10: price], priceUpdated: priceUpdated)
}
}
final class FuelSavingsSettingsTests: XCTestCase {
func testSwitchingFillUnitStartsAnExplicitNewComparison() {
let budget = FuelSavingsFillAmount(value: 60, unit: .pounds)
let litres = FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .litres)
XCTAssertEqual(litres, FuelSavingsFillAmount(value: 30, unit: .litres))
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: litres, to: .pounds),
FuelSavingsFillAmount(value: 40, unit: .pounds))
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .pounds), budget)
}
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
+14 -2
View File
@@ -203,6 +203,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// 3) Load + order stations per widget mode.
let ordered: [FuelStation]
var bestValueResult: FuelSavingsResult?
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var bestValuePriceWarning: String?
if isFavourites {
let favourites = FuelStore.loadFavourites()
.filter { $0.station.prices[$0.fuel] != nil }
@@ -306,7 +308,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
break
}
let amount = settings.preferredAmount
let baseline = FuelSavingsEstimator.baselineStation(filtered, fuel: fuel, journeyMiles: journey)
let baseline = FuelSavingsEstimator.bestValueBaselineStation(filtered, fuel: fuel,
journeyMiles: journey)
let ranked = FuelSavingsEstimator.ranked(
filtered,
fuel: fuel,
@@ -317,6 +320,13 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
)
ordered = ranked
if let first = ranked.first, let baseline {
bestValuePriceConfidence = FuelPriceFreshness.comparisonConfidence(
station: first, fuel: fuel, baseline: baseline)
bestValuePriceWarning = FuelPriceFreshness.comparisonWarning(
station: first, fuel: fuel, baseline: baseline)
}
if let first = ranked.first, let baseline,
bestValuePriceConfidence != .ineligible {
let baselineJourney = journey(baseline)
let result = FuelSavingsEstimator.availability(
station: first,
@@ -348,7 +358,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
date: Date(), stations: Array(ordered.prefix(8)),
fuel: fuel, sort: sort, isFavourites: isFavourites,
location: origin, locationSource: source,
unit: unit, bestValueResult: bestValueResult
unit: unit, bestValueResult: bestValueResult,
bestValuePriceConfidence: bestValuePriceConfidence,
bestValuePriceWarning: bestValuePriceWarning
)
), originSource)
}
+189
View File
@@ -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)
}
}
+47 -1
View File
@@ -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:
+30 -2
View File
@@ -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
View File
@@ -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 }
+67
View File
@@ -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 {