The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type. The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed. Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched. Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic. Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
877 lines
38 KiB
Swift
877 lines
38 KiB
Swift
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.
|
||
///
|
||
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
|
||
/// are read as phrases, and a capsule set in all caps reads as shouting beside
|
||
/// the sentence-case copy in the rows around it. The tests pin these exact
|
||
/// strings, and `StationLabelKind` maps each one to its indicator, so the view
|
||
/// never matches on the literal text.
|
||
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.")
|
||
]
|
||
|
||
/// The same three verdicts the listing capsule draws, so the legend and the
|
||
/// rows cannot disagree on a word. "Costs" is the capsule's own headline.
|
||
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", 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 station", 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."
|
||
)
|
||
|
||
/// The link that opens the explanation sheet. Names the question the sheet
|
||
/// answers rather than the feature ("How does Best Value work?"), because the
|
||
/// sheet explains the whole calculation — fill-up, return trip, extra travel,
|
||
/// total — not only the Best Value gate at the end of it.
|
||
static let helpTitle = "How does FuelBoard calculate value?"
|
||
/// The explanation sheet's own title: a SHORT form of the same question, not
|
||
/// the link's words. A navigation title is clamped to the bar, and the full
|
||
/// question truncated to "How does FuelBoard…" in BOTH inline and large
|
||
/// display modes — so the row carries the full question and the sheet it opens
|
||
/// names the same topic in words that render in full.
|
||
static let helpSheetTitle = "How value is calculated"
|
||
}
|
||
|
||
/// A station label's meaning, independent of its copy.
|
||
///
|
||
/// The Key sheet used to dispatch its indicator on the label's literal text
|
||
/// (`case "BEST VALUE":`), so renaming a label silently dropped that row to the
|
||
/// fallback indicator. The enum makes the mapping explicit and lets the
|
||
/// compiler catch a missing case.
|
||
enum StationLabelKind: String, CaseIterable {
|
||
case bestValue
|
||
case cheapest
|
||
case closestStation
|
||
case top
|
||
|
||
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
|
||
var label: String {
|
||
switch self {
|
||
case .bestValue: return String(localized: "Best value")
|
||
case .cheapest: return String(localized: "Cheapest")
|
||
case .closestStation: return FuelSavingsResult.baselineHeadline
|
||
case .top: return String(localized: "Top")
|
||
}
|
||
}
|
||
|
||
var symbol: String {
|
||
switch self {
|
||
case .bestValue: return "medal"
|
||
case .cheapest: return "tag"
|
||
case .closestStation: return "location.north.fill"
|
||
case .top: return "trophy"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// Every ranking label a station qualifies for, in display order.
|
||
///
|
||
/// A listing row shows ONE badge (Best Value outranks Cheapest, because a row has
|
||
/// room for one). The More info sheet has room for all of them, so it shows each
|
||
/// qualification side by side — but it must never invent a rule of its own, so this
|
||
/// defers to `StationRecommendationBadge.badge` for the Best Value gate and takes
|
||
/// the other two as the SAME flags the host list gives its rows.
|
||
///
|
||
/// Top and Cheapest are distinct on purpose: Cheapest means "lowest price in the
|
||
/// range the Stations tab is showing", Top means "lowest price among your
|
||
/// Favourites". A caller passes whichever context it is listing, never both, so a
|
||
/// station is not labelled twice for the same achievement.
|
||
enum StationRanking {
|
||
static func kinds(netSavingGBP: Double?,
|
||
isCheapestInRange: Bool,
|
||
isTopFavourite: Bool,
|
||
bestValueThresholdGBP: Double) -> [StationLabelKind] {
|
||
var kinds: [StationLabelKind] = []
|
||
// The same gate the listing row uses, asked without a cheapest flag so the
|
||
// ranking is decided only by the saving.
|
||
if StationRecommendationBadge.badge(
|
||
netSavingGBP: netSavingGBP,
|
||
isCheapest: false,
|
||
bestValueThresholdGBP: bestValueThresholdGBP
|
||
) == .bestValue {
|
||
kinds.append(.bestValue)
|
||
}
|
||
if isTopFavourite { kinds.append(.top) }
|
||
if isCheapestInRange { kinds.append(.cheapest) }
|
||
return kinds
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|