From 5f3051aed7f882801d9645f4ff21452072794e79 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 15 Oct 2026 22:00:48 +0100 Subject: [PATCH] Best Value: one price age instead of a source breakdown Dated pricing had crowded More info and the station rows, and most of the extra copy described a distinction the data cannot make: every station carries a single station-wide timestamp, so "grade-specific vs station-wide" never varied in practice. - More info shows one row, "Price updated 28h ago" (or "Not provided"), replacing the separate station-report and fuel-update-time rows and the blanket "price freshness is not confirmed" label. - Rows and the detail sheet stay quiet when a saving is shown; only an ineligible comparison explains itself. Provisional savings already read as estimates from the capsule, so the fuel-named warning lines are gone. - Freshness/eligibility logic is unchanged: it still gates the baseline and still supports grade-specific timestamps for when the relay emits them. - Tests updated; localisation keys for the removed copy dropped. --- FuelBoard/StationDetailView.swift | 12 +---- FuelBoard/en.lproj/Localizable.strings | 9 +--- .../FuelPriceFreshnessTests.swift | 44 +++++++++++-------- Shared/FuelPriceFreshness.swift | 43 +++++++----------- 4 files changed, 44 insertions(+), 64 deletions(-) diff --git a/FuelBoard/StationDetailView.swift b/FuelBoard/StationDetailView.swift index 6aa7548..85ee020 100644 --- a/FuelBoard/StationDetailView.swift +++ b/FuelBoard/StationDetailView.swift @@ -213,20 +213,10 @@ struct StationDetailView: View { LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit)) .monospacedDigit() } - if let reportAge = priceFreshness.stationReportAgeLabel { - LabeledContent("Station price report", value: reportAge) - .monospacedDigit() - } - LabeledContent("\(selectedFuel.displayName) update time", value: priceFreshness.selectedFuelUpdateLabel) + LabeledContent("Price updated", value: priceFreshness.updateAgeLabel) .monospacedDigit() LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText) .monospacedDigit() - if priceFreshness.warning { - Label("Price freshness is not confirmed for \(selectedFuel.displayName)", - systemImage: "exclamationmark.triangle.fill") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.orange) - } ForEach(StationNotice.notices(for: station)) { notice in Label(notice.title, systemImage: notice.symbolName) .font(.subheadline.weight(.semibold)) diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index db04467..a1a9341 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -223,21 +223,14 @@ /* Best Value calculation and price confidence */ "£ compares the litres your budget buys at each station; Litres compares one fixed quantity." = "£ compares the litres your budget buys at each station; Litres compares one fixed quantity."; "You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund."; -"Price age" = "Price age"; "Best Value unavailable · price confidence insufficient" = "Best Value unavailable · price confidence insufficient"; "Best Value unavailable · location needed" = "Best Value unavailable · location needed"; "price order · Best Value unavailable" = "price order · Best Value unavailable"; "your order" = "your order"; -"Price not individually dated — saving is an estimate." = "Price not individually dated — saving is an estimate."; +"Price updated" = "Price updated"; "Price age unavailable or too old — not a Best Value recommendation." = "Price age unavailable or too old — not a Best Value recommendation."; "%@ ago" = "%@ ago"; "Not provided" = "Not provided"; -"Station price report" = "Station price report"; -"%@ update time" = "%@ update time"; -"Estimated saving · %@ price not individually dated" = "Estimated saving · %@ price not individually dated"; -"Estimated saving · closest eligible station's %@ price not individually dated" = "Estimated saving · closest eligible station's %@ price not individually dated"; -"Estimated saving · %@ price over 7 days old" = "Estimated saving · %@ price over 7 days old"; -"Estimated saving · closest eligible station's %@ price over 7 days old" = "Estimated saving · closest eligible station's %@ price over 7 days old"; "Fuel you'll receive" = "Fuel you'll receive"; "Best Value unavailable: no eligible price comparison. Stations and prices remain visible below." = "Best Value unavailable: no eligible price comparison. Stations and prices remain visible below."; "Compared with your closest eligible station." = "Compared with your closest eligible station."; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift index 0298f9a..ba32419 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelPriceFreshnessTests.swift @@ -70,37 +70,45 @@ final class FuelPriceFreshnessTests: XCTestCase { XCTAssertTrue(assessment.displayLabel.contains("station-wide")) } - func testRecentStationReportExplainsSelectedFuelTimeIsMissing() { + func testMoreInfoReportsOneStationPriceAge() { 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.updateAgeLabel, "28h ago") 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() { + func testOlderFuelSpecificTimestampStillReportsItsOwnAgeOnce() { 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") + XCTAssertEqual(assessment.updateAgeLabel, "10d ago") + XCTAssertEqual(assessment.source, .gradeSpecific) } - func testProvisionalWarningNamesBaselineWhenCandidateHasFuelSpecificTimestamp() { + func testMissingUpdateTimeReadsNotProvided() { + let assessment = FuelPriceFreshness.assessment(for: station(), fuel: .e10, now: now) + XCTAssertEqual(assessment.updateAgeLabel, "Not provided") + } + + func testProvisionalSavingAddsNoSecondWarningLine() { let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970 - let candidate = station(priceUpdatedByFuel: [.e10: recent]) - let baseline = station(priceUpdated: recent) + let stationWide = station(priceUpdated: recent) + let gradeSpecific = station(priceUpdatedByFuel: [.e10: recent]) + XCTAssertEqual(FuelPriceFreshness.comparisonConfidence( + station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now), .provisional) + XCTAssertNil(FuelPriceFreshness.comparisonWarning( + station: stationWide, fuel: .e10, baseline: stationWide, now: now)) + XCTAssertNil(FuelPriceFreshness.comparisonWarning( + station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now)) + } + + func testIneligibleComparisonStillExplainsItself() { + let old = now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970 + let stale = station(priceUpdatedByFuel: [.diesel: old]) XCTAssertEqual(FuelPriceFreshness.comparisonWarning( - station: candidate, fuel: .e10, baseline: baseline, now: now), - "Estimated saving · closest eligible station's Unleaded price not individually dated") + station: stale, fuel: .diesel, baseline: stale, now: now), + "Price age unavailable or too old — not a Best Value recommendation.") } func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() { diff --git a/Shared/FuelPriceFreshness.swift b/Shared/FuelPriceFreshness.swift index d8e4fa7..4a0cbeb 100644 --- a/Shared/FuelPriceFreshness.swift +++ b/Shared/FuelPriceFreshness.swift @@ -34,15 +34,13 @@ enum FuelPriceFreshness { 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") + /// The one age More info reports. Production data carries a single + /// station-wide timestamp per station, so there is no separate + /// grade-level age to show; an absent or unreadable timestamp reads + /// "Not provided" rather than implying freshness. + var updateAgeLabel: String { + guard let age else { + return String(localized: "Not provided", comment: "Price update time is missing") } return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"), Self.ageText(age)) @@ -118,33 +116,24 @@ enum FuelPriceFreshness { FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) } + /// Only an ineligible comparison needs a line of its own. The capsule + /// already presents a provisional saving as an estimate, so a second + /// line saying so crowded the rows and the detail sheet without + /// changing anything the user could do. var warningText: String? { switch self { - case .verified: return nil - case .provisional: return String(localized: "Price not individually dated — saving is an estimate.") + case .verified, .provisional: return nil case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.") } } } + /// A short line for a comparison that cannot be recommended at all; rows + /// stay quiet otherwise. Naming the fuel, and which side of the comparison + /// was weak, was noise once the saving is already labelled an estimate. static func comparisonWarning(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> String? { - 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) + comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now).warningText } static func comparisonConfidence(station: FuelStation, fuel: FuelType,