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.
This commit is contained in:
@@ -213,20 +213,10 @@ struct StationDetailView: View {
|
|||||||
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
if let reportAge = priceFreshness.stationReportAgeLabel {
|
LabeledContent("Price updated", value: priceFreshness.updateAgeLabel)
|
||||||
LabeledContent("Station price report", value: reportAge)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
LabeledContent("\(selectedFuel.displayName) update time", value: priceFreshness.selectedFuelUpdateLabel)
|
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
|
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
|
||||||
.monospacedDigit()
|
.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
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
Label(notice.title, systemImage: notice.symbolName)
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|||||||
@@ -223,21 +223,14 @@
|
|||||||
/* Best Value calculation and price confidence */
|
/* 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.";
|
"£ 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.";
|
"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 · price confidence insufficient" = "Best Value unavailable · price confidence insufficient";
|
||||||
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
||||||
"price order · Best Value unavailable" = "price order · Best Value unavailable";
|
"price order · Best Value unavailable" = "price order · Best Value unavailable";
|
||||||
"your order" = "your order";
|
"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.";
|
"Price age unavailable or too old — not a Best Value recommendation." = "Price age unavailable or too old — not a Best Value recommendation.";
|
||||||
"%@ ago" = "%@ ago";
|
"%@ ago" = "%@ ago";
|
||||||
"Not provided" = "Not provided";
|
"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";
|
"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.";
|
"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.";
|
"Compared with your closest eligible station." = "Compared with your closest eligible station.";
|
||||||
|
|||||||
@@ -70,37 +70,45 @@ final class FuelPriceFreshnessTests: XCTestCase {
|
|||||||
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
|
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRecentStationReportExplainsSelectedFuelTimeIsMissing() {
|
func testMoreInfoReportsOneStationPriceAge() {
|
||||||
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||||
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
|
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
|
||||||
XCTAssertEqual(assessment.stationReportAgeLabel, "28h ago")
|
XCTAssertEqual(assessment.updateAgeLabel, "28h ago")
|
||||||
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "Not provided")
|
|
||||||
XCTAssertEqual(assessment.source, .stationWide)
|
XCTAssertEqual(assessment.source, .stationWide)
|
||||||
XCTAssertEqual(assessment.status, .fresh)
|
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 updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
|
||||||
let priced = station(priceUpdatedByFuel: [.diesel: updated])
|
let priced = station(priceUpdatedByFuel: [.diesel: updated])
|
||||||
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
|
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
|
||||||
XCTAssertNil(assessment.stationReportAgeLabel)
|
XCTAssertEqual(assessment.updateAgeLabel, "10d ago")
|
||||||
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "10d ago")
|
XCTAssertEqual(assessment.source, .gradeSpecific)
|
||||||
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
|
||||||
station: priced, fuel: .diesel, baseline: priced, now: now),
|
|
||||||
"Estimated saving · Diesel price over 7 days old")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||||
let candidate = station(priceUpdatedByFuel: [.e10: recent])
|
let stationWide = station(priceUpdated: recent)
|
||||||
let baseline = 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(
|
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
||||||
station: candidate, fuel: .e10, baseline: baseline, now: now),
|
station: stale, fuel: .diesel, baseline: stale, now: now),
|
||||||
"Estimated saving · closest eligible station's Unleaded price not individually dated")
|
"Price age unavailable or too old — not a Best Value recommendation.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() {
|
func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() {
|
||||||
|
|||||||
@@ -34,15 +34,13 @@ enum FuelPriceFreshness {
|
|||||||
recommendationEligibility == .eligible
|
recommendationEligibility == .eligible
|
||||||
}
|
}
|
||||||
|
|
||||||
var stationReportAgeLabel: String? {
|
/// The one age More info reports. Production data carries a single
|
||||||
guard source == .stationWide, let age else { return nil }
|
/// station-wide timestamp per station, so there is no separate
|
||||||
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
/// grade-level age to show; an absent or unreadable timestamp reads
|
||||||
Self.ageText(age))
|
/// "Not provided" rather than implying freshness.
|
||||||
}
|
var updateAgeLabel: String {
|
||||||
|
guard let age else {
|
||||||
var selectedFuelUpdateLabel: String {
|
return String(localized: "Not provided", comment: "Price update time is missing")
|
||||||
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"),
|
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
||||||
Self.ageText(age))
|
Self.ageText(age))
|
||||||
@@ -118,33 +116,24 @@ enum FuelPriceFreshness {
|
|||||||
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
|
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? {
|
var warningText: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .verified: return nil
|
case .verified, .provisional: 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.")
|
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,
|
static func comparisonWarning(station: FuelStation, fuel: FuelType,
|
||||||
baseline: FuelStation?, now: Date = Date()) -> String? {
|
baseline: FuelStation?, now: Date = Date()) -> String? {
|
||||||
let confidence = comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
|
comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now).warningText
|
||||||
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,
|
static func comparisonConfidence(station: FuelStation, fuel: FuelType,
|
||||||
|
|||||||
Reference in New Issue
Block a user