From 7c2eeb260495b342875eddd0133267614f287c99 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 15 Oct 2026 21:37:24 +0100 Subject: [PATCH] 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. --- .../FuelBoardShared/FuelPriceFreshness.swift | 1 + .../FuelPriceFreshnessTests.swift | 243 ++++++++++++++++++ .../FuelSavingsCalculationAuditTests.swift | 38 ++- .../FuelSavingsEstimateTests.swift | 185 ++++++++++++- .../FuelSavingsTests.swift | 25 +- FuelBoardWidgets/FuelPriceWidget.swift | 16 +- Shared/FuelPriceFreshness.swift | 189 ++++++++++++++ Shared/FuelPriceProvider.swift | 48 +++- Shared/FuelPriceWidgetViews.swift | 32 ++- Shared/FuelSavings.swift | 99 +++++-- Shared/FuelStore.swift | 67 +++++ 11 files changed, 895 insertions(+), 48 deletions(-) create mode 120000 FuelBoardTests/Sources/FuelBoardShared/FuelPriceFreshness.swift create mode 100644 FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift create mode 100644 Shared/FuelPriceFreshness.swift diff --git a/FuelBoardTests/Sources/FuelBoardShared/FuelPriceFreshness.swift b/FuelBoardTests/Sources/FuelBoardShared/FuelPriceFreshness.swift new file mode 120000 index 0000000..8180963 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/FuelPriceFreshness.swift @@ -0,0 +1 @@ +../../../Shared/FuelPriceFreshness.swift \ No newline at end of file diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift new file mode 100644 index 0000000..0298f9a --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.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) + }() + } +} diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift index d7996fa..cdce9a8 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift @@ -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") } } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift index 28241dc..9a51293 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -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, diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift index c7cd784..a65411e 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift @@ -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) diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index b0ef840..7220e0c 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -203,6 +203,8 @@ struct FuelPriceTimelineProvider 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) + } +} diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift index 798fedd..8c554c2 100644 --- a/Shared/FuelPriceProvider.swift +++ b/Shared/FuelPriceProvider.swift @@ -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: diff --git a/Shared/FuelPriceWidgetViews.swift b/Shared/FuelPriceWidgetViews.swift index 687d4ec..e31bb58 100644 --- a/Shared/FuelPriceWidgetViews.swift +++ b/Shared/FuelPriceWidgetViews.swift @@ -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) diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index c0b2ed1..1987d5c 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -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 } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index bc639c6..e6b4873 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -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 {