Files
fuelboard/Shared/FuelSavings.swift
T
FuelBoard Contributor 244f9be640 Refine the station listing: muted verdict capsules, three-line names
The listing's verdict capsule now carries white text on muted semantic
fills instead of the neutral secondary tint: Saves #45825E, Costs
#A9635B, Break even #707070, Closest #58789D.

Fixed tones, not translucent tints, because white text on a 15% tint
over a white row measures ~1.8:1. Each tone sits at a relative
luminance of ~0.18 — the band where white clears 4.5:1 AND the pill
still separates ~3:1 from both a white row and the dark card fill — so
one value each works in light and dark without a dynamic colour. The
saturated palette colours cannot carry white text at all: systemGreen
measures 2.2:1, systemRed 3.6:1.

"Save" becomes "Saves", matching the detail sheet and reading as a
statement of the outcome; amounts, symbols, padding, radius and the
shape-follows-line-count rule are unchanged. Saves and Costs share an
84pt minimum width so their trailing edges line up down the list.

The price and its capsule are now one vertically grouped element (5pt),
with the badge keeping the column's full 12pt above, so the row no
longer reads as three equal lines.

Station names cap at three lines with a tail ellipsis: unbounded wrap
let one very long forecourt name set the height of the whole row. 59 of
the 8,073 names in the dump exceed three lines and now ellipsise; the
full name is always in Station Details. Addresses still wrap freely.

The baseline pill moves onto ListingFuelSavingsCapsule so the listing
shows the same soft blue and white text as its siblings, leaving
FuelSavingsCapsule to the detail sheet, which it now alone uses.
Badges keep their tinted styling: they annotate a price rather than
report an outcome.

Verified: 261/261 swift test; Debug simulator and Release device
builds; all four fills and their white text measured from simulator
pixels in both appearances, and the detail sheet's .blue absent from
the listing entirely.
2026-10-18 19:45:19 +01:00

794 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
enum FuelSavingsFillUnit: String, Codable, CaseIterable, Identifiable {
case pounds
case litres
var id: String { rawValue }
var displayName: String {
switch self {
case .pounds: return "£"
case .litres: return "Litres"
}
}
}
struct FuelSavingsFillAmount: Codable, Equatable {
var value: Double
var unit: FuelSavingsFillUnit
var isValid: Bool {
value.isFinite && value > 0 && value <= FuelSavingsCalculator.maximumFillAmount(for: unit)
}
}
struct FuelSavingsSettings: Codable, Equatable {
var enabled: Bool
var mpg: Double
var preferredAmount: FuelSavingsFillAmount
static let defaultMPG: Double = 45
static let defaultPreferredAmount = FuelSavingsFillAmount(value: 40, unit: .pounds)
static var defaults: FuelSavingsSettings {
FuelSavingsSettings(enabled: false, mpg: defaultMPG, preferredAmount: defaultPreferredAmount)
}
var isConfigured: Bool {
enabled && FuelSavingsCalculator.isValidMPG(mpg) && preferredAmount.isValid
}
}
enum FuelSavingsStatus: String, Codable, Equatable {
case worthTheDrive
case breakEven
case notWorthTheDrive
/// The comparison baseline itself — the station every other saving is
/// measured against. Shown as "Closest station", never "Break even".
case baseline
var displayName: String {
switch self {
case .worthTheDrive: return "Worth the drive"
case .breakEven: return "Break even"
case .notWorthTheDrive: return "Not worth the drive"
case .baseline: return FuelSavingsResult.baselineHeadline
}
}
}
/// How the journey distances behind an estimate were obtained.
enum FuelSavingsDistanceBasis: String, Codable, Equatable {
/// Measured road route, out and back, from the routing service.
case roadRoute
/// Straight-line out-and-back estimate, used until a route is measured.
case straightLine
var isEstimate: Bool { self == .straightLine }
}
/// Whether a Best Value comparison is backed by complete road-route data.
enum JourneyConfidence: String, Codable, Equatable {
/// Both baseline and candidate have measured outward and return road routes.
case confirmed
/// At least one journey is straight-line, missing, failed, or using a legacy
/// one-way road cache whose return leg is inferred rather than measured.
case estimated
var isEstimated: Bool { self == .estimated }
}
struct FuelSavingsResult: Codable, Equatable {
let fillLitres: Double
let grossSavingGBP: Double
let extraFuelLitres: Double
let extraDrivingCostGBP: Double
let extraDistanceMiles: Double
let netSavingGBP: Double
let status: FuelSavingsStatus
let distanceBasis: FuelSavingsDistanceBasis
let journeyConfidence: JourneyConfidence
init(fillLitres: Double,
grossSavingGBP: Double,
extraFuelLitres: Double = 0,
extraDrivingCostGBP: Double,
extraDistanceMiles: Double,
netSavingGBP: Double,
status: FuelSavingsStatus,
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
journeyConfidence: JourneyConfidence? = nil) {
self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP
self.extraFuelLitres = extraFuelLitres
self.extraDrivingCostGBP = extraDrivingCostGBP
self.extraDistanceMiles = extraDistanceMiles
self.netSavingGBP = netSavingGBP
self.status = status
self.distanceBasis = distanceBasis
self.journeyConfidence = journeyConfidence ?? (distanceBasis.isEstimate ? .estimated : .confirmed)
}
}
/// Money formatting for savings strings, in one place so the exact wording the
/// UI shows is testable.
enum FuelSavingsFormat {
static func money(_ value: Double) -> String {
String(format: "£%.2f", value)
}
/// A distance stored in MILES, formatted in the display unit. The figures
/// behind an estimate are stored in miles whatever the user's unit, so this
/// is the one place that conversion lives.
static func distance(_ miles: Double, unit: DistanceUnit) -> String {
// Convert miles→km FIRST, then let the unit format it. `unit.toKM` would
// convert by the CURRENT unit instead, leaving a km user's figure
// unlabelled and 1.6× too large.
unit.format(miles * FuelSavingsCalculator.kilometresPerMile)
}
/// Row label for the extra driving a saving has to beat. The stored figure is
/// always MILES while the row's distance line is one-way, so the wording names
/// the RETURN journey: the figure is an out-and-back DELTA between two
/// journeys and can legitimately exceed the one-way distance, which read as a
/// contradiction when it said "more driving than closest".
static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard let distanceText = extraTravelDistanceLabel(extraDistanceMiles: extraDistanceMiles,
unit: unit,
confidence: confidence) else { return nil }
return "\(distanceText) extra return travel"
}
/// The extra-travel FIGURE on its own, with its confidence marker: "4.3 mi"
/// or "Approx. 4.3 mi".
///
/// A row form ("Extra return travel | 4.3 mi") names the journey in its own
/// label, so this is the same figure and the same "Approx." rule as
/// `extraTravelLabel`, from the same inputs — never rebuilt at a call site,
/// or one surface marks an estimate while another drops the marker.
static func extraTravelDistanceLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit)
return confidence == .estimated ? "Approx. \(distanceText)" : distanceText
}
/// Compact comparison copy kept outside the coloured verdict capsule.
/// "Closest" refers to the existing shortest-journey baseline selection;
/// it does not claim a straight-line comparison.
static func comparisonLabel(status: FuelSavingsStatus) -> String {
status == .baseline ? "your baseline" : "vs closest station"
}
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
/// are spoken as words ("Approximately 6.4 miles extra return travel") because
/// synthesizers render "Approx." and "mi" inconsistently.
static func extraTravelSpokenLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = spokenDistance(extraDistanceMiles, unit: unit)
return confidence == .estimated
? "Approximately \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
}
/// A distance stored in MILES, spoken: "6.4 miles" / "10.3 kilometres". The
/// miles→km hop is deliberate: the stored figure is miles while
/// `DistanceUnit.spoken` speaks a KM value, the same boundary `distance(_:unit:)`
/// crosses.
static func spokenDistance(_ miles: Double, unit: DistanceUnit) -> String {
unit.spoken(miles * FuelSavingsCalculator.kilometresPerMile)
}
}
extension FuelSavingsResult {
/// Baseline row label — required wording.
static let baselineHeadline = "Closest station"
/// Neutral result label — required wording.
static let breakEvenHeadline = "Break even"
/// The same estimate relabelled as the comparison baseline: nothing to save
/// or lose against itself, and no extra travel.
func asBaseline() -> FuelSavingsResult {
FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: 0,
extraFuelLitres: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline,
distanceBasis: distanceBasis,
journeyConfidence: journeyConfidence
)
}
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
/// 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
/// tech may skip it) instead of saying "this is an estimate". Break-even and
/// baseline stay plain words, with the estimate context carried by the
/// extra-distance caption or the detail explanation.
func headline(compact: Bool = false) -> String {
let prefix = journeyConfidence.isEstimated ? "Approx. " : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
case .notWorthTheDrive:
return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more"
}
}
/// VoiceOver form of `headline()`: "Approx." is spelled out so a synthesizer
/// cannot read the abbreviation as "approx" or drop it entirely.
func spokenHeadline() -> String {
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
}
/// Full monetary verdict used by Station Details. Estimated confidence is a
/// trailing qualifier so the amount remains the first figure users scan.
func detailCapsuleHeadline() -> String {
let qualifier = journeyConfidence.isEstimated ? " (approx.)" : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return "\(Self.breakEvenHeadline)\(qualifier)"
case .worthTheDrive:
return "Save \(FuelSavingsFormat.money(netSavingGBP))\(qualifier)"
case .notWorthTheDrive:
return "Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more\(qualifier)"
}
}
func detailCapsuleSpokenHeadline() -> String {
detailCapsuleHeadline().replacingOccurrences(of: "(approx.)", with: "(approximately)")
}
/// Row capsule copy. The baseline relationship is shown as secondary text
/// beside the capsule in the station listing and detail summary, so the
/// capsule itself contains only the verdict.
func capsuleHeadline() -> String {
headline(compact: true)
}
/// The station-listing capsule's verdict, split into the lines the capsule
/// draws.
///
/// The amount always takes its OWN line, so where the capsule breaks is
/// decided by the data, never by the width it was handed: the figure can
/// never straddle two lines, and a taller verdict grows the capsule downward
/// instead of widening the row's fixed price column.
struct ListingVerdict: Equatable {
/// "Saves", "Costs", "Break even" or "Closest station".
let headline: String
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
let amount: String?
/// "(approx.)" when the journey behind the figure was estimated.
let qualifier: String?
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
/// in the middle of a phrase when spoken.
var spokenText: String { lines.joined(separator: " ") }
}
/// Verdict for the station listing rows — Stations and Favourites share one
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
/// which demonstrates the wording without inventing a figure.
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
switch status {
case .worthTheDrive:
return ListingVerdict(
headline: String(localized: "Saves"),
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
qualifier: qualifier
)
case .notWorthTheDrive:
return ListingVerdict(
headline: String(localized: "Costs"),
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
qualifier: qualifier
)
case .breakEven:
// No figure: ±£0.05 is noise, so this stays a single line unless the
// estimate qualifier adds its own.
return ListingVerdict(
headline: String(localized: "Break even"),
amount: nil,
qualifier: qualifier
)
case .baseline:
return ListingVerdict(
headline: Self.baselineHeadline,
amount: nil,
qualifier: nil
)
}
}
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
private static func amountWithMore(_ value: Double) -> String {
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
}
/// One-line verdict for voice and tests; the capsule itself renders
/// `listingVerdict()`'s lines separately.
func listingCapsuleHeadline() -> String {
listingVerdict().spokenText
}
}
/// One station's journey distance, plus how it was obtained.
struct FuelSavingsJourney: Equatable {
let miles: Double
let basis: FuelSavingsDistanceBasis
let confidence: JourneyConfidence
init(miles: Double,
basis: FuelSavingsDistanceBasis,
confidence: JourneyConfidence? = nil) {
self.miles = miles
self.basis = basis
self.confidence = confidence ?? (basis.isEstimate ? .estimated : .confirmed)
}
}
enum FuelSavingsAvailability: Equatable {
case available(FuelSavingsResult)
case unavailable(String)
var result: FuelSavingsResult? {
if case .available(let result) = self { return result }
return nil
}
}
struct FuelSavingsRankedStation: Equatable {
let stationID: String
let result: FuelSavingsAvailability
let distanceMiles: Double
let pricePencePerLitre: Double
}
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
/// each visual indicator without coupling the wording to SwiftUI layout.
enum KeySheetContent {
struct Item: Equatable {
let label: String
let description: String
}
static let priceRatings = [
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
]
static let savings = [
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
Item(label: "Break even", description: "The saving or extra cost is negligible."),
Item(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
]
static let stationLabels = [
Item(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
Item(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
Item(label: "CLOSEST", description: "The nearest station by journey distance."),
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
]
static let favourite = Item(
label: "Favourites",
description: "Star a station to save it to your Favourites."
)
static let helpTitle = "How does Best Value work?"
}
/// 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
}
enum FuelSavingsError: Error, Equatable {
case invalidMPG
case invalidAmount
case invalidPrice
case invalidDistance
}
enum FuelSavingsCalculator {
/// 1 UK (imperial) gallon. Must never be the US gallon (3.785411784 L):
/// UK pumps are priced per litre and drivers quote UK MPG, so a US gallon
/// would understate every travel cost by 17%.
static let imperialGallonLitres = 4.54609
/// Exact mile↔kilometre factors (1 mile = 1.609344 km). Every figure the
/// savings maths keeps is in MILES, and every displayed or fallback
/// distance converts through these, so one quantity is never converted with
/// two different constants. The audit found 1.60934, 1609.34 and 0.621371
/// mixed in the same path — a ~2 parts-per-million difference, immaterial to
/// the money, but three constants where there should be one.
static let kilometresPerMile = 1.609344
static let metresPerMile = 1609.344
static let breakEvenToleranceGBP = 0.05
static let minimumMPG: Double = 5
static let maximumMPG: Double = 200
static func maximumFillAmount(for unit: FuelSavingsFillUnit) -> Double {
switch unit {
case .pounds: return 500
case .litres: return 250
}
}
/// Bounds for every fill-up SLIDER (Settings and the in-list adjustment
/// share them, so the two controls can never offer different ranges). The
/// validity ceiling above is deliberately wider, so a stored outlier is
/// displayed rather than destroyed by the control that shows it.
static func fillAmountSliderRange(for unit: FuelSavingsFillUnit) -> ClosedRange<Double> {
switch unit {
case .pounds: return 10...100
case .litres: return 10...80
}
}
static func fillAmountSliderStep(for unit: FuelSavingsFillUnit) -> Double {
switch unit {
case .pounds: return 5
case .litres: return 1
}
}
/// Bounds for the average-MPG slider. The validity bounds (5...200) are
/// wide enough for any stored figure but far too wide to fine-tune by
/// touch; every real-world UK car sits inside this range.
static let mpgSliderRange: ClosedRange<Double> = 10...100
static let mpgSliderStep: Double = 1
static func isValidMPG(_ mpg: Double) -> Bool {
mpg.isFinite && mpg >= minimumMPG && mpg <= maximumMPG
}
static func fuelUsedLitres(distanceMiles: Double, mpg: Double) -> Double {
distanceMiles / mpg * imperialGallonLitres
}
static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double {
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
switch amount.unit {
case .litres:
return amount.value
case .pounds:
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
return amount.value / (pricePencePerLitre / 100.0)
}
}
/// Changing between a per-candidate monetary budget and a fixed litre
/// quantity has no lossless conversion across all stations. Start a new
/// comparison at that mode's stated default; the UI must tell the user
/// explicitly that the comparison basis changed and invite adjustment.
static func newComparisonAmount(afterSwitching amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit) -> FuelSavingsFillAmount {
guard amount.unit != unit else { return amount }
switch unit {
case .pounds: return FuelSavingsFillAmount(value: 40, unit: .pounds)
case .litres: return FuelSavingsFillAmount(value: 30, unit: .litres)
}
}
static func convert(amount: FuelSavingsFillAmount,
to unit: FuelSavingsFillUnit,
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
guard referencePricePencePerLitre.isFinite, referencePricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
guard amount.unit != unit else { return amount }
switch (amount.unit, unit) {
case (.pounds, .litres):
return FuelSavingsFillAmount(
value: amount.value / (referencePricePencePerLitre / 100.0),
unit: .litres
)
case (.litres, .pounds):
return FuelSavingsFillAmount(
value: amount.value * (referencePricePencePerLitre / 100.0),
unit: .pounds
)
case (.pounds, .pounds), (.litres, .litres):
return amount
}
}
static func calculate(mpg: Double,
amount: FuelSavingsFillAmount,
baselinePricePencePerLitre: Double,
candidatePricePencePerLitre: Double,
baselineJourneyMiles: Double,
candidateJourneyMiles: Double,
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
journeyConfidence: JourneyConfidence? = nil,
breakEvenToleranceGBP: Double = Self.breakEvenToleranceGBP) throws -> FuelSavingsResult {
guard isValidMPG(mpg) else { throw FuelSavingsError.invalidMPG }
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0,
candidatePricePencePerLitre.isFinite, candidatePricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
guard baselineJourneyMiles.isFinite, candidateJourneyMiles.isFinite,
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
throw FuelSavingsError.invalidDistance
}
let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre)
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
let extraDistance = max(0, candidateJourneyMiles - baselineJourneyMiles)
let extraFuelLitres = fuelUsedLitres(distanceMiles: extraDistance, mpg: mpg)
let extraFuelCost = extraFuelLitres * candidatePriceGBP
let netSaving = grossSaving - extraFuelCost
let status: FuelSavingsStatus
if netSaving > breakEvenToleranceGBP {
status = .worthTheDrive
} else if netSaving < -breakEvenToleranceGBP {
status = .notWorthTheDrive
} else {
status = .breakEven
}
return FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: grossSaving,
extraFuelLitres: extraFuelLitres,
extraDrivingCostGBP: extraFuelCost,
extraDistanceMiles: extraDistance,
netSavingGBP: netSaving,
status: status,
distanceBasis: distanceBasis,
journeyConfidence: journeyConfidence
)
}
static func sortedForWorthTheDrive(_ stations: [FuelSavingsRankedStation]) -> [FuelSavingsRankedStation] {
stations.sorted { lhs, rhs in
switch (lhs.result.result, rhs.result.result) {
case let (l?, r?):
if l.netSavingGBP != r.netSavingGBP { return l.netSavingGBP > r.netSavingGBP }
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
if lhs.pricePencePerLitre != rhs.pricePencePerLitre { return lhs.pricePencePerLitre < rhs.pricePencePerLitre }
return lhs.stationID < rhs.stationID
case (_?, nil):
return true
case (nil, _?):
return false
case (nil, nil):
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
return lhs.stationID < rhs.stationID
}
}
}
static func bestValueAlertCandidate(
stations: [FuelStation],
fuel: FuelType,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
minimumNetSavingGBP: Double,
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.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 {
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)
baselineJourneyLoaded = true
}
return baselineJourneyCache
}()
guard case .available(let result) = FuelSavingsEstimator.availability(
station: station,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey,
baselineJourney: baselineJourney
), result.status == .worthTheDrive,
result.netSavingGBP >= minimumNetSavingGBP else { continue }
let distance = journey?.miles ?? .greatestFiniteMagnitude
let price = station.prices[fuel] ?? .infinity
guard let current = best else {
best = BestValueAlertCandidate(station: station, result: result)
bestDistance = distance
bestPrice = price
continue
}
if result.netSavingGBP > current.result.netSavingGBP ||
(result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) ||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) ||
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) {
best = BestValueAlertCandidate(station: station, result: result)
bestDistance = distance
bestPrice = price
}
}
return best
}
}
/// Turns stations into savings estimates.
///
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
/// rules are unit tested directly.
enum FuelSavingsEstimator {
/// Baseline station for Best Value: the station with the shortest journey
/// among stations that sell the selected fuel, using the SAME journey source
/// the savings calculation will use. This keeps the row labelled "Closest
/// station" and every "extra return travel" value tied to one
/// reference, instead of mixing a separate straight-line baseline with
/// road-route savings.
static func baselineStation(_ stations: [FuelStation],
fuel: FuelType,
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
var bestStation: FuelStation?
var bestMiles = Double.infinity
for station in stations where station.prices[fuel] != nil {
guard let journey = journeyMiles(station) else { continue }
let miles = journey.miles
guard miles.isFinite, miles >= 0 else { continue }
if miles < bestMiles {
bestMiles = miles
bestStation = station
}
}
return bestStation
}
/// Savings for a single station against `baseline`, or nil when there is
/// nothing to show at all (feature off).
static func availability(station: FuelStation,
fuel: FuelType,
baseline: FuelStation?,
settings: FuelSavingsSettings,
amount: FuelSavingsFillAmount,
journey: FuelSavingsJourney?,
baselineJourney: FuelSavingsJourney?) -> FuelSavingsAvailability? {
guard settings.isConfigured else { return nil }
guard let baseline, let baselineJourney else { return .unavailable("No comparison station") }
guard let baselinePrice = baseline.prices[fuel] else { return .unavailable("No comparison station") }
guard let price = station.prices[fuel] else { return .unavailable("No price") }
guard let journey else { return .unavailable("Distance unavailable") }
// Both journeys must be measured, complete road routes for the comparison
// to be confirmed. Legacy one-way caches keep the road-route basis for
// distance arithmetic but are still provisional because the return leg is
// inferred rather than measured.
let basis: FuelSavingsDistanceBasis =
(journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine
let confidence: JourneyConfidence =
(journey.confidence == .confirmed && baselineJourney.confidence == .confirmed) ? .confirmed : .estimated
do {
let result = try FuelSavingsCalculator.calculate(
mpg: settings.mpg,
amount: amount,
baselinePricePencePerLitre: baselinePrice,
candidatePricePencePerLitre: price,
baselineJourneyMiles: baselineJourney.miles,
candidateJourneyMiles: journey.miles,
distanceBasis: basis,
journeyConfidence: confidence
)
// The baseline compares against itself: label it as the closest
// station rather than a meaningless "Break even".
return .available(station.id == baseline.id ? result.asBaseline() : result)
} catch {
return .unavailable("Distance unavailable")
}
}
/// 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?) -> [FuelStation] {
var journeys: [String: FuelSavingsJourney] = [:]
journeys.reserveCapacity(stations.count)
for station in stations {
if let journey = journeyMiles(station) {
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 ranked = stations.map { station -> FuelSavingsRankedStation in
let journey = journeys[station.id]
let availability = availability(station: station,
fuel: fuel,
baseline: baseline,
settings: settings,
amount: amount,
journey: journey,
baselineJourney: baselineJourney) ?? .unavailable("Unavailable")
return FuelSavingsRankedStation(
stationID: station.id,
result: availability,
distanceMiles: journey?.miles ?? .greatestFiniteMagnitude,
pricePencePerLitre: station.prices[fuel] ?? .infinity
)
}
var order: [String: Int] = [:]
for (offset, rankedStation) in FuelSavingsCalculator.sortedForWorthTheDrive(ranked).enumerated() {
if order[rankedStation.stationID] == nil {
order[rankedStation.stationID] = offset
}
}
return stations.sorted { lhs, rhs in
let left = order[lhs.id] ?? Int.max
let right = order[rhs.id] ?? Int.max
if left != right { return left < right }
return lhs.id < rhs.id
}
}
}