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 ") } /// 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) } } /// 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 } /// 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 { 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 = 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 } } }