Compare commits

...
Author SHA1 Message Date
FuelBoard Contributor ff8a1e18d0 Redesign the station listing and move the Best Value controls
Two columns split by a 1pt hairline: identity on the left at a fixed 52%
share so rows line up when scrolling, price and savings on the right,
leading-aligned on one axis with the price dominant. The favourites star
sits under the station logo, freeing the width the pounds-and-pence price
needs to render in full rather than scaled or truncated.

Badges stay tinted rather than filled — BEST VALUE green, CHEAPEST blue,
none for nearest — and the verdict capsule is one shared view across the
listing, the detail sheet and the compact summary, so wording and colour
cannot drift between them.

Best Value's minimum saving moves from Alerts to Settings, Fuel Savings,
beside the fill size and MPG that produce the saving; the Alerts tab reads
it back instead of offering it twice. Its options are now 50p steps to £5
(was 50p/£1/£2/£5), so £2.50 and £3 are reachable, with a test pinning the
step, the ceiling and the store's string round-trip.

The verdict capsule scales to 0.7 before truncating. Measured on a 393pt
screen its copy is handed ~69pt while "Closest station" needs ~90pt, so the
old 0.8 floor could not reach it and rows showed "Closest stati..." instead.

Verified: Debug build clean, swift test 249/249.
2026-10-16 13:38:11 +01:00
FuelBoard Contributor e960ef85c6 Make station price age informational only
The relay's price_updated is the maximum price_last_updated across the grades it maps, so it cannot vouch for the selected grade: stop using it to score, gate or warn about Best Value.

- FuelPriceFreshness is now station-level and display-only: one age per station, fresh/outdated/unknown/invalid, "May be outdated" past 30 days, with future-tolerance and non-finite guards.
- Best Value restores the closest-station-selling-the-selected-fuel baseline and its savings ranking; eligibility gating, eligible-first sorting and the unavailable banner are gone.
- Price age appears in the More info sheet only - no listing-row warnings, no extra banners.
- Copy reverted from "closest eligible station" to "closest station"; stale price-confidence strings removed, "May be outdated" added.
- Dormant per-grade timestamp plumbing removed, and regression tests added for every fuel type, missing/invalid/future stamps and the bundled offline dump.
2026-10-16 09:48:01 +01:00
22 changed files with 1052 additions and 880 deletions
+11 -11
View File
@@ -13,7 +13,10 @@ struct AlertsView: View {
@Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var alertMode: AlertMode
@Binding var bestValueThresholdGBP: Double
/// Read-only here: the threshold is edited in Settings → Fuel Savings, where
/// the fill size and MPG that decide the saving live. Read back so the
/// alert's own rule is visible without leaving the tab.
let bestValueThresholdGBP: Double
let bestValueConfigured: Bool
@Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
@@ -178,16 +181,13 @@ struct AlertsView: View {
.font(.caption)
.foregroundStyle(.secondary)
if alertMode == .bestValue {
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
// The threshold is edited in Settings → Fuel Savings: it
// labels station rows BEST VALUE as well as gating this
// alert, so it lives with the fill size and MPG that
// produce the saving. Read back here, not offered twice.
Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP)))
.font(.caption)
.foregroundStyle(.secondary)
}
if enabled && monitoredCount == 0 {
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
+293 -145
View File
@@ -29,7 +29,7 @@ struct ContentView: View {
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
@@ -114,6 +114,21 @@ struct ContentView: View {
return fallback
}
/// QA hook: `-priceStyle poundsPence` renders prices in the wide pounds &
/// pence form for ONE launch. Same reason as `-sortMode`: simctl has no tap
/// command, so the wide price has to be measured against the row's column
/// widths without walking Settings → Units for every check. Never persisted.
private static func qaPriceStyleOverride(default fallback: PriceDisplayStyle) -> PriceDisplayStyle {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-priceStyle"), i + 1 < args.count,
let style = PriceDisplayStyle(rawValue: args[i + 1]) {
return style
}
#endif
return fallback
}
private static func qaSortModeOverride(default fallback: SortMode) -> SortMode {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
@@ -401,9 +416,9 @@ struct ContentView: View {
/// (computing a journey for each) and `savingsLookup` immediately scanned them
/// all AGAIN to fill the per-station map. Both now share one result.
///
/// The baseline is chosen with the same journey and selected-fuel freshness
/// guards as `FuelSavingsEstimator.bestValueBaselineStation` when configured;
/// with the feature off, the ordinary Closest station still names that row.
/// The baseline is chosen with the same journey guards as
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
/// off, the ordinary Closest station still names that row.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) }
guard fuelSavingsSettings.isConfigured else {
@@ -418,7 +433,7 @@ struct ContentView: View {
journeys[station.id] = journey
}
}
let baseline = FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: selectedFuel) {
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
journeys[$0.id]
}
return (baseline, journeys)
@@ -454,15 +469,15 @@ struct ContentView: View {
}
}
/// The Closest station with an eligible selected-fuel price — the Best Value
/// comparison baseline. Ordinary Closest sorting still includes stale stations.
/// It uses the same journey values as the savings calculation, so a cached
/// road route can change the baseline and keep the "extra return travel"
/// labels synchronized with the cost calculation. Without a cached route,
/// the journey falls back to straight-line and the result is an estimate.
/// The nearest station that sells the selected fuel — the Best Value
/// comparison baseline. It is chosen with the SAME journey values used by
/// the savings calculation, so a cached road route can change the baseline
/// and keep the "extra return travel" labels synchronized with the
/// cost calculation. When no road route is cached, the journey falls back to
/// straight-line out-and-back, and the result is marked as an estimate.
private var nearestSavingsBaseline: FuelStation? {
guard let location else { return nil }
return FuelSavingsEstimator.bestValueBaselineStation(
return FuelSavingsEstimator.baselineStation(
stations,
fuel: selectedFuel,
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
@@ -802,10 +817,6 @@ struct ContentView: View {
fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount,
savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence.assess(
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
savingsPriceWarning: FuelPriceFreshness.comparisonWarning(
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
savingsBaselineStationName: scaffold.baseline?.name,
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
isFavourite: favouriteIDs.contains(station.id),
@@ -1532,12 +1543,6 @@ struct ContentView: View {
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
savingsPriceConfidenceForStation: { station in
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
},
savingsPriceWarningForStation: { station in
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
},
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
@@ -1559,12 +1564,6 @@ struct ContentView: View {
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
savingsPriceConfidenceForStation: { station in
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
},
savingsPriceWarningForStation: { station in
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
},
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
@@ -1580,7 +1579,7 @@ struct ContentView: View {
radius: $alertsRadius,
fuel: $alertsFuel,
alertMode: $alertsMode,
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
bestValueThresholdGBP: alertsBestValueThresholdGBP,
bestValueConfigured: fuelSavingsSettings.isConfigured,
favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel,
@@ -1608,6 +1607,7 @@ struct ContentView: View {
priceDisplayStyle: $priceDisplayStyle,
stationTapAction: $stationTapAction,
fuelSavingsSettings: $fuelSavingsSettings,
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
@@ -1791,22 +1791,47 @@ struct StationRow: View {
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
/// This row is the cheapest price in range — the caller's existing
/// `topStationID` test. Feeds the CHEAPEST badge.
let isTopResult: Bool
let isFavourite: Bool
/// Label for the cheapest-pump-price badge ("TOP" in price/distance order,
/// "CHEAPEST" when the list is ranked by net saving).
var topBadgeText: String = "TOP"
var tapAction: StationTapAction = .openMap
var fuelSavings: FuelSavingsAvailability?
/// The session fill-up figure (the one the adjustment control edits). The
/// compact banner does not print it today, but a row that carried a hard
/// zero would print "for £0" the moment it did — pass the real figure.
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
/// The user's Best Value alert threshold — the app's existing gate for
/// calling a saving worth the drive, so a row labels itself BEST VALUE by
/// exactly the rule the alerts already use. Read per row (the alert path
/// reads it per evaluation too); pass explicitly to inject a value.
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in }
/// The price's point size, scaled with Dynamic Type: the price is built from
/// an explicit size (its "p"/"/L" suffixes are sized relative to it inside
/// `priceTextAttributed`), so it needs a metric rather than a font style.
/// 22pt keeps the price the row's loudest element after the station name.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
/// The identity column's share of the ROW, for every row and both price
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
/// held per row rather than derived from the row's text — the point is that
/// the hairline, the price axis and the star cannot move as the list scrolls.
/// The price column takes the remainder, and its savings capsule scales down
/// instead of widening the column and dragging the hairline with it.
///
/// One value for both price styles: the star used to sit at the end of the
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
/// — ~20pt wider than the bare `158.7` at the same point size — did not fit on
/// a 393pt screen. With the star now under the station logo the price column
/// has that width back, so the identity column no longer has to give width up
/// in pounds & pence, and names keep the same room in both styles.
private static let leftSectionShare: CGFloat = 0.52
/// The comparison label sits beside the capsule only where the row is wide
/// enough for both (regular width). On an iPhone the mockup's canvas is
/// wider than the whole screen, so the label drops under the capsule rather
/// than starving the station name.
@Environment(\.horizontalSizeClass) private var sizeClass
private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
@@ -1832,6 +1857,26 @@ struct StationRow: View {
return abs(price - baselinePrice) <= 0.05
}
/// The row's single recommendation badge, if any. BEST VALUE (worth the
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
/// being context rather than a recommendation.
private var recommendationBadge: StationRecommendationBadge? {
StationRecommendationBadge.badge(
netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapest: isTopResult,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
/// VoiceOver: the price, then the delta the redesign no longer prints — the
/// listing shows a savings capsule in its place, so the comparison against
/// the cheapest price in range would otherwise be visible only as a colour.
private func priceSpokenLabel(_ price: Double) -> String {
guard let deltaText else { return FuelStore.priceTextSpoken(price) }
let comparison = String(localized: "vs cheapest", comment: "Price delta comparison caption")
return "\(FuelStore.priceTextSpoken(price)), \(isCheapestPrice ? deltaText : "\(deltaText) \(comparison)")"
}
private func handleTap() {
switch tapAction {
case .openMap:
@@ -1848,133 +1893,236 @@ struct StationRow: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
// LEFT — round brand logo (or generic fuel pump fallback)
Group {
if let asset = station.brandImageName {
Image(asset)
.resizable()
.scaledToFit()
.padding(5)
} else {
Image(systemName: "fuelpump.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.black.opacity(0.82))
}
}
.frame(width: 46, height: 46)
.background(Circle().fill(Color.white))
.clipShape(Circle())
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// Two sections, one hairline: station identity on the left, price and
// savings on the right. No wrapper VStack and no full-width verdict
// banner under the row — the savings capsule carries the verdict inside
// the right-hand column, and the extra travel behind it is explained in
// the detail sheet instead of being repeated on every row.
HStack(alignment: .center, spacing: 12) {
// LEFT SECTION — station identity: brand logo, then name, address and
// distance. Given a FIXED SHARE of the row (~52%, the mockup's ratio)
// rather than sizing to its content: sized to content, this column
// grew and shrank with each row's badge and savings copy, which moved
// the hairline 23pt between adjacent rows (x767 on a BEST VALUE row,
// x699 on a "costs more" row) — so the divider and the price axis
// both jumped sideways as the list scrolled. One share for every row
// puts them on a single vertical line down the whole list.
HStack(alignment: .top, spacing: 12) {
// The logo, with the favourite star directly beneath it. Moving
// the star out of the price line is what gives the price column
// its width back: sat at the end of that line it held ~28pt of
// the column, which is exactly the space the wide pounds &
// pence price needs for its own digits on a 393pt screen.
// 10pt between logo and star: at 4pt the star's glyph sat almost
// against the logo's bottom edge and read as part of it rather
// than as the control it is.
VStack(spacing: 10) {
// Round brand logo (or generic fuel pump fallback)
Group {
if let asset = station.brandImageName {
Image(asset)
.resizable()
.scaledToFit()
.padding(5)
} else {
Image(systemName: "fuelpump.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.black.opacity(0.82))
}
}
.frame(width: 46, height: 46)
.background(Circle().fill(Color.white))
.clipShape(Circle())
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// MIDDLE — name, full address, distance
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(station.name)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
ForEach(StationNotice.notices(for: station)) { notice in
Image(systemName: notice.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.accessibilityLabel(notice.title)
favouriteButton
}
// Name, full address, distance
VStack(alignment: .leading, spacing: 2) {
// The name WRAPS instead of truncating: the row is tall
// enough now, and a name cut to "Z A Service Stat…" cannot
// be told apart from the station next door. Baseline
// alignment keeps the notice symbols on the name's FIRST
// line instead of floating beside a two-line name.
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name)
.font(.headline)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
ForEach(StationNotice.notices(for: station)) { notice in
Image(systemName: notice.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.accessibilityLabel(notice.title)
}
}
// Wraps for the same reason as the name — addresses here run
// to 70 characters at the 95th percentile.
Text(StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
// ONE-WAY distance: the listing shows how far the station
// is. The return leg stays in the savings explanation, where
// the extra travel it drives is actually costed.
HStack(spacing: 3) {
Image(systemName: "location.fill")
.font(.caption2)
.accessibilityHidden(true)
Text(distance.label(unit: distanceUnit))
.font(.caption2)
.monospacedDigit()
}
.foregroundStyle(.secondary)
.accessibilityElement(children: .ignore)
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
}
}
Text(StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
Text(distance.labelWithReturn(unit: distanceUnit))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.accessibilityLabel(distance.spokenLabelWithReturn(unit: distanceUnit))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in
length * Self.leftSectionShare
}
// RIGHT — price in £ (no p) + difference vs cheapest
VStack(alignment: .trailing, spacing: 2) {
if isTopResult {
// The badge always marks the CHEAPEST PUMP PRICE (never
// "best saving"), so in Best Value order — where the
// top row is the best net saving — it may sit lower down
// the list, and says so. It sits ABOVE the price it
// annotates rather than beside the name, so it reads as
// part of the price and the name keeps its width.
Text(topBadgeText)
.font(.caption2.bold())
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(.blue.opacity(0.15)))
.foregroundStyle(.blue)
.lineLimit(1)
.fixedSize()
// 1pt hairline between the two sections. Inset top and bottom so it
// spans the row's content without touching the row edges, and
// decorative: the columns are told apart by their content, and two
// hairs per row would just be noise to VoiceOver.
//
// `secondary`, not `separator`: over a dark row fill the system
// separator measures 7/255 brighter than the fill — measurably
// invisible — while the mockup's rule sits ~112 above it. Secondary
// lands on the mockup's own value in both appearances.
Rectangle()
.fill(Color.secondary)
.frame(width: 1)
.frame(maxHeight: .infinity)
.padding(.vertical, 6)
.accessibilityHidden(true)
// RIGHT SECTION — price and savings, every element leading-aligned on
// ONE axis. The 12pt row spacing plus this column's leading padding
// puts that axis ~22pt right of the hairline. (The mockup centres the
// price against the badge so its status dot hangs into the gutter; the
// redesign was explicitly asked not to do that.)
VStack(alignment: .leading, spacing: 12) {
if let badge = recommendationBadge {
badgeCapsule(badge)
}
// Price line: the status dot and the price, leading-aligned on
// the column's one axis. The star no longer ends this line — it
// sits under the station logo — so this whole column width now
// belongs to the price, which is what lets the wide pounds &
// pence form (£1.58⁷/L) render at full size rather than being
// scaled down or truncated.
if let price = station.prices[fuel] {
HStack(spacing: 5) {
Circle()
.fill(ragColor)
.frame(width: 8, height: 8)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
}
if let deltaText {
Text(deltaText)
.font(.caption2.bold().monospaced())
.foregroundStyle(ragColor)
if !isCheapestPrice {
// "+8.0p" on its own never said what it measured
// against; the delta is against the cheapest price
// in range in EVERY sort mode.
Text("vs cheapest")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
size: priceSize, weight: .bold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
// The visible "+1.8p vs cheapest" line is gone with the
// redesign, so the comparison rides on the price's label.
.accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price))
}
if let result = fuelSavings?.result {
savingsCapsule(result)
}
}
// FAR RIGHT — favourite star
Button {
onToggleFavourite()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
}
if fuelSavings != nil {
// Full-width verdict banner under the row. Inline in the middle
// column it truncated ("Approx. £2.75 better off after tra…")
// because the
// price block squeezes that column — and the verdict is the
// point of this sort mode, so it gets its own line.
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
compact: true,
distanceUnit: distanceUnit
)
}
.frame(maxWidth: .infinity, alignment: .leading)
// The spec's ~20-24pt of air between the divider and the price
// column: the row's own 12pt spacing plus this 10pt.
.padding(.leading, 10)
}
.contentShape(Rectangle())
.onTapGesture(perform: handleTap)
.onLongPressGesture(perform: showDetails)
}
/// The favourite toggle, sitting under the station logo. Kept as its own view
/// so it stays a separate VoiceOver element — the price beside it is a single
/// grouped label — and keeps its own tap target inside the row's tap and
/// long-press gestures.
private var favouriteButton: some View {
Button {
onToggleFavourite()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
}
/// The savings capsule plus the comparison it is measured against. The
/// capsule itself is the shared verdict view, so a row, the detail sheet and
/// the compact summary cannot drift apart. The comparison label sits beside
/// the capsule only where there is width for both.
@ViewBuilder
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
let comparison = Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
// Same narrow column as the capsule above it, so the same floor:
// scaled copy, not a trimmed "vs closest st…".
.minimumScaleFactor(0.7)
if sizeClass == .regular {
HStack(spacing: 8) {
FuelSavingsCapsule(result: result)
comparison
}
} else {
VStack(alignment: .leading, spacing: 2) {
FuelSavingsCapsule(result: result)
comparison
}
}
}
/// One recommendation badge, above the price it annotates. Tinted rather
/// than the mockup's solid fill, like every other badge and capsule in the
/// app: coloured text keeps its contrast in both appearances, at every
/// Dynamic Type size, without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
Text(badge.title)
.font(.caption2.bold())
.foregroundStyle(badge.tint)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(badge.tint.opacity(0.15)))
.lineLimit(1)
.fixedSize()
}
}
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
/// they stay out of the shared model the tests cover.
private extension StationRecommendationBadge {
var title: String {
switch self {
case .bestValue:
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
case .cheapest:
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
}
}
var tint: Color { self == .bestValue ? .green : .blue }
}
// MARK: - Location manager
+2 -14
View File
@@ -21,12 +21,10 @@ struct FavouritesView: View {
/// still never touches the Settings default.
let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
/// Estimated saving for a favourite, against the closest eligible station by
/// Estimated saving for a favourite, against the closest station by
/// journey distance — the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -86,8 +84,6 @@ struct FavouritesView: View {
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
savingsPriceConfidenceForStation: @escaping (FuelStation) -> FuelPriceFreshness.ComparisonConfidence = { _ in .ineligible },
savingsPriceWarningForStation: @escaping (FuelStation) -> String? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
@@ -102,8 +98,6 @@ struct FavouritesView: View {
_fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.savingsPriceConfidenceForStation = savingsPriceConfidenceForStation
self.savingsPriceWarningForStation = savingsPriceWarningForStation
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
@@ -152,7 +146,7 @@ struct FavouritesView: View {
Section {
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
} footer: {
Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
Text(String(localized: "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
}
}
@@ -167,12 +161,8 @@ struct FavouritesView: View {
baselinePrice: cheapestPrice,
isTopResult: station.id == cheapest?.id,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
topBadgeText: "CHEAPEST",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
@@ -224,8 +214,6 @@ struct FavouritesView: View {
selectedFuel: activeFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars.
+47 -38
View File
@@ -80,9 +80,6 @@ struct FuelSavingsAdjustmentView: View {
struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount
/// Candidate/baseline price confidence for every Best Value result.
var priceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var priceWarning: String? = nil
var baselineStationName: String? = nil
var compact: Bool = false
/// Sizes the "extra return travel" caption in the user's own unit (the
@@ -120,12 +117,6 @@ struct FuelSavingsSummaryView: View {
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
@@ -166,16 +157,10 @@ struct FuelSavingsSummaryView: View {
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit()
if amount.unit == .pounds {
Text("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.")
Text("You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let warning = priceWarning ?? priceConfidence.warningText {
Text(warning)
.font(.caption2)
.foregroundStyle(.orange)
.accessibilityLabel(warning)
}
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
@@ -187,11 +172,11 @@ struct FuelSavingsSummaryView: View {
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
if result.status == .baseline {
Text("Your closest eligible station by journey distance is the baseline — every other saving is measured against it.")
Text("Your closest station by journey distance is the baseline — every other saving is measured against it.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest eligible station.")
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest station.")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent(
@@ -266,42 +251,66 @@ struct FuelSavingsSummaryView: View {
}
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
/// and leaves the plain words ("Break even", "Closest eligible
/// station") alone — one source, so visible and spoken copy cannot drift.
/// and leaves the plain words ("Break even", "Closest station") alone —
/// one source, so visible and spoken copy cannot drift.
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
result.spokenHeadline()
}
/// The colour-coded verdict capsule — ONE implementation for the listing rows
/// and the detail sheet, so the verdict reads identically in both; the sheet
/// just gets the larger font and padding.
///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
/// a Label lays its icon and title out across the row and leaves a visible
/// gap between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict
/// is in the text — so VoiceOver reads the capsule as one phrase.
/// The colour-coded verdict capsule lives in `FuelSavingsCapsule` — ONE
/// implementation for the listing rows, the detail sheet and this compact
/// summary, so the verdict reads identically everywhere; the sheet just
/// gets the larger font and padding.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
FuelSavingsCapsule(result: result, compact: compact)
}
/// Verdict colour lives in `FuelSavingsCapsule.color(for:)`.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
FuelSavingsCapsule.color(for: status)
}
private func symbol(for status: FuelSavingsStatus) -> String {
FuelSavingsCapsule.symbol(for: status)
}
}
/// The colour-coded verdict capsule shared by the station listing rows, the
/// detail sheet and the compact summary.
///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
/// a Label lays its icon and title out across the row and leaves a visible gap
/// between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict is
/// in the text — so VoiceOver reads the capsule as one phrase.
struct FuelSavingsCapsule: View {
let result: FuelSavingsResult
var compact: Bool = true
var body: some View {
HStack(spacing: compact ? 3 : 5) {
Image(systemName: symbol(for: result.status))
Image(systemName: Self.symbol(for: result.status))
.accessibilityHidden(true)
Text(result.capsuleHeadline())
.lineLimit(1)
// Belt and braces: now that the capsule owns its line it has no
// rival for width, and shrinking beats truncating at large
// Dynamic Type sizes.
.minimumScaleFactor(0.8)
// Measured on a 393pt screen: the capsule is handed ~101pt, of
// which the icon, its spacing and the 8pt padding either side
// take 32pt, leaving ~69pt for the text. "Closest station" needs
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
// which clears it at ~10pt type: scaled, never truncated.
.minimumScaleFactor(0.7)
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status))
.foregroundStyle(Self.color(for: result.status))
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 4 : 7)
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
}
/// Verdict colour: green save, red worse off, grey break even, blue
/// baseline — the same green/amber/red family the price dot already uses.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
static func color(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return .green
case .notWorthTheDrive: return .red
@@ -310,7 +319,7 @@ struct FuelSavingsSummaryView: View {
}
}
private func symbol(for status: FuelSavingsStatus) -> String {
static func symbol(for status: FuelSavingsStatus) -> String {
switch status {
case .worthTheDrive: return "checkmark.circle.fill"
case .breakEven: return "equal.circle.fill"
+2 -2
View File
@@ -14,9 +14,9 @@ struct HowBestValueSheet: View {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back.")
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
Text("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.")
.foregroundStyle(.secondary)
+20
View File
@@ -20,6 +20,12 @@ struct SettingsView: View {
@Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var stationTapAction: StationTapAction
@Binding var fuelSavingsSettings: FuelSavingsSettings
/// The Best Value gate — the net saving that makes a station worth the trip.
/// Edited here, with the fill size and MPG that PRODUCE that saving, rather
/// than in the Alerts tab: the same figure also decides which station rows
/// carry the BEST VALUE badge, so it is a fuel-savings setting first and an
/// alert setting second. Stored under the alerts key either way.
@Binding var bestValueThresholdGBP: Double
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
@@ -149,6 +155,20 @@ struct SettingsView: View {
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
.accessibilityLabel("Typical fill-up")
.accessibilityValue(fillUpText)
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
// Persisted by ContentView's single
// `.onChange(of: alertsBestValueThresholdGBP)`, which
// also refreshes the alert monitor — so this picker
// needs no handler of its own. The store snaps to the
// nearest option, so the picker and the stored value
// cannot disagree.
Text("A saving worth driving for: the net saving left after the extra travel cost. Stations above it are labelled BEST VALUE in the station list, and Best Value alerts use the same figure.")
.font(.caption)
.foregroundStyle(.secondary)
if let settingsUnitChangeNotice {
Text(settingsUnitChangeNotice)
.font(.caption2)
+3 -8
View File
@@ -21,8 +21,6 @@ struct StationDetailView: View {
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
var savingsPriceWarning: String? = nil
var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Whether this station is starred for the fuel currently being viewed, and
@@ -62,11 +60,11 @@ struct StationDetailView: View {
}
private var priceFreshness: FuelPriceFreshness.Assessment {
FuelPriceFreshness.assessment(for: station, fuel: selectedFuel)
FuelPriceFreshness.assessment(for: station)
}
/// The selected grade's current price, when present. Kept separate from
/// the freshness label so a stale price remains visible rather than being
/// the price-age row so an old price remains visible rather than being
/// silently hidden.
private var selectedFuelPriceText: String {
guard let price = station.prices[selectedFuel] else { return "—" }
@@ -215,8 +213,7 @@ struct StationDetailView: View {
}
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
LabeledContent("Price updated", value: priceFreshness.updateAgeLabel)
.monospacedDigit()
StationPriceAgeView(assessment: priceFreshness)
ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold))
@@ -257,8 +254,6 @@ struct StationDetailView: View {
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
priceConfidence: savingsPriceConfidence,
priceWarning: savingsPriceWarning,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
/// The More info sheet's price-age row.
///
/// Station-level by design: the relay publishes a single `price_updated` per
/// station (the newest grade it maps), so the copy says "Price updated" and
/// never claims the selected grade itself was updated.
///
/// Past `FuelPriceFreshness.outdatedAge` a discreet note flags the information
/// as possibly out of date. It is informational only — it gates nothing, and
/// the station's own price row above it is unaffected.
///
/// Its own view rather than inlined into `StationDetailView`: that body sits at
/// the compiler's type-checking limit, so one extra statement there fails the
/// build. One statement here, one there.
struct StationPriceAgeView: View {
let assessment: FuelPriceFreshness.Assessment
var body: some View {
VStack(alignment: .leading, spacing: 2) {
LabeledContent("Price updated", value: assessment.updateAgeLabel)
.monospacedDigit()
if let notice = assessment.outdatedNotice {
Text(notice)
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel(notice)
}
}
}
}
-13
View File
@@ -20,8 +20,6 @@ struct StationsView: View {
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
var savingsPriceWarningForStation: (FuelStation) -> String?
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
@@ -123,11 +121,6 @@ struct StationsView: View {
// for a switched-off feature.
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
if !stations.isEmpty && stations.allSatisfy({ savingsPriceConfidenceForStation($0) == .ineligible }) {
Text("Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.")
.font(.caption)
.foregroundStyle(.orange)
}
}
if isLoading && stations.isEmpty {
@@ -264,8 +257,6 @@ struct StationsView: View {
selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
journeyDiagnostics: journeyDiagnosticsForStation(station),
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
@@ -365,12 +356,8 @@ struct StationsView: View {
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
savingsPriceWarning: savingsPriceWarningForStation(station),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
+11 -11
View File
@@ -10,6 +10,8 @@
"Settings" = "Settings";
"FuelBoard" = "FuelBoard";
"TOP" = "TOP";
"BEST VALUE" = "BEST VALUE";
"CHEAPEST" = "CHEAPEST";
/* Stations tab */
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
@@ -36,10 +38,10 @@
"Remove from favourites" = "Remove from favourites";
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
"How Best Value works" = "How Best Value works";
"Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back.";
"Your closest eligible station by journey distance is the baseline — every other saving is measured against it." = "Your closest eligible station by journey distance is the baseline — every other saving is measured against it.";
"Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.";
"Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.";
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
"Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.";
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
@@ -207,6 +209,7 @@
"Best Value" = "Best Value";
"Total" = "Total";
"Minimum saving" = "Minimum saving";
"Minimum saving %@ — change in Settings, Fuel Savings." = "Minimum saving %@ — change in Settings, Fuel Savings.";
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
"When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour." = "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.";
@@ -220,17 +223,14 @@
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
"%@ — %@" = "%@ — %@";
/* Best Value calculation and price confidence */
/* Best Value calculation and station price age */
"£ 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.";
"Best Value unavailable · price confidence insufficient" = "Best Value unavailable · price confidence insufficient";
"You pay the selected budget here. The saving compares the same litres at your closest 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 station; litres differ between stations, and this is not a refund.";
"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 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";
"May be outdated" = "May be outdated";
"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.";
"Compared with your closest station." = "Compared with your closest station.";
@@ -3,28 +3,41 @@ import XCTest
@testable import FuelBoardShared
#endif
/// Station-level price age — one timestamp per station, reported in More info
/// and nowhere else.
///
/// The relay's `price_updated` is the newest `price_last_updated` across every
/// grade a station maps, so it is not evidence about the selected grade. These
/// tests pin what the app *reports*; `StationPriceAgePolicyTests` pins what the
/// app does about it (nothing).
final class FuelPriceFreshnessTests: XCTestCase {
private let now = Date(timeIntervalSince1970: 1_792_022_400)
private let now = Date(timeIntervalSince1970: 1_800_000_000)
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
)
private func at(_ offset: TimeInterval) -> TimeInterval {
now.addingTimeInterval(offset).timeIntervalSince1970
}
func testPerGradeTimestampIsDecodedAndEligibleWhenFresh() throws {
private func station(priceUpdated: TimeInterval?) -> FuelStation {
FuelStation(id: "freshness-station", name: "Freshness Station", brand: "Test",
address: "1 High Street", postcode: "BD1 1AA", lat: 53.79, lng: -1.75,
prices: [.e10: 169.9], priceUpdated: priceUpdated)
}
// MARK: - One figure per station, whatever fuel is on screen
func testOneAgeIsReportedForEveryFuelAStationSells() {
let sold = FuelStation(id: "multi", name: "Multi", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.e5: 165.9, .e10: 169.9, .diesel: 129.9],
priceUpdated: at(-28 * 60 * 60))
for fuel in FuelType.allCases {
XCTAssertEqual(FuelPriceFreshness.assessment(for: sold, now: now).updateAgeLabel,
"28h ago",
"\(fuel.rawValue): every grade reads the same station timestamp")
}
}
func testStationTimestampIsDecodedAndNoPerGradeTimestampIsInvented() throws {
let json = """
{
"stations": [{
@@ -36,207 +49,122 @@ final class FuelPriceFreshnessTests: XCTestCase {
"lat": 53.78,
"lng": -1.75,
"prices": {"E10": 169.9, "DIESEL": 129.9},
"price_updated": "2026-09-25T07:45:52Z",
"price_updated": "2026-10-14T07:45:52Z",
"price_updated_by_grade": {
"E10": "2026-10-14T07:45:52Z",
"DIESEL": "2026-10-14T07:45:52Z"
"E10": "2026-01-01T00:00:00Z",
"DIESEL": "2026-01-01T00:00:00Z"
}
}]
}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
let decoded = try XCTUnwrap(stations.first)
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).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(decoded.priceUpdated,
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970,
"the station-level field is what the app records")
// A per-grade map in the payload is ignored: the app has one age, and it
// is the station's.
let assessment = FuelPriceFreshness.assessment(for: decoded, now: now)
XCTAssertEqual(assessment.status, .outdated,
"the station field is used, not the 2026-01-01 grade entries")
}
// MARK: - Reading the age
func testFreshAgeReadsInMinutesThenHours() {
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-59 * 60), now: now).updateAgeLabel,
"59m ago")
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-60 * 60), now: now).updateAgeLabel,
"1h ago")
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-47 * 60 * 60), now: now).updateAgeLabel,
"47h ago")
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-48 * 60 * 60), now: now).updateAgeLabel,
"2d ago")
}
func testFreshInformationIsNeverFlagged() {
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: at(-28 * 60 * 60)), now: now)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertTrue(assessment.isEligibleForBestValue)
XCTAssertFalse(assessment.isOutdated)
XCTAssertNil(assessment.outdatedNotice)
XCTAssertEqual(assessment.age, 28 * 60 * 60)
}
func testStationWideTimestampIsNotPresentedAsGradeSpecificOrEligible() {
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge / 2).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(
for: station(priceUpdated: updated), fuel: .diesel, now: now
)
// MARK: - The thirty-day display threshold
XCTAssertEqual(assessment.source, .stationWide)
func testInformationExactlyThirtyDaysOldIsNotYetFlagged() {
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge), now: now)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertEqual(assessment.confidence, .provisional)
XCTAssertTrue(assessment.recommendationEligibility == .eligible)
XCTAssertTrue(assessment.isEligibleForBestValue)
XCTAssertTrue(assessment.warning)
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
XCTAssertNil(assessment.outdatedNotice)
}
func testMoreInfoReportsOneStationPriceAge() {
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
XCTAssertEqual(assessment.updateAgeLabel, "28h ago")
XCTAssertEqual(assessment.source, .stationWide)
XCTAssertEqual(assessment.status, .fresh)
func testInformationJustPastThirtyDaysIsFlaggedButStillReportsItsAge() {
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge - 1), now: now)
XCTAssertEqual(assessment.status, .outdated)
XCTAssertTrue(assessment.isOutdated)
XCTAssertEqual(assessment.outdatedNotice, "May be outdated")
XCTAssertEqual(assessment.updateAgeLabel, "30d ago")
}
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)
XCTAssertEqual(assessment.updateAgeLabel, "10d ago")
XCTAssertEqual(assessment.source, .gradeSpecific)
func testLongOldInformationIsFlaggedWithItsDayCount() {
// The oldest figure the mirror carries for a real station is ~250 days.
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-250 * 86_400), now: now)
XCTAssertEqual(assessment.status, .outdated)
XCTAssertEqual(assessment.updateAgeLabel, "250d ago")
XCTAssertEqual(assessment.outdatedNotice, "May be outdated")
}
func testMissingUpdateTimeReadsNotProvided() {
let assessment = FuelPriceFreshness.assessment(for: station(), fuel: .e10, now: now)
// MARK: - Missing, malformed, future and absurd timestamps
func testMissingTimestampReadsNotProvidedAndIsNeverFlagged() {
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: nil), now: now)
XCTAssertEqual(assessment.status, .unknown)
XCTAssertNil(assessment.age)
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
XCTAssertFalse(assessment.isOutdated)
XCTAssertNil(assessment.outdatedNotice)
}
func testProvisionalSavingAddsNoSecondWarningLine() {
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
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 testUnparseableSourceTextIsDroppedRatherThanGuessed() throws {
XCTAssertNil(FuelPriceTimestamp.parse("not-a-date"))
XCTAssertNil(FuelPriceTimestamp.parse(""))
XCTAssertNil(FuelPriceTimestamp.parse(" "))
let json = """
{"stations": [{"id": "bad", "name": "Bad", "brand": "", "address": "", "postcode": "",
"lat": 53, "lng": -1, "prices": {"E10": 169.9}, "price_updated": "2026-99-99T99:99:99Z"}]}
"""
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).first)
XCTAssertNil(decoded.priceUpdated)
XCTAssertEqual(FuelPriceFreshness.assessment(for: decoded, now: now).updateAgeLabel, "Not provided")
}
func testIneligibleComparisonStillExplainsItself() {
let old = now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
let stale = station(priceUpdatedByFuel: [.diesel: old])
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
station: stale, fuel: .diesel, baseline: stale, now: now),
"Price age unavailable or too old — not a Best Value recommendation.")
func testSmallClockSkewReadsAsJustNowNotAsAFutureDate() {
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(60), now: now)
XCTAssertEqual(assessment.status, .fresh)
XCTAssertEqual(assessment.age, 0)
XCTAssertEqual(assessment.updateAgeLabel, "0m ago")
}
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 testTimestampBeyondTheSkewToleranceIsUnusableNotOptimistic() {
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(86_400), now: now)
XCTAssertEqual(assessment.status, .invalid)
XCTAssertNil(assessment.age)
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
XCTAssertNil(assessment.outdatedNotice)
}
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 testNonFiniteAndAbsurdEpochsAreUnusableAndCannotTrap() {
let values: [TimeInterval] = [.nan, .infinity, -.infinity,
.greatestFiniteMagnitude, -.greatestFiniteMagnitude]
for value in values {
let assessment = FuelPriceFreshness.assessment(priceUpdated: value, now: now)
XCTAssertEqual(assessment.status, .invalid, "\(value)")
XCTAssertNil(assessment.age, "\(value)")
XCTAssertEqual(assessment.updateAgeLabel, "Not provided", "\(value)")
XCTAssertFalse(assessment.isOutdated, "\(value)")
}
}
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 {
@@ -533,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
)?.result
XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest eligible station")
XCTAssertEqual(result?.headline(), "Closest station")
}
}
@@ -41,6 +41,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
FuelSavingsJourney(miles: miles, basis: .straightLine)
}
/// Fuel types are never mixed: a station that does not sell the selected
/// fuel cannot be its baseline, and cannot be compared with it.
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 199.9], priceUpdated: nil)
@@ -55,6 +57,26 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
}
/// Ranking must still work when the list being ordered is filtered tighter
/// than the pool the baseline was chosen from.
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 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.
@@ -99,24 +121,6 @@ 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
@@ -191,56 +195,6 @@ 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)
@@ -432,8 +386,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
)
XCTAssertEqual(availability?.result?.status, .baseline)
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
}
@@ -485,7 +439,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// The exact strings the brief asks for.
func testHeadlineWordingMatchesTheBrief() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
let saving = FuelSavingsResult(fillLitres: 34.9,
@@ -509,87 +463,10 @@ 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 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 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 ranked = FuelSavingsEstimator.ranked(
[baseline, cheapButFar, pricierButNear],
@@ -603,7 +480,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",
@@ -613,18 +490,18 @@ final class FuelSavingsEstimateTests: XCTestCase {
// MARK: Row copy
func testCompactComparisonLabelUsesClosestEligibleStationWording() {
func testCompactComparisonLabelUsesGenericNearestStationWording() {
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
"vs closest eligible station"
"vs closest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
"vs closest eligible station"
"vs closest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .breakEven),
"vs closest eligible station"
"vs closest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .baseline),
@@ -653,7 +530,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
}
/// The baseline capsule has to say what it IS: it previously repeated the
/// status word above it ("Closest eligible station" twice) and never explained that
/// status word above it ("Closest station" twice) and never explained that
/// the savings are measured against it.
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
let baseline = FuelSavingsResult(
@@ -664,7 +541,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0,
status: .baseline
)
XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
let saving = FuelSavingsResult(
fillLitres: 30,
@@ -110,11 +110,10 @@ 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, 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),
station("baseline", lat: 0, lng: 0, price: 160),
station("small", lat: 0, lng: 0, price: 154),
station("best", lat: 0, lng: 0, price: 148),
]
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
@@ -124,8 +123,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
settings: settings,
amount: settings.preferredAmount,
minimumNetSavingGBP: 1.0,
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } },
now: now
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
)
XCTAssertEqual(candidate?.station.id, "best")
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
@@ -139,10 +137,8 @@ final class FuelSavingsCalculatorTests: XCTestCase {
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
}
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)
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)
}
}
@@ -163,3 +159,101 @@ final class FuelSavingsSettingsTests: XCTestCase {
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
}
}
/// The row's single recommendation badge. Presentation only, but the rules are
/// worth pinning: they decide which of two true statements a row makes, and
/// "no badge" is itself an answer.
final class StationRecommendationBadgeTests: XCTestCase {
private let threshold = 1.0
func testSavingAtTheThresholdEarnsBestValue() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 1.0, isCheapest: false,
bestValueThresholdGBP: threshold),
.bestValue
)
}
func testSavingBelowTheThresholdDoesNot() {
XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: false,
bestValueThresholdGBP: threshold)
)
}
func testCheapestWithoutAWorthwhileSavingStillSaysCheapest() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: true,
bestValueThresholdGBP: threshold),
.cheapest
)
}
/// The cheapest station keeps its badge even when the drive costs more than
/// the saving buys — it IS the cheapest price; the capsule underneath is
/// what says the trip is not worth it.
func testCheapestKeepsItsBadgeWhenTheDriveCostsMoreThanItSaves() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: -14.30, isCheapest: true,
bestValueThresholdGBP: threshold),
.cheapest
)
}
/// One badge per row: the stronger statement wins.
func testBestValueOutranksCheapestWhenBothApply() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 3.15, isCheapest: true,
bestValueThresholdGBP: threshold),
.bestValue
)
}
/// A near-by station with no saving to speak of is context, not a
/// recommendation — the mockup gives NEAREST no capsule, and nothing else
/// here may invent one.
func testNoSavingAndNotCheapestMeansNoBadge() {
XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
bestValueThresholdGBP: threshold)
)
XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false,
bestValueThresholdGBP: threshold)
)
}
func testUnavailableSavingsLeaveOnlyTheCheapestBadge() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true,
bestValueThresholdGBP: threshold),
.cheapest
)
XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: false,
bestValueThresholdGBP: threshold)
)
}
/// A rubbish figure must not become a recommendation.
func testNonFiniteFiguresProduceNoBadge() {
XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: .nan, isCheapest: false,
bestValueThresholdGBP: threshold)
)
XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: 5.0, isCheapest: false,
bestValueThresholdGBP: .nan)
)
}
/// The threshold is the user's alert setting: set it to zero and any saving
/// that is not a loss counts.
func testZeroThresholdAwardsBestValueToAnyNonNegativeSaving() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 0, isCheapest: false,
bestValueThresholdGBP: 0),
.bestValue
)
}
}
@@ -0,0 +1,33 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
/// The Best Value gate's option list.
///
/// This one array is the figure behind two user-facing things — the BEST VALUE
/// badge on a station row and the proximity alert — so its granularity is a
/// setting, not an implementation detail. It was four coarse values (50p, £1,
/// £2, £5); these tests pin the 50p step and the £5 ceiling so a later edit
/// cannot quietly coarsen it back.
final class FuelStoreBestValueThresholdTests: XCTestCase {
func testOptionsStepByFiftyPenceUpToFivePounds() {
let options = FuelStore.bestValueAlertThresholdOptionsGBP
XCTAssertEqual(options, [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0])
XCTAssertEqual(options.count, 10)
XCTAssertEqual(options.first, 0.5, "the floor is the cheapest worth-the-drive saving offered")
XCTAssertEqual(options.last, 5.0, "£5 is the ceiling")
}
/// The store writes the threshold as a string and, on load, rejects any
/// value that is not one of these options — falling back to the £1 default.
/// So an option that does not survive `String` → `Double` exactly would
/// silently discard the user's choice. Multiples of 0.5 are exactly
/// representable in binary, and this keeps them that way.
func testEveryOptionSurvivesTheStoresStringRoundTrip() {
for option in FuelStore.bestValueAlertThresholdOptionsGBP {
XCTAssertEqual(Double(String(option)), option,
"£\(option) does not round-trip through the store's string encoding")
}
}
}
@@ -0,0 +1,219 @@
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
/// Regression: a station's price age never removes it from Best Value.
///
/// The relay publishes one `price_updated` per station — the newest
/// `price_last_updated` across the grades it maps — which cannot vouch for the
/// selected grade. It is therefore display-only: Best Value, Cheapest and
/// Closest rank stations on their prices and distances exactly as before,
/// whether the timestamp is minutes, months or absent.
final class StationPriceAgePolicyTests: XCTestCase {
private let now = Date(timeIntervalSince1970: 1_800_000_000)
private let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
private func station(_ id: String,
price: Double,
fuel: FuelType = .e10,
ageDays: Double?,
lat: Double = 53,
lng: Double = -1) -> FuelStation {
FuelStation(
id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng,
prices: [fuel: price],
priceUpdated: ageDays.map { now.addingTimeInterval(-$0 * 86_400).timeIntervalSince1970 }
)
}
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
FuelSavingsJourney(miles: miles, basis: .straightLine)
}
private func distanceProvider(_ miles: [String: Double]) -> (FuelStation) -> FuelSavingsJourney? {
{ station in miles[station.id].map { self.straightLine($0) } }
}
// MARK: - The baseline is the closest seller, whatever its age
func testClosestSellerIsTheBaselineEvenWhenItsTimestampIsAncient() {
let ancientClosest = station("ancient-closest", price: 199.9, ageDays: 250)
let freshFurther = station("fresh-further", price: 170.9, ageDays: 1)
let journeys = distanceProvider(["ancient-closest": 0.8, "fresh-further": 3.0])
XCTAssertEqual(
FuelSavingsEstimator.baselineStation([ancientClosest, freshFurther], fuel: .e10,
journeyMiles: journeys)?.id,
"ancient-closest",
"the baseline is the closest station selling the selected fuel — age is not a criterion"
)
}
func testBaselineStillRequiresTheSelectedFuelAndAUsableJourney() {
let wrongFuel = station("wrong-fuel", price: 140, fuel: .diesel, ageDays: 1)
let unusableJourney = station("bad-journey", price: 141, ageDays: 1)
let sellsFuel = station("sells-fuel", price: 145, ageDays: 1)
let journeys = distanceProvider(["wrong-fuel": 0.1, "sells-fuel": 2.0]) // bad-journey has none
XCTAssertEqual(
FuelSavingsEstimator.baselineStation([wrongFuel, unusableJourney, sellsFuel], fuel: .e10,
journeyMiles: journeys)?.id,
"sells-fuel"
)
}
// MARK: - Old, missing and invalid timestamps still rank
func testOldPriceTimestampsRankByNetSavingForEveryFuel() {
for fuel in FuelType.allCases {
let base = station("base", price: 199.9, fuel: fuel, ageDays: 250)
let cheap = station("cheap", price: 129.9, fuel: fuel, ageDays: 250)
let dear = station("dear", price: 198.9, fuel: fuel, ageDays: 250)
let journeys = distanceProvider(["base": 0.8, "cheap": 1.2, "dear": 1.0])
let ranked = FuelSavingsEstimator.ranked([dear, cheap, base], fuel: fuel, baseline: base,
settings: settings, amount: settings.preferredAmount,
journeyMiles: journeys)
XCTAssertEqual(ranked.map(\.id), ["cheap", "dear", "base"], fuel.rawValue)
let saving = FuelSavingsEstimator.availability(
station: cheap, fuel: fuel, baseline: base, settings: settings,
amount: settings.preferredAmount, journey: straightLine(1.2), baselineJourney: straightLine(0.8)
)?.result
XCTAssertGreaterThan(saving?.netSavingGBP ?? -1, 0,
"\(fuel.rawValue): a 250-day-old price still produces a saving")
}
}
func testNetSavingStillOutranksPumpPriceWhenTimestampsAreOld() {
// Net saving decides the order: the cheapest station is 30 miles away,
// so a pricier one a mile from home wins. Nothing here consults the age.
let base = station("base", price: 171.9, ageDays: 120)
let cheapFar = station("cheap-far", price: 169.9, ageDays: 250)
let pricierNear = station("pricier-near", price: 170.9, ageDays: 200)
let journeys = distanceProvider(["base": 0.8, "cheap-far": 30, "pricier-near": 1.2])
let ranked = FuelSavingsEstimator.ranked([cheapFar, pricierNear, base], fuel: .e10, baseline: base,
settings: settings, amount: settings.preferredAmount,
journeyMiles: journeys)
XCTAssertEqual(ranked.map(\.id), ["pricier-near", "base", "cheap-far"])
XCTAssertEqual(ranked.first?.prices[.e10], 170.9, "the winner is chosen on net saving, not pump price")
}
func testMissingFutureAndAbsurdTimestampsDoNotRemoveStations() {
let invalid = FuelStation(id: "absurd", name: "Absurd", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.e10: 150.9],
priceUpdated: -Double.greatestFiniteMagnitude)
let pool = [station("base", price: 199.9, ageDays: 250),
station("cheap", price: 129.9, ageDays: 100),
FuelStation(id: "future", name: "Future", brand: "", address: "", postcode: "",
lat: 53, lng: -1, prices: [.e10: 160.9],
priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970),
station("undated", price: 170.9, ageDays: nil),
invalid]
let journeys = distanceProvider(["base": 0.8, "cheap": 1.2, "future": 1.6,
"undated": 2.0, "absurd": 2.8])
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0],
settings: settings, amount: settings.preferredAmount,
journeyMiles: journeys)
XCTAssertEqual(Set(ranked.map(\.id)), Set(pool.map(\.id)),
"every station stays in the pool: no timestamp can filter a row out")
XCTAssertEqual(ranked.first?.id, "cheap", "the cheapest station still wins")
}
func testEveryPricedStationKeepsAResultWhateverItsTimestamp() {
let pool = [
station("base", price: 199.9, ageDays: 1),
station("fresh", price: 180.9, ageDays: 1),
station("old", price: 175.9, ageDays: 40),
station("ancient", price: 170.9, ageDays: 250),
station("undated", price: 165.9, ageDays: nil)
]
let journeys = distanceProvider(["base": 0.8, "fresh": 1.0, "old": 1.4, "ancient": 1.8, "undated": 2.2])
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0],
settings: settings, amount: settings.preferredAmount,
journeyMiles: journeys)
XCTAssertEqual(ranked.map(\.id).sorted(), ["ancient", "base", "fresh", "old", "undated"])
for station in pool {
let result = FuelSavingsEstimator.availability(
station: station, fuel: .e10, baseline: pool[0], settings: settings,
amount: settings.preferredAmount,
journey: journeys(station), baselineJourney: straightLine(0.8)
)?.result
XCTAssertNotNil(result, "\(station.id) still shows a Best Value row")
}
}
func testCheapestAndClosestOrdersAreUnaffectedByTimestampAge() {
// Cheapest sorts on pump price, Closest on distance — the only inputs are
// prices and distances, so no age combination changes either order.
let specs: [(String, Double, Double)] = [("a", 199.9, 0.8), ("b", 129.9, 30), ("c", 170.9, 1.2)]
for ages in [[1.0, 1.0, 1.0], [250.0, 250.0, 250.0]] {
let pool = zip(specs, ages).map { spec, age in
station(spec.0, price: spec.1, ageDays: age)
}
let journeys = distanceProvider(Dictionary(uniqueKeysWithValues:
zip(specs.map(\.0), specs.map(\.2))))
let cheapestFirst = pool.sorted { lhs, rhs in
let left = lhs.prices[.e10] ?? .infinity
let right = rhs.prices[.e10] ?? .infinity
if left != right { return left < right }
return (journeys(lhs)?.miles ?? .infinity) < (journeys(rhs)?.miles ?? .infinity)
}
XCTAssertEqual(cheapestFirst.map(\.id), ["b", "c", "a"])
let closestFirst = pool.sorted { lhs, rhs in
let left = journeys(lhs)?.miles ?? .infinity
let right = journeys(rhs)?.miles ?? .infinity
if left != right { return left < right }
return (lhs.prices[.e10] ?? .infinity) < (rhs.prices[.e10] ?? .infinity)
}
XCTAssertEqual(closestFirst.map(\.id), ["a", "c", "b"])
}
}
// MARK: - The bundled / offline dataset
func testBundledDatasetWithMonthOldTimestampsStillRecommendsBestValue() {
// The bundled dump ships one pull of the whole UK feed, so every station
// arrives with the same months-old timestamp. Best Value must still
// recommend — it used to read "unavailable" for the entire offline list.
var specs: [(String, Double, Double, Double?)] = [("base", 171.9, 0.8, 90),
("pricier-near", 170.9, 1.2, 200),
("cheap-far", 149.9, 40, 250),
("dear-near", 179.9, 1.6, nil),
("old-undated", 160.9, 5.0, 240)]
let pool = specs.map { station($0.0, price: $0.1, ageDays: $0.3) }
let journeys = distanceProvider(Dictionary(uniqueKeysWithValues:
specs.map { ($0.0, $0.2) }))
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0],
settings: settings, amount: settings.preferredAmount,
journeyMiles: journeys)
let savings = pool.map { station in
(station.id, FuelSavingsEstimator.availability(
station: station, fuel: .e10, baseline: pool[0], settings: settings,
amount: settings.preferredAmount, journey: journeys(station), baselineJourney: straightLine(0.8)
)?.result?.netSavingGBP ?? -Double.infinity)
}
let expectedWinner = savings.max { $0.1 < $1.1 }?.0
XCTAssertEqual(ranked.first?.id, expectedWinner,
"the offline list is still ordered by net saving")
XCTAssertNotNil(ranked.first?.prices[.e10], "the top station has a price to show")
XCTAssertGreaterThan(savings.first { $0.0 == expectedWinner }?.1 ?? -1, 0,
"and that recommendation carries a real saving")
}
}
+2 -14
View File
@@ -203,8 +203,6 @@ 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 }
@@ -308,8 +306,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
break
}
let amount = settings.preferredAmount
let baseline = FuelSavingsEstimator.bestValueBaselineStation(filtered, fuel: fuel,
journeyMiles: journey)
let baseline = FuelSavingsEstimator.baselineStation(filtered, fuel: fuel, journeyMiles: journey)
let ranked = FuelSavingsEstimator.ranked(
filtered,
fuel: fuel,
@@ -320,13 +317,6 @@ 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,
@@ -358,9 +348,7 @@ 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,
bestValuePriceConfidence: bestValuePriceConfidence,
bestValuePriceWarning: bestValuePriceWarning
unit: unit, bestValueResult: bestValueResult
)
), originSource)
}
+53 -130
View File
@@ -12,31 +12,48 @@ enum FuelPriceTimestamp {
}
}
/// Station-level price freshness — informational only.
///
/// The relay publishes ONE `price_updated` per station, derived from the
/// GOV.UK Fuel Finder field `price_last_updated` as the MAXIMUM across the
/// fuel grades it maps. It is therefore a station-level figure and never a
/// verified timestamp for the grade the user has selected, so every label
/// describes the station's price information rather than one fuel.
///
/// Age excludes nothing: a station with a valid price is ranked on that price
/// however old its timestamp is. `outdatedAge` only decides whether More info
/// adds a discreet "May be outdated" note.
enum FuelPriceFreshness {
static let warningAge: TimeInterval = 7 * 24 * 60 * 60
static let staleAge: TimeInterval = 30 * 24 * 60 * 60
/// Price information older than this is flagged as possibly out of date.
/// A display threshold only — it gates nothing.
static let outdatedAge: TimeInterval = 30 * 24 * 60 * 60
/// Tolerated clock skew. A timestamp further into the future than this is
/// unusable (a feed error), never silently treated as "just now".
static let futureTolerance: TimeInterval = 5 * 60
/// Ages beyond this are unusable rather than displayed. It also keeps the
/// day count inside `Int`, so an absurd epoch cannot trap.
static let maximumAge: TimeInterval = 365 * 24 * 60 * 60 * 10
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 }
enum Status: Equatable {
/// Readable timestamp within `outdatedAge`.
case fresh
/// Readable timestamp older than `outdatedAge`.
case outdated
/// The record carries no timestamp.
case unknown
/// A timestamp is present but unreadable, or implausibly in the future.
case invalid
}
struct Assessment: Equatable {
let source: Source
let status: Status
/// Age in seconds. nil exactly when the timestamp is unusable, so no
/// caller can print an age that was never measured.
let age: TimeInterval?
let confidence: Confidence
let recommendationEligibility: RecommendationEligibility
let warning: Bool
var isEligibleForBestValue: Bool {
recommendationEligibility == .eligible
}
var isOutdated: Bool { status == .outdated }
/// 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
/// The one age More info reports. Missing or unreadable timestamps read
/// "Not provided" rather than implying freshness.
var updateAgeLabel: String {
guard let age else {
@@ -46,19 +63,16 @@ enum FuelPriceFreshness {
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"
}
/// Discreet flag for price information past `outdatedAge`. nil while the
/// information is fresh, and nil when there is nothing to measure.
var outdatedNotice: String? {
guard status == .outdated else { return nil }
return String(localized: "May be outdated",
comment: "Station price information older than 30 days")
}
private static func ageText(_ age: TimeInterval) -> String {
/// Compact age: minutes under an hour, hours under two days, then days.
static func ageText(_ age: TimeInterval) -> String {
let minutes = max(0, Int(age / 60))
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
@@ -67,112 +81,21 @@ enum FuelPriceFreshness {
}
}
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)
/// The station's own timestamp. Deliberately takes no fuel: there is one
/// figure per station and every grade reads the same one, so the policy
/// cannot differ between fuels.
static func assessment(for station: FuelStation, now: Date = Date()) -> Assessment {
assessment(priceUpdated: station.priceUpdated, 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)
}
/// 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, .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? {
comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now).warningText
}
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)
static func assessment(priceUpdated: TimeInterval?, now: Date = Date()) -> Assessment {
guard let priceUpdated else { return Assessment(status: .unknown, age: nil) }
guard priceUpdated.isFinite else { return Assessment(status: .invalid, age: nil) }
let age = now.timeIntervalSince1970 - priceUpdated
guard age.isFinite, age >= -futureTolerance, age <= maximumAge else {
return Assessment(status: .invalid, age: nil)
}
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)
return Assessment(status: boundedAge > outdatedAge ? .outdated : .fresh, age: boundedAge)
}
}
+1 -28
View File
@@ -38,9 +38,7 @@ enum FuelPriceProvider {
lat: relay.lat ?? 0,
lng: relay.lng ?? 0,
prices: relay.allPrices,
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970,
priceUpdatedByFuel: relay.priceUpdatedByFuel,
priceUpdatedByFuelRaw: relay.priceUpdatedByFuelRaw
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970
)
}
}
@@ -118,41 +116,16 @@ private struct MirrorResponse: Codable {
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:
/// any grade outside the 50–500 pence/litre band is dropped, so a mirror
+2 -20
View File
@@ -35,11 +35,6 @@ 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.
@@ -97,16 +92,10 @@ struct FuelPriceWidgetContent: View {
Text(result.headline(compact: true))
.font(.title3.weight(.bold))
.lineLimit(1)
Text(result.status == .baseline ? "your baseline" : "vs closest eligible station")
Text(result.status == .baseline ? "your baseline" : "vs closest 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)
@@ -116,11 +105,6 @@ struct FuelPriceWidgetContent: View {
.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)
@@ -163,9 +147,7 @@ struct FuelPriceWidgetContent: View {
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(entry.sort == .worthTheDrive && entry.bestValuePriceConfidence == .ineligible
? "price order · Best Value unavailable"
: entry.isFavourites ? "your order"
Text(entry.isFavourites ? "your order"
: (entry.locationSource == "none" ? "by price" : "near you"))
.font(.caption2)
.foregroundStyle(.secondary)
+49 -62
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 eligible station", never "Break even".
/// measured against. Shown as "Closest 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 eligible station"
status == .baseline ? "your baseline" : "vs closest 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 eligible station"
static let baselineHeadline = "Closest 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 eligible station".
/// travel", "Break even", "Closest station".
///
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
@@ -262,6 +262,29 @@ struct FuelSavingsRankedStation: Equatable {
let pricePencePerLitre: Double
}
/// Which recommendation badge a station listing row shows, if any.
///
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
/// app's existing Best Value gate (the user's alert threshold, the same figure
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
/// reuses the caller's existing "cheapest in range" flag (the station behind
/// `topStationID`). One badge per row: a station that is both the cheapest and
/// worth the drive shows BEST VALUE, the stronger of the two.
enum StationRecommendationBadge: Equatable {
case bestValue
case cheapest
static func badge(netSavingGBP: Double?,
isCheapest: Bool,
bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
netSavingGBP >= bestValueThresholdGBP {
return .bestValue
}
return isCheapest ? .cheapest : nil
}
}
struct BestValueAlertCandidate: Equatable {
let station: FuelStation
let result: FuelSavingsResult
@@ -452,24 +475,21 @@ enum FuelSavingsCalculator {
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
minimumNetSavingGBP: Double,
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()
journeyMiles: (FuelStation) -> FuelSavingsJourney?
) -> 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.bestValueBaselineStation(sellers, fuel: fuel,
journeyMiles: journeyMiles, now: now)
guard let baseline else { return nil }
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
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)
@@ -513,8 +533,12 @@ enum FuelSavingsCalculator {
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
/// rules are unit tested directly.
enum FuelSavingsEstimator {
/// Closest priced station, irrespective of evidence age. Used for ordinary
/// Closest sorting, which must continue to include older prices.
/// 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.
static func baselineStation(_ stations: [FuelStation],
fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
@@ -532,17 +556,6 @@ 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,
@@ -584,26 +597,20 @@ enum FuelSavingsEstimator {
}
}
/// 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.
/// 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.
static func ranked(_ stations: [FuelStation],
fuel: FuelType,
baseline: FuelStation?,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
now: Date = Date()) -> [FuelStation] {
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
var journeys: [String: FuelSavingsJourney] = [:]
journeys.reserveCapacity(stations.count)
for station in stations {
@@ -611,21 +618,11 @@ enum FuelSavingsEstimator {
journeys[station.id] = journey
}
}
// The baseline is normally in the pool, so this is the same lookup it has
// always been; asking for its journey directly covers the case where the
// list being ranked is filtered tighter than the baseline was chosen from,
// which would otherwise mark every row unavailable.
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,
@@ -648,17 +645,7 @@ 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 }
+14 -24
View File
@@ -273,20 +273,13 @@ struct FuelStation: Identifiable, Codable, Equatable {
let lng: Double
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
let prices: [FuelType: Double]
/// Updated timestamp (seconds since 1970) — nil for sample data.
/// Station-level price timestamp (seconds since 1970): the relay's
/// `price_updated`, i.e. the newest price submission across the grades it
/// maps — never a per-grade time. nil when the source omits it (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) {
priceUpdated: TimeInterval?) {
self.id = id
self.name = name
self.brand = brand
@@ -296,18 +289,12 @@ struct FuelStation: Identifiable, Codable, Equatable {
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 {
@@ -322,10 +309,6 @@ struct FuelStation: Identifiable, Codable, Equatable {
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 {
@@ -339,8 +322,6 @@ struct FuelStation: Identifiable, Codable, Equatable {
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.
@@ -730,7 +711,16 @@ struct FuelStore {
saveString(mode.rawValue, service: alertsModeKey)
}
static let bestValueAlertThresholdOptionsGBP: [Double] = [0.5, 1.0, 2.0, 5.0]
/// The Best Value gate's options, in 50p steps from 50p to £5. The gate is
/// the same figure for the listing's BEST VALUE badges and for the alert, so
/// its granularity decides how finely the two can be tuned: the old four
/// values (50p, £1, £2, £5) made £2.50 and £3 unreachable, and a whole extra
/// detour's worth of saving sat between £1 and £2. Multiples of 0.5 are
/// exactly representable, so the stored value and the picker's tags cannot
/// drift apart.
static let bestValueAlertThresholdOptionsGBP: [Double] = [
0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0
]
static func loadAlertsBestValueThresholdGBP() -> Double {
if let raw = loadString(service: alertsBestValueThresholdKey),