Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5900a81473 | ||
|
|
ed3a5137cd | ||
|
|
5f3051aed7 | ||
|
|
5c0458dbf6 | ||
|
|
9e77ee2947 | ||
|
|
7c2eeb2604 |
+48
-24
@@ -161,7 +161,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func installLocationUpdateHook() {
|
private func installLocationUpdateHook() {
|
||||||
locationManager.onLocationUpdate = { [weak monitor] in
|
locationManager.onLocationUpdate = {
|
||||||
self.updateAlertMonitor()
|
self.updateAlertMonitor()
|
||||||
// The Live Activity follows the same wake-ups — this hook
|
// The Live Activity follows the same wake-ups — this hook
|
||||||
// fires on every fix incl. background significant-change
|
// fires on every fix incl. background significant-change
|
||||||
@@ -401,28 +401,27 @@ struct ContentView: View {
|
|||||||
/// (computing a journey for each) and `savingsLookup` immediately scanned them
|
/// (computing a journey for each) and `savingsLookup` immediately scanned them
|
||||||
/// all AGAIN to fill the per-station map. Both now share one result.
|
/// all AGAIN to fill the per-station map. Both now share one result.
|
||||||
///
|
///
|
||||||
/// The baseline is chosen with EXACTLY the loop and guards of
|
/// The baseline is chosen with the same journey and selected-fuel freshness
|
||||||
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest
|
/// guards as `FuelSavingsEstimator.bestValueBaselineStation` when configured;
|
||||||
/// station" cannot move — and it is still selected when the feature is
|
/// with the feature off, the ordinary Closest station still names that row.
|
||||||
/// unconfigured (it names that row), while the maps are only built when the
|
|
||||||
/// labels will actually be shown.
|
|
||||||
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
||||||
guard let location else { return (nil, [:]) }
|
guard let location else { return (nil, [:]) }
|
||||||
let configured = fuelSavingsSettings.isConfigured
|
guard fuelSavingsSettings.isConfigured else {
|
||||||
|
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
|
||||||
|
savingsJourneyMiles(for: $0, location: location)
|
||||||
|
}
|
||||||
|
return (baseline, [:])
|
||||||
|
}
|
||||||
var journeys: [String: FuelSavingsJourney] = [:]
|
var journeys: [String: FuelSavingsJourney] = [:]
|
||||||
var bestStation: FuelStation?
|
|
||||||
var bestMiles = Double.infinity
|
|
||||||
for station in stations where station.prices[selectedFuel] != nil {
|
for station in stations where station.prices[selectedFuel] != nil {
|
||||||
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue }
|
if let journey = savingsJourneyMiles(for: station, location: location) {
|
||||||
if configured { journeys[station.id] = journey }
|
journeys[station.id] = journey
|
||||||
let miles = journey.miles
|
|
||||||
guard miles.isFinite, miles >= 0 else { continue }
|
|
||||||
if miles < bestMiles {
|
|
||||||
bestMiles = miles
|
|
||||||
bestStation = station
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (bestStation, configured ? journeys : [:])
|
let baseline = FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: selectedFuel) {
|
||||||
|
journeys[$0.id]
|
||||||
|
}
|
||||||
|
return (baseline, journeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row/detail savings lookup with the comparison baseline and the per-station
|
/// Row/detail savings lookup with the comparison baseline and the per-station
|
||||||
@@ -455,15 +454,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The nearest station that sells the selected fuel — the Best Value
|
/// The Closest station with an eligible selected-fuel price — the Best Value
|
||||||
/// comparison baseline. It is chosen with the SAME journey values used by
|
/// comparison baseline. Ordinary Closest sorting still includes stale stations.
|
||||||
/// the savings calculation, so a cached road route can change the baseline
|
/// It uses the same journey values as the savings calculation, so a cached
|
||||||
/// and keep the "extra return travel" labels synchronized with the
|
/// road route can change the baseline and keep the "extra return travel"
|
||||||
/// cost calculation. When no road route is cached, the journey falls back to
|
/// labels synchronized with the cost calculation. Without a cached route,
|
||||||
/// straight-line out-and-back, and the result is marked as an estimate.
|
/// the journey falls back to straight-line and the result is an estimate.
|
||||||
private var nearestSavingsBaseline: FuelStation? {
|
private var nearestSavingsBaseline: FuelStation? {
|
||||||
guard let location else { return nil }
|
guard let location else { return nil }
|
||||||
return FuelSavingsEstimator.baselineStation(
|
return FuelSavingsEstimator.bestValueBaselineStation(
|
||||||
stations,
|
stations,
|
||||||
fuel: selectedFuel,
|
fuel: selectedFuel,
|
||||||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||||||
@@ -803,6 +802,10 @@ struct ContentView: View {
|
|||||||
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
||||||
journeys: scaffold.journeys)(station),
|
journeys: scaffold.journeys)(station),
|
||||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||||
|
savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence.assess(
|
||||||
|
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
|
||||||
|
savingsPriceWarning: FuelPriceFreshness.comparisonWarning(
|
||||||
|
station: station, fuel: selectedFuel, baseline: scaffold.baseline),
|
||||||
savingsBaselineStationName: scaffold.baseline?.name,
|
savingsBaselineStationName: scaffold.baseline?.name,
|
||||||
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
@@ -1119,6 +1122,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: fuelSavingsSettings) { old, new in
|
.onChange(of: fuelSavingsSettings) { old, new in
|
||||||
|
// An explicit Settings default edit supersedes the temporary slider
|
||||||
|
// override. Changes to MPG/enabled alone leave the session fill intact.
|
||||||
|
if old.preferredAmount != new.preferredAmount {
|
||||||
|
sessionFuelSavingsAmount = new.preferredAmount
|
||||||
|
}
|
||||||
// Fuel Savings changed in Settings while the app was running: the
|
// Fuel Savings changed in Settings while the app was running: the
|
||||||
// effective sort follows automatically (no stored sort is touched).
|
// effective sort follows automatically (no stored sort is touched).
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -1524,6 +1532,12 @@ struct ContentView: View {
|
|||||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||||
|
savingsPriceConfidenceForStation: { station in
|
||||||
|
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
|
||||||
|
},
|
||||||
|
savingsPriceWarningForStation: { station in
|
||||||
|
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
|
||||||
|
},
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onRefresh: { await refresh(force: true) }
|
onRefresh: { await refresh(force: true) }
|
||||||
)
|
)
|
||||||
@@ -1545,6 +1559,12 @@ struct ContentView: View {
|
|||||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||||
|
savingsPriceConfidenceForStation: { station in
|
||||||
|
FuelPriceFreshness.ComparisonConfidence.assess(station: station, fuel: selectedFuel, baseline: baselineStation)
|
||||||
|
},
|
||||||
|
savingsPriceWarningForStation: { station in
|
||||||
|
FuelPriceFreshness.comparisonWarning(station: station, fuel: selectedFuel, baseline: baselineStation)
|
||||||
|
},
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites,
|
onReorder: reorderFavourites,
|
||||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
@@ -1782,6 +1802,8 @@ struct StationRow: View {
|
|||||||
/// compact banner does not print it today, but a row that carried a hard
|
/// compact banner does not print it today, but a row that carried a hard
|
||||||
/// zero would print "for £0" the moment it did — pass the real figure.
|
/// zero would print "for £0" the moment it did — pass the real figure.
|
||||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||||
|
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||||||
|
var savingsPriceWarning: String? = nil
|
||||||
var onToggleFavourite: () -> Void = {}
|
var onToggleFavourite: () -> Void = {}
|
||||||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
@@ -1942,6 +1964,8 @@ struct StationRow: View {
|
|||||||
FuelSavingsSummaryView(
|
FuelSavingsSummaryView(
|
||||||
availability: fuelSavings,
|
availability: fuelSavings,
|
||||||
amount: fuelSavingsAmount,
|
amount: fuelSavingsAmount,
|
||||||
|
priceConfidence: savingsPriceConfidence,
|
||||||
|
priceWarning: savingsPriceWarning,
|
||||||
compact: true,
|
compact: true,
|
||||||
distanceUnit: distanceUnit
|
distanceUnit: distanceUnit
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ struct FavouritesView: View {
|
|||||||
/// journey distance — the same baseline and definition the Stations list uses.
|
/// journey distance — the same baseline and definition the Stations list uses.
|
||||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
|
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
|
||||||
|
var savingsPriceWarningForStation: (FuelStation) -> String? = { _ in nil }
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
@@ -84,6 +86,8 @@ struct FavouritesView: View {
|
|||||||
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||||
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||||
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
||||||
|
savingsPriceConfidenceForStation: @escaping (FuelStation) -> FuelPriceFreshness.ComparisonConfidence = { _ in .ineligible },
|
||||||
|
savingsPriceWarningForStation: @escaping (FuelStation) -> String? = { _ in nil },
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
onHistoryUnavailable: (() -> Void)? = nil,
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
@@ -98,6 +102,8 @@ struct FavouritesView: View {
|
|||||||
_fuelSavingsAmount = fuelSavingsAmount
|
_fuelSavingsAmount = fuelSavingsAmount
|
||||||
self.savingsForStation = savingsForStation
|
self.savingsForStation = savingsForStation
|
||||||
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
||||||
|
self.savingsPriceConfidenceForStation = savingsPriceConfidenceForStation
|
||||||
|
self.savingsPriceWarningForStation = savingsPriceWarningForStation
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
self.onHistoryUnavailable = onHistoryUnavailable
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
@@ -144,10 +150,7 @@ struct FavouritesView: View {
|
|||||||
|
|
||||||
if fuelSavingsSettings.isConfigured {
|
if fuelSavingsSettings.isConfigured {
|
||||||
Section {
|
Section {
|
||||||
FuelSavingsAdjustmentView(
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||||
amount: $fuelSavingsAmount,
|
|
||||||
referencePricePencePerLitre: cheapestPrice
|
|
||||||
)
|
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||||
}
|
}
|
||||||
@@ -168,6 +171,8 @@ struct FavouritesView: View {
|
|||||||
tapAction: stationTapAction,
|
tapAction: stationTapAction,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
|
||||||
|
savingsPriceWarning: savingsPriceWarningForStation(station),
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
onShowDetails: { detailStation = $0 }
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
@@ -216,8 +221,11 @@ struct FavouritesView: View {
|
|||||||
station: station,
|
station: station,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
|
selectedFuel: activeFuel,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
|
||||||
|
savingsPriceWarning: savingsPriceWarningForStation(station),
|
||||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
// Every station listed here IS a favourite for the tab's
|
// Every station listed here IS a favourite for the tab's
|
||||||
// fuel, so the star starts filled; tapping it unstars.
|
// fuel, so the star starts filled; tapping it unstars.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FuelSavingsAdjustmentView: View {
|
struct FuelSavingsAdjustmentView: View {
|
||||||
@Binding var amount: FuelSavingsFillAmount
|
@Binding var amount: FuelSavingsFillAmount
|
||||||
let referencePricePencePerLitre: Double?
|
|
||||||
|
@State private var unitChangeNotice: String?
|
||||||
|
|
||||||
private var range: ClosedRange<Double> {
|
private var range: ClosedRange<Double> {
|
||||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||||
@@ -44,6 +45,14 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let unitChangeNotice {
|
||||||
|
Text(unitChangeNotice)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +61,10 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
get: { amount.unit },
|
get: { amount.unit },
|
||||||
set: { newUnit in
|
set: { newUnit in
|
||||||
guard newUnit != amount.unit else { return }
|
guard newUnit != amount.unit else { return }
|
||||||
if let referencePricePencePerLitre,
|
// No one litre figure equals a budget at every candidate price.
|
||||||
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
|
// A unit change starts a new, explicitly labelled comparison.
|
||||||
amount = converted
|
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||||
} else {
|
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
||||||
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -73,6 +80,9 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
struct FuelSavingsSummaryView: View {
|
struct FuelSavingsSummaryView: View {
|
||||||
let availability: FuelSavingsAvailability?
|
let availability: FuelSavingsAvailability?
|
||||||
let amount: FuelSavingsFillAmount
|
let amount: FuelSavingsFillAmount
|
||||||
|
/// Candidate/baseline price confidence for every Best Value result.
|
||||||
|
var priceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||||||
|
var priceWarning: String? = nil
|
||||||
var baselineStationName: String? = nil
|
var baselineStationName: String? = nil
|
||||||
var compact: Bool = false
|
var compact: Bool = false
|
||||||
/// Sizes the "extra return travel" caption in the user's own unit (the
|
/// Sizes the "extra return travel" caption in the user's own unit (the
|
||||||
@@ -97,15 +107,24 @@ struct FuelSavingsSummaryView: View {
|
|||||||
// break even, blue baseline. lineLimit + minimumScaleFactor keep
|
// break even, blue baseline. lineLimit + minimumScaleFactor keep
|
||||||
// it to one line, so rows stay even.
|
// it to one line, so rows stay even.
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
// The capsule gets the WHOLE line and the comparison caption
|
||||||
capsule(for: result, compact: true)
|
// sits beneath it. Side by side, the caption's layoutPriority
|
||||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
// squeezed the verdict until it truncated ("Approx. £2.75
|
||||||
Text(comparisonLabel(for: result))
|
// better off after tra…"), and in this sort mode the verdict
|
||||||
|
// is the point of the row.
|
||||||
|
capsule(for: result, compact: true)
|
||||||
|
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||||
|
Text(comparisonLabel(for: result))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||||
|
if let warning = priceWarning ?? priceConfidence.warningText {
|
||||||
|
Text(warning)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.orange)
|
||||||
.lineLimit(1)
|
.accessibilityLabel(warning)
|
||||||
.layoutPriority(1)
|
|
||||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
|
||||||
}
|
}
|
||||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
||||||
extraDistanceMiles: result.extraDistanceMiles,
|
extraDistanceMiles: result.extraDistanceMiles,
|
||||||
@@ -130,19 +149,33 @@ struct FuelSavingsSummaryView: View {
|
|||||||
// room for. The old shape led with a status word + headline that
|
// room for. The old shape led with a status word + headline that
|
||||||
// matched neither the listing nor each other.
|
// matched neither the listing nor each other.
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
// Same shape as the listing rows: the capsule takes the line
|
||||||
|
// and the comparison caption sits directly beneath it, so the
|
||||||
|
// verdict never competes for width with its own caption.
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
capsule(for: result, compact: false)
|
capsule(for: result, compact: false)
|
||||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||||
Text(comparisonLabel(for: result))
|
Text(comparisonLabel(for: result))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.layoutPriority(1)
|
.minimumScaleFactor(0.85)
|
||||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||||
}
|
}
|
||||||
LabeledContent("Your fill-up", value: amountText)
|
LabeledContent("Your fill-up", value: amountText)
|
||||||
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
|
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
if amount.unit == .pounds {
|
||||||
|
Text("You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let warning = priceWarning ?? priceConfidence.warningText {
|
||||||
|
Text(warning)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityLabel(warning)
|
||||||
|
}
|
||||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
||||||
extraDistanceMiles: result.extraDistanceMiles,
|
extraDistanceMiles: result.extraDistanceMiles,
|
||||||
unit: distanceUnit,
|
unit: distanceUnit,
|
||||||
@@ -158,7 +191,7 @@ struct FuelSavingsSummaryView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Compared with \(baselineStationName ?? "your closest station").")
|
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest eligible station.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
LabeledContent(
|
LabeledContent(
|
||||||
@@ -233,7 +266,7 @@ struct FuelSavingsSummaryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
|
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
|
||||||
/// estimated comparisons and leaves the plain words ("Break even", "Closest
|
/// and leaves the plain words ("Break even", "Closest eligible
|
||||||
/// station") alone — one source, so visible and spoken copy cannot drift.
|
/// station") alone — one source, so visible and spoken copy cannot drift.
|
||||||
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
|
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
|
||||||
result.spokenHeadline()
|
result.spokenHeadline()
|
||||||
@@ -254,6 +287,10 @@ struct FuelSavingsSummaryView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
Text(result.capsuleHeadline())
|
Text(result.capsuleHeadline())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
// Belt and braces: now that the capsule owns its line it has no
|
||||||
|
// rival for width, and shrinking beats truncating at large
|
||||||
|
// Dynamic Type sizes.
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
}
|
}
|
||||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||||
.foregroundStyle(capsuleColor(for: result.status))
|
.foregroundStyle(capsuleColor(for: result.status))
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct HowBestValueSheet: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
|
Text("Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
|
||||||
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
|
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
|
||||||
|
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back.")
|
||||||
|
Text("With Litres, every station is compared for the fixed quantity you selected.")
|
||||||
Text("Results are marked “Approx.” until road distances are available.")
|
Text("Results are marked “Approx.” until road distances are available.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ enum LiveActivityManager {
|
|||||||
currentStartDate = nil
|
currentStartDate = nil
|
||||||
start(attributes: attributes, state: state)
|
start(attributes: attributes, state: state)
|
||||||
} else {
|
} else {
|
||||||
Task { await current.update(using: state) }
|
Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
start(attributes: attributes, state: state)
|
start(attributes: attributes, state: state)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// every row at once; the mode must also be ON for any pass to run at all.
|
// every row at once; the mode must also be ON for any pass to run at all.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
import MapKit
|
import MapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
@@ -407,8 +408,14 @@ enum RoadDistanceService {
|
|||||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
let request = MKDirections.Request()
|
let request = MKDirections.Request()
|
||||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
request.source = MKMapItem(
|
||||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
location: CLLocation(latitude: from.latitude, longitude: from.longitude),
|
||||||
|
address: nil
|
||||||
|
)
|
||||||
|
request.destination = MKMapItem(
|
||||||
|
location: CLLocation(latitude: to.latitude, longitude: to.longitude),
|
||||||
|
address: nil
|
||||||
|
)
|
||||||
request.transportType = .automobile
|
request.transportType = .automobile
|
||||||
request.requestsAlternateRoutes = false
|
request.requestsAlternateRoutes = false
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ struct SettingsView: View {
|
|||||||
/// Return key, so the keyboard's Done button clears this to dismiss AND
|
/// Return key, so the keyboard's Done button clears this to dismiss AND
|
||||||
/// commit the typed value. Both Fuel Savings controls are sliders now, so
|
/// commit the typed value. Both Fuel Savings controls are sliders now, so
|
||||||
/// nothing sets it — it stays for any numeric field this screen takes again.
|
/// nothing sets it — it stays for any numeric field this screen takes again.
|
||||||
|
@State private var settingsUnitChangeNotice: String?
|
||||||
@FocusState private var focusedSavingsField: SavingsField?
|
@FocusState private var focusedSavingsField: SavingsField?
|
||||||
|
|
||||||
private enum SavingsField: Hashable {
|
private enum SavingsField: Hashable {
|
||||||
@@ -148,6 +149,11 @@ struct SettingsView: View {
|
|||||||
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
|
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
|
||||||
.accessibilityLabel("Typical fill-up")
|
.accessibilityLabel("Typical fill-up")
|
||||||
.accessibilityValue(fillUpText)
|
.accessibilityValue(fillUpText)
|
||||||
|
if let settingsUnitChangeNotice {
|
||||||
|
Text(settingsUnitChangeNotice)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
||||||
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -709,10 +715,10 @@ struct SettingsView: View {
|
|||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.unit },
|
get: { fuelSavingsSettings.preferredAmount.unit },
|
||||||
set: { newUnit in
|
set: { newUnit in
|
||||||
fuelSavingsSettings.preferredAmount.unit = newUnit
|
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
|
||||||
if !fuelSavingsSettings.preferredAmount.isValid {
|
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
||||||
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30
|
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
||||||
}
|
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
||||||
persistFuelSavingsSettings()
|
persistFuelSavingsSettings()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ struct StationDetailView: View {
|
|||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
|
/// Fuel whose price is being shown by the surrounding station list.
|
||||||
|
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||||
var fuelSavings: FuelSavingsAvailability? = nil
|
var fuelSavings: FuelSavingsAvailability? = nil
|
||||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||||
|
var savingsPriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||||||
|
var savingsPriceWarning: String? = nil
|
||||||
var savingsBaselineStationName: String? = nil
|
var savingsBaselineStationName: String? = nil
|
||||||
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
||||||
/// Whether this station is starred for the fuel currently being viewed, and
|
/// Whether this station is starred for the fuel currently being viewed, and
|
||||||
@@ -57,6 +61,18 @@ struct StationDetailView: View {
|
|||||||
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var priceFreshness: FuelPriceFreshness.Assessment {
|
||||||
|
FuelPriceFreshness.assessment(for: station, fuel: selectedFuel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selected grade's current price, when present. Kept separate from
|
||||||
|
/// the freshness label so a stale price remains visible rather than being
|
||||||
|
/// silently hidden.
|
||||||
|
private var selectedFuelPriceText: String {
|
||||||
|
guard let price = station.prices[selectedFuel] else { return "—" }
|
||||||
|
return FuelStore.priceText(price, style: .stationSign)
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens Apple Maps driving directions — the same destination (and the same
|
/// Opens Apple Maps driving directions — the same destination (and the same
|
||||||
/// URL) the Directions button below uses, so the map and the button can
|
/// URL) the Directions button below uses, so the map and the button can
|
||||||
/// never point at two different places.
|
/// never point at two different places.
|
||||||
@@ -197,6 +213,10 @@ struct StationDetailView: View {
|
|||||||
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
|
||||||
|
.monospacedDigit()
|
||||||
|
LabeledContent("Price updated", value: priceFreshness.updateAgeLabel)
|
||||||
|
.monospacedDigit()
|
||||||
ForEach(StationNotice.notices(for: station)) { notice in
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
Label(notice.title, systemImage: notice.symbolName)
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
@@ -237,6 +257,8 @@ struct StationDetailView: View {
|
|||||||
FuelSavingsSummaryView(
|
FuelSavingsSummaryView(
|
||||||
availability: fuelSavings,
|
availability: fuelSavings,
|
||||||
amount: fuelSavingsAmount,
|
amount: fuelSavingsAmount,
|
||||||
|
priceConfidence: savingsPriceConfidence,
|
||||||
|
priceWarning: savingsPriceWarning,
|
||||||
baselineStationName: savingsBaselineStationName,
|
baselineStationName: savingsBaselineStationName,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
showInfoAction: { showBestValueInfo = true }
|
showInfoAction: { showBestValueInfo = true }
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ struct StationsView: View {
|
|||||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
|
var savingsPriceConfidenceForStation: (FuelStation) -> FuelPriceFreshness.ComparisonConfidence
|
||||||
|
var savingsPriceWarningForStation: (FuelStation) -> String?
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
if location != nil {
|
if location != nil {
|
||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — closest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — closest first, lowest price within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
@@ -120,10 +122,12 @@ struct StationsView: View {
|
|||||||
// stale stored sort mode must not keep the control on screen
|
// stale stored sort mode must not keep the control on screen
|
||||||
// for a switched-off feature.
|
// for a switched-off feature.
|
||||||
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
||||||
FuelSavingsAdjustmentView(
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||||
amount: $fuelSavingsAmount,
|
if !stations.isEmpty && stations.allSatisfy({ savingsPriceConfidenceForStation($0) == .ineligible }) {
|
||||||
referencePricePencePerLitre: baselinePrice
|
Text("Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.")
|
||||||
)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isLoading && stations.isEmpty {
|
if isLoading && stations.isEmpty {
|
||||||
@@ -148,22 +152,7 @@ struct StationsView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
||||||
StationRow(
|
stationRow(station)
|
||||||
station: station,
|
|
||||||
fuel: selectedFuel,
|
|
||||||
location: location,
|
|
||||||
distanceUnit: distanceUnit,
|
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
|
||||||
baselinePrice: baselinePrice,
|
|
||||||
isTopResult: station.id == topStationID,
|
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
|
||||||
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
|
||||||
tapAction: stationTapAction,
|
|
||||||
fuelSavings: savingsForStation(station),
|
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
|
||||||
onShowDetails: { detailStation = $0 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if visibleCount < stations.count {
|
if visibleCount < stations.count {
|
||||||
@@ -259,6 +248,31 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showKey) {
|
.sheet(isPresented: $showKey) {
|
||||||
|
keySheet
|
||||||
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
detailSheet(for: station)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailSheet(for station: FuelStation) -> some View {
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
|
selectedFuel: selectedFuel,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
|
||||||
|
savingsPriceWarning: savingsPriceWarningForStation(station),
|
||||||
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var keySheet: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
@@ -339,20 +353,27 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
.presentationDetents([.fraction(0.6)])
|
.presentationDetents([.fraction(0.6)])
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
.sheet(item: $detailStation) { station in
|
|
||||||
StationDetailView(
|
private func stationRow(_ station: FuelStation) -> some View {
|
||||||
station: station,
|
StationRow(
|
||||||
location: location,
|
station: station,
|
||||||
distanceUnit: distanceUnit,
|
fuel: selectedFuel,
|
||||||
fuelSavings: savingsForStation(station),
|
location: location,
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
distanceUnit: distanceUnit,
|
||||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
baselinePrice: baselinePrice,
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
isTopResult: station.id == topStationID,
|
||||||
)
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
}
|
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
||||||
}
|
tapAction: stationTapAction,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsPriceConfidence: savingsPriceConfidenceForStation(station),
|
||||||
|
savingsPriceWarning: savingsPriceWarningForStation(station),
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableSortModes: [SortMode] {
|
private var availableSortModes: [SortMode] {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"TOP" = "TOP";
|
"TOP" = "TOP";
|
||||||
|
|
||||||
/* Stations tab */
|
/* Stations tab */
|
||||||
"Closest %@ stations — closest first, best value within %lld %@ · %lld stations" = "Closest %@ stations — closest first, best value within %lld %@ · %lld stations";
|
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
|
||||||
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
|
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
|
||||||
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
|
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
|
||||||
"Fuel type" = "Fuel type";
|
"Fuel type" = "Fuel type";
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"Remove from favourites" = "Remove from favourites";
|
"Remove from favourites" = "Remove from favourites";
|
||||||
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
||||||
"How Best Value works" = "How Best Value works";
|
"How Best Value works" = "How Best Value works";
|
||||||
"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
"Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest eligible station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
||||||
|
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest eligible station, then deduct extra journey fuel. You do not receive cash back.";
|
||||||
|
"Your closest eligible station by journey distance is the baseline — every other saving is measured against it." = "Your closest eligible station by journey distance is the baseline — every other saving is measured against it.";
|
||||||
|
"Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.";
|
||||||
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
|
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
|
||||||
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
||||||
|
|
||||||
@@ -70,7 +73,6 @@
|
|||||||
"More info" = "More info";
|
"More info" = "More info";
|
||||||
"Name" = "Name";
|
"Name" = "Name";
|
||||||
"Address" = "Address";
|
"Address" = "Address";
|
||||||
"Distance" = "Distance";
|
|
||||||
"Copies the postcode" = "Copies the postcode";
|
"Copies the postcode" = "Copies the postcode";
|
||||||
"Postcode copied" = "Postcode copied";
|
"Postcode copied" = "Postcode copied";
|
||||||
"Address: %@" = "Address: %@";
|
"Address: %@" = "Address: %@";
|
||||||
@@ -217,3 +219,18 @@
|
|||||||
"A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.";
|
"A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.";
|
||||||
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
|
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
|
||||||
"%@ — %@" = "%@ — %@";
|
"%@ — %@" = "%@ — %@";
|
||||||
|
|
||||||
|
/* Best Value calculation and price confidence */
|
||||||
|
"£ compares the litres your budget buys at each station; Litres compares one fixed quantity." = "£ compares the litres your budget buys at each station; Litres compares one fixed quantity.";
|
||||||
|
"You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest eligible station; litres differ between stations, and this is not a refund.";
|
||||||
|
"Best Value unavailable · price confidence insufficient" = "Best Value unavailable · price confidence insufficient";
|
||||||
|
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
||||||
|
"price order · Best Value unavailable" = "price order · Best Value unavailable";
|
||||||
|
"your order" = "your order";
|
||||||
|
"Price updated" = "Price updated";
|
||||||
|
"Price age unavailable or too old — not a Best Value recommendation." = "Price age unavailable or too old — not a Best Value recommendation.";
|
||||||
|
"%@ ago" = "%@ ago";
|
||||||
|
"Not provided" = "Not provided";
|
||||||
|
"Fuel you'll receive" = "Fuel you'll receive";
|
||||||
|
"Best Value unavailable: no eligible price comparison. Stations and prices remain visible below." = "Best Value unavailable: no eligible price comparison. Stations and prices remain visible below.";
|
||||||
|
"Compared with your closest eligible station." = "Compared with your closest eligible station.";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/FuelPriceFreshness.swift
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class FuelPriceFreshnessTests: XCTestCase {
|
||||||
|
private let now = Date(timeIntervalSince1970: 1_792_022_400)
|
||||||
|
|
||||||
|
private func station(
|
||||||
|
priceUpdated: TimeInterval? = nil,
|
||||||
|
priceUpdatedByFuel: [FuelType: TimeInterval]? = nil
|
||||||
|
) -> FuelStation {
|
||||||
|
FuelStation(
|
||||||
|
id: "freshness-station",
|
||||||
|
name: "Freshness Station",
|
||||||
|
brand: "Test",
|
||||||
|
address: "1 High Street",
|
||||||
|
postcode: "BD1 1AA",
|
||||||
|
lat: 53.79,
|
||||||
|
lng: -1.75,
|
||||||
|
prices: [.diesel: 129.9, .e10: 169.9],
|
||||||
|
priceUpdated: priceUpdated,
|
||||||
|
priceUpdatedByFuel: priceUpdatedByFuel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPerGradeTimestampIsDecodedAndEligibleWhenFresh() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"stations": [{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "LISTER ARMS",
|
||||||
|
"brand": "Shell",
|
||||||
|
"address": "MANCHESTER ROAD, BRADFORD",
|
||||||
|
"postcode": "BD5 7JX",
|
||||||
|
"lat": 53.78,
|
||||||
|
"lng": -1.75,
|
||||||
|
"prices": {"E10": 169.9, "DIESEL": 129.9},
|
||||||
|
"price_updated": "2026-09-25T07:45:52Z",
|
||||||
|
"price_updated_by_grade": {
|
||||||
|
"E10": "2026-10-14T07:45:52Z",
|
||||||
|
"DIESEL": "2026-10-14T07:45:52Z"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||||||
|
let decoded = try XCTUnwrap(stations.first)
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.priceUpdatedByFuel?[.diesel],
|
||||||
|
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970)
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: decoded, fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(assessment.source, .gradeSpecific)
|
||||||
|
XCTAssertEqual(assessment.status, .fresh)
|
||||||
|
XCTAssertTrue(assessment.isEligibleForBestValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationWideTimestampIsNotPresentedAsGradeSpecificOrEligible() {
|
||||||
|
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge / 2).timeIntervalSince1970
|
||||||
|
let assessment = FuelPriceFreshness.assessment(
|
||||||
|
for: station(priceUpdated: updated), fuel: .diesel, now: now
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(assessment.source, .stationWide)
|
||||||
|
XCTAssertEqual(assessment.status, .fresh)
|
||||||
|
XCTAssertEqual(assessment.confidence, .provisional)
|
||||||
|
XCTAssertTrue(assessment.recommendationEligibility == .eligible)
|
||||||
|
XCTAssertTrue(assessment.isEligibleForBestValue)
|
||||||
|
XCTAssertTrue(assessment.warning)
|
||||||
|
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMoreInfoReportsOneStationPriceAge() {
|
||||||
|
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "28h ago")
|
||||||
|
XCTAssertEqual(assessment.source, .stationWide)
|
||||||
|
XCTAssertEqual(assessment.status, .fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOlderFuelSpecificTimestampStillReportsItsOwnAgeOnce() {
|
||||||
|
let updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
|
||||||
|
let priced = station(priceUpdatedByFuel: [.diesel: updated])
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "10d ago")
|
||||||
|
XCTAssertEqual(assessment.source, .gradeSpecific)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingUpdateTimeReadsNotProvided() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: station(), fuel: .e10, now: now)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProvisionalSavingAddsNoSecondWarningLine() {
|
||||||
|
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||||
|
let stationWide = station(priceUpdated: recent)
|
||||||
|
let gradeSpecific = station(priceUpdatedByFuel: [.e10: recent])
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(
|
||||||
|
station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now), .provisional)
|
||||||
|
XCTAssertNil(FuelPriceFreshness.comparisonWarning(
|
||||||
|
station: stationWide, fuel: .e10, baseline: stationWide, now: now))
|
||||||
|
XCTAssertNil(FuelPriceFreshness.comparisonWarning(
|
||||||
|
station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIneligibleComparisonStillExplainsItself() {
|
||||||
|
let old = now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
|
||||||
|
let stale = station(priceUpdatedByFuel: [.diesel: old])
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
||||||
|
station: stale, fuel: .diesel, baseline: stale, now: now),
|
||||||
|
"Price age unavailable or too old — not a Best Value recommendation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() {
|
||||||
|
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge).timeIntervalSince1970
|
||||||
|
let onBoundary = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(onBoundary.status, .fresh)
|
||||||
|
XCTAssertTrue(onBoundary.isEligibleForBestValue)
|
||||||
|
let pastBoundary = FuelPriceFreshness.assessment(
|
||||||
|
for: station(priceUpdated: updated - 1), fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(pastBoundary.status, .warning)
|
||||||
|
XCTAssertFalse(pastBoundary.isEligibleForBestValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationWideTimestampOlderThanWarningThresholdCannotRecommendSuspiciousDiesel() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let shell = station(priceUpdated: now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970)
|
||||||
|
let age = FuelPriceFreshness.assessment(for: shell, fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(age.status, .warning)
|
||||||
|
XCTAssertFalse(age.isEligibleForBestValue)
|
||||||
|
XCTAssertEqual(shell.prices[.diesel], 129.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testComparisonConfidenceRejectsMissingSelectedFuelPriceEvenWithFreshTimestamp() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let updated = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||||||
|
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: nil,
|
||||||
|
priceUpdatedByFuel: [.e10: updated])
|
||||||
|
let e10 = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.e10: 169.9], priceUpdated: nil,
|
||||||
|
priceUpdatedByFuel: [.e10: updated])
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: dieselOnly, fuel: .e10,
|
||||||
|
baseline: e10, now: now), .ineligible)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: e10, fuel: .e10,
|
||||||
|
baseline: dieselOnly, now: now), .ineligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testComparisonConfidenceRequiresBothSelectedGradeTimestamps() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||||||
|
let old = now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970
|
||||||
|
let verified = station(priceUpdatedByFuel: [.diesel: recent])
|
||||||
|
let provisional = station(priceUpdated: recent)
|
||||||
|
let oldStationWide = station(priceUpdated: old)
|
||||||
|
let oldGrade = station(priceUpdatedByFuel: [.diesel: old])
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
|
||||||
|
baseline: verified, now: now), .verified)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
|
||||||
|
baseline: provisional, now: now), .provisional)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: oldGrade, fuel: .diesel,
|
||||||
|
baseline: verified, now: now), .provisional)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
|
||||||
|
baseline: oldStationWide, now: now), .ineligible)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: provisional, fuel: .diesel,
|
||||||
|
baseline: verified, now: now), .provisional)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .e10,
|
||||||
|
baseline: verified, now: now), .ineligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonFiniteAndExtremeEpochsCannotBecomeTrusted() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
for value in [Double.nan, .infinity, -.infinity, Double.greatestFiniteMagnitude, -Double.greatestFiniteMagnitude] {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(
|
||||||
|
priceUpdated: value, priceUpdatedByFuel: nil, fuel: .diesel, now: now)
|
||||||
|
XCTAssertFalse(assessment.isEligibleForBestValue)
|
||||||
|
XCTAssertTrue(assessment.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingAndMalformedTimestampsAreUnknownAndIneligible() {
|
||||||
|
let missing = FuelPriceFreshness.assessment(for: station(), fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(missing.status, .unknown)
|
||||||
|
XCTAssertEqual(missing.source, .unknown)
|
||||||
|
XCTAssertFalse(missing.isEligibleForBestValue)
|
||||||
|
|
||||||
|
let malformed = FuelPriceFreshness.assessment(
|
||||||
|
priceUpdated: "not-a-date", priceUpdatedByGrade: nil, fuel: .diesel, now: now
|
||||||
|
)
|
||||||
|
XCTAssertEqual(malformed.status, .invalid)
|
||||||
|
XCTAssertFalse(malformed.isEligibleForBestValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStaleDisplayLabelDoesNotCallIneligiblePriceProvisional() {
|
||||||
|
let stale = station(priceUpdatedByFuel: [
|
||||||
|
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
|
||||||
|
])
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
|
||||||
|
XCTAssertFalse(assessment.isEligibleForBestValue)
|
||||||
|
XCTAssertFalse(assessment.displayLabel.contains("provisional"))
|
||||||
|
XCTAssertTrue(assessment.displayLabel.contains("outdated"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStaleAndFutureGradeTimestampsAreRejected() {
|
||||||
|
let stale = station(priceUpdatedByFuel: [
|
||||||
|
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
|
||||||
|
])
|
||||||
|
let staleAssessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(staleAssessment.status, .stale)
|
||||||
|
XCTAssertFalse(staleAssessment.isEligibleForBestValue)
|
||||||
|
XCTAssertTrue(staleAssessment.warning)
|
||||||
|
|
||||||
|
let future = station(priceUpdatedByFuel: [
|
||||||
|
.diesel: now.addingTimeInterval(FuelPriceFreshness.futureTolerance + 1).timeIntervalSince1970
|
||||||
|
])
|
||||||
|
let futureAssessment = FuelPriceFreshness.assessment(for: future, fuel: .diesel, now: now)
|
||||||
|
XCTAssertEqual(futureAssessment.status, .invalid)
|
||||||
|
XCTAssertFalse(futureAssessment.isEligibleForBestValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMalformedAndFutureSourceValuesDoNotCrashOrProduceNegativeAge() {
|
||||||
|
let malformed = FuelPriceFreshness.assessment(
|
||||||
|
priceUpdated: "2026-99-99T99:99:99Z",
|
||||||
|
priceUpdatedByGrade: ["DIESEL": "2026-99-99T99:99:99Z"],
|
||||||
|
fuel: .diesel,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
XCTAssertNil(malformed.age)
|
||||||
|
XCTAssertFalse(malformed.isEligibleForBestValue)
|
||||||
|
|
||||||
|
let future = FuelPriceFreshness.assessment(
|
||||||
|
priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970,
|
||||||
|
priceUpdatedByFuel: nil,
|
||||||
|
fuel: .diesel,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
XCTAssertNil(future.age)
|
||||||
|
XCTAssertFalse(future.isEligibleForBestValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ISO8601DateFormatter {
|
||||||
|
static func fuelBoardDate(_ string: String) -> Date? {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter.date(from: string) ?? {
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: string)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,42 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - 1. Unit conversion
|
// MARK: - 1. Unit conversion
|
||||||
|
|
||||||
|
func testBudgetExamplesAcrossAllFuelTypes() throws {
|
||||||
|
for fuel in FuelType.allCases {
|
||||||
|
let base = FuelStation(id: "base", name: "Base", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 199.9], priceUpdated: nil)
|
||||||
|
let other = FuelStation(id: "other", name: "Other", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 129.9], priceUpdated: nil)
|
||||||
|
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 40, unit: .pounds))
|
||||||
|
for budget in [20.0, 40, 60, 73, 100] {
|
||||||
|
let result = FuelSavingsEstimator.availability(station: other, fuel: fuel, baseline: base,
|
||||||
|
settings: settings, amount: .init(value: budget, unit: .pounds),
|
||||||
|
journey: .init(miles: 8.3, basis: .roadRoute),
|
||||||
|
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||||
|
let volume = budget / 1.299
|
||||||
|
XCTAssertEqual(result?.fillLitres ?? 0, volume, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result?.grossSavingGBP ?? 0, volume * 0.7, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result?.netSavingGBP ?? 0, volume * 0.7 - 7.4 / 30 * 4.54609 * 1.299, accuracy: 1e-9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCandidateBudgetAndEqualQuantitySavingsForEveryFuel() throws {
|
||||||
|
for fuel in FuelType.allCases {
|
||||||
|
let candidate = FuelStation(id: "candidate-\(fuel.rawValue)", name: "Candidate", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 129.9], priceUpdated: nil)
|
||||||
|
let baseline = FuelStation(id: "baseline-\(fuel.rawValue)", name: "Nearest", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 199.9], priceUpdated: nil)
|
||||||
|
let configured = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
|
||||||
|
let money = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
|
||||||
|
amount: .init(value: 60, unit: .pounds), journey: .init(miles: 8.3, basis: .roadRoute),
|
||||||
|
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||||
|
let litres = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
|
||||||
|
amount: .init(value: 60 / 1.299, unit: .litres), journey: .init(miles: 8.3, basis: .roadRoute),
|
||||||
|
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||||
|
XCTAssertEqual(money?.fillLitres ?? 0, 60 / 1.299, accuracy: 1e-8, fuel.rawValue)
|
||||||
|
XCTAssertEqual(money?.grossSavingGBP ?? 0, litres?.grossSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
|
||||||
|
XCTAssertEqual(money?.netSavingGBP ?? 0, litres?.netSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
|
||||||
|
XCTAssertEqual(money?.netSavingGBP ?? 0, (money?.grossSavingGBP ?? 0) - (money?.extraDrivingCostGBP ?? 0), accuracy: 1e-8, fuel.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testImperialGallonIsUsedNotTheUSGallon() {
|
func testImperialGallonIsUsedNotTheUSGallon() {
|
||||||
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
|
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
|
||||||
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
|
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
|
||||||
@@ -497,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
)?.result
|
)?.result
|
||||||
XCTAssertEqual(result?.status, .baseline)
|
XCTAssertEqual(result?.status, .baseline)
|
||||||
XCTAssertEqual(result?.netSavingGBP, 0)
|
XCTAssertEqual(result?.netSavingGBP, 0)
|
||||||
XCTAssertEqual(result?.headline(), "Closest station")
|
XCTAssertEqual(result?.headline(), "Closest eligible station")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
|
||||||
|
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: 199.9], priceUpdated: nil)
|
||||||
|
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.diesel: 129.9], priceUpdated: nil)
|
||||||
|
let nearest = FuelSavingsEstimator.baselineStation([petrolOnly, dieselOnly], fuel: .diesel) { station in
|
||||||
|
self.straightLine(station.id == "petrol" ? 0.5 : 1)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(nearest?.id, "diesel")
|
||||||
|
let unavailable = FuelSavingsEstimator.availability(station: dieselOnly, fuel: .diesel, baseline: petrolOnly,
|
||||||
|
settings: settings, amount: amount, journey: straightLine(1), baselineJourney: straightLine(0.5))
|
||||||
|
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
|
||||||
|
}
|
||||||
|
|
||||||
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||||||
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||||||
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||||||
@@ -85,6 +99,24 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
"unmeasured routes are reported as straight-line estimates")
|
"unmeasured routes are reported as straight-line estimates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testBaselineOutsideFilteredPoolStillRanksByNetSaving() {
|
||||||
|
let baseline = station("nearest-outside-radius", price: 199.9)
|
||||||
|
let cheap = station("z-cheap", price: 129.9)
|
||||||
|
let expensive = station("a-expensive", price: 198.9)
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
[expensive, cheap], fuel: .e10, baseline: baseline,
|
||||||
|
settings: settings, amount: amount,
|
||||||
|
journeyMiles: { s in self.straightLine(s.id == baseline.id ? 0.8 : 4.0) }
|
||||||
|
)
|
||||||
|
XCTAssertEqual(ranked.map(\.id), ["z-cheap", "a-expensive"])
|
||||||
|
let expected = FuelSavingsEstimator.availability(
|
||||||
|
station: cheap, fuel: .e10, baseline: baseline,
|
||||||
|
settings: settings, amount: amount,
|
||||||
|
journey: straightLine(4.0), baselineJourney: straightLine(0.8)
|
||||||
|
)?.result
|
||||||
|
XCTAssertGreaterThan(expected?.netSavingGBP ?? -1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||||||
// 40 stations, but only the first one has a measured road route — the
|
// 40 stations, but only the first one has a measured road route — the
|
||||||
// old implementation capped the whole ranking to 12 shortlisted
|
// old implementation capped the whole ranking to 12 shortlisted
|
||||||
@@ -159,6 +191,56 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testBestValueBaselineSkipsClosestStalePriceButKeepsThatStationVisible() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
let staleClosest = priced("stale-closest", 199.9, 27)
|
||||||
|
let eligibleClosest = priced("eligible-closest", 170.9, 1)
|
||||||
|
let eligibleFurther = priced("eligible-further", 150.9, 1)
|
||||||
|
let pool = [staleClosest, eligibleClosest, eligibleFurther]
|
||||||
|
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
|
||||||
|
self.straightLine(station.id == "stale-closest" ? 0.8 : station.id == "eligible-closest" ? 1.6 : 3.0)
|
||||||
|
}
|
||||||
|
let baseline = FuelSavingsEstimator.bestValueBaselineStation(pool, fuel: .e10,
|
||||||
|
journeyMiles: journeys, now: now)
|
||||||
|
XCTAssertEqual(baseline?.id, "eligible-closest")
|
||||||
|
XCTAssertEqual(FuelSavingsEstimator.baselineStation(pool, fuel: .e10, journeyMiles: journeys)?.id,
|
||||||
|
"stale-closest", "non-Best-Value closest behaviour must stay unchanged")
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: baseline,
|
||||||
|
settings: briefSettings, amount: briefAmount, journeyMiles: journeys, now: now)
|
||||||
|
XCTAssertEqual(ranked.first?.id, "eligible-further")
|
||||||
|
XCTAssertTrue(ranked.contains(where: { $0.id == "stale-closest" }))
|
||||||
|
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: staleClosest, fuel: .e10,
|
||||||
|
baseline: baseline, now: now))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueBaselineRequiresEligibleSelectedFuelAndUsableJourney() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||||||
|
let stale = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
|
||||||
|
let stations = [
|
||||||
|
FuelStation(id: "wrong-fuel", name: "Wrong", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||||
|
prices: [.diesel: 140], priceUpdated: recent),
|
||||||
|
FuelStation(id: "stale", name: "Stale", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||||
|
prices: [.e10: 140], priceUpdated: stale),
|
||||||
|
FuelStation(id: "bad-journey", name: "Bad journey", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||||
|
prices: [.e10: 141], priceUpdated: recent),
|
||||||
|
FuelStation(id: "eligible", name: "Eligible", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||||
|
prices: [.e10: 145], priceUpdated: recent)
|
||||||
|
]
|
||||||
|
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
|
||||||
|
self.straightLine(station.id == "wrong-fuel" ? 0.1 : station.id == "stale" ? 0.2 :
|
||||||
|
station.id == "bad-journey" ? .infinity : 2)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: .e10,
|
||||||
|
journeyMiles: journeys, now: now)?.id, "eligible")
|
||||||
|
XCTAssertNil(FuelSavingsEstimator.bestValueBaselineStation(Array(stations.prefix(2)), fuel: .e10,
|
||||||
|
journeyMiles: journeys, now: now))
|
||||||
|
}
|
||||||
|
|
||||||
func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
|
func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
|
||||||
let geographicallyNearest = station("geographically-nearest", price: 145.9)
|
let geographicallyNearest = station("geographically-nearest", price: 145.9)
|
||||||
let roadNearest = station("road-nearest", price: 146.9)
|
let roadNearest = station("road-nearest", price: 146.9)
|
||||||
@@ -350,8 +432,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(availability?.result?.status, .baseline)
|
XCTAssertEqual(availability?.result?.status, .baseline)
|
||||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
|
||||||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
|
||||||
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||||||
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||||||
}
|
}
|
||||||
@@ -403,7 +485,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
|
|
||||||
/// The exact strings the brief asks for.
|
/// The exact strings the brief asks for.
|
||||||
func testHeadlineWordingMatchesTheBrief() {
|
func testHeadlineWordingMatchesTheBrief() {
|
||||||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
|
||||||
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||||||
|
|
||||||
let saving = FuelSavingsResult(fillLitres: 34.9,
|
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||||||
@@ -427,10 +509,87 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
|
|
||||||
/// The sort must order by NET saving, not pump price: a pricier station can
|
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||||
/// win when the cheaper one is far enough away.
|
/// win when the cheaper one is far enough away.
|
||||||
|
func testBestValueAlertUsesClosestEligiblePriceAsBaseline() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
let stations = [priced("stale-closest", 199.9, 27), priced("eligible-closest", 170.9, 1),
|
||||||
|
priced("eligible-cheaper", 150.9, 1)]
|
||||||
|
let settings = FuelSavingsSettings(enabled: true, mpg: 30,
|
||||||
|
preferredAmount: .init(value: 60, unit: .pounds))
|
||||||
|
let alert = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||||
|
stations: stations, fuel: .e10, settings: settings, amount: settings.preferredAmount,
|
||||||
|
minimumNetSavingGBP: 1,
|
||||||
|
journeyMiles: { self.straightLine($0.id == "stale-closest" ? 0.8 : $0.id == "eligible-closest" ? 1.6 : 3) },
|
||||||
|
now: now)
|
||||||
|
XCTAssertEqual(alert?.station.id, "eligible-cheaper")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueAlertDoesNotRecommendStaleCandidateOrStaleBaseline() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
let base = priced("base", 199.9, 1)
|
||||||
|
let stale = priced("stale", 129.9, 40)
|
||||||
|
let fresh = priced("fresh", 159.9, 1)
|
||||||
|
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
|
||||||
|
let choice = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||||
|
stations: [base, stale, fresh], fuel: .e10, settings: settings,
|
||||||
|
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
|
||||||
|
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
|
||||||
|
XCTAssertEqual(choice?.station.id, "fresh")
|
||||||
|
let noTrustedBaseline = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||||
|
stations: [priced("base", 199.9, 40), fresh], fuel: .e10, settings: settings,
|
||||||
|
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
|
||||||
|
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
|
||||||
|
XCTAssertNil(noTrustedBaseline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStalePriceCannotLeadBestValueButRemainsVisible() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let baseline = FuelStation(id: "base", name: "Baseline", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: 199.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
|
||||||
|
let stale = FuelStation(id: "stale-cheap", name: "Stale", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: 129.9], priceUpdated: now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970)
|
||||||
|
let recent = FuelStation(id: "recent", name: "Recent", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: 170.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
|
||||||
|
let ranked = FuelSavingsEstimator.ranked([stale, recent], fuel: .e10, baseline: baseline,
|
||||||
|
settings: briefSettings, amount: briefAmount,
|
||||||
|
journeyMiles: { _ in self.straightLine(0.8) }, now: now)
|
||||||
|
XCTAssertEqual(ranked.map(\.id), ["recent", "stale-cheap"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoFreshPriceComparisonUsesExplicitCheapestPriceFallback() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let staleTime = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
|
||||||
|
let baseline = FuelStation(id: "baseline", name: "Baseline", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.diesel: 199.9], priceUpdated: staleTime)
|
||||||
|
let cheapest = FuelStation(id: "cheapest", name: "Cheapest", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: staleTime)
|
||||||
|
let near = FuelStation(id: "near", name: "Near", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.diesel: 170.9], priceUpdated: staleTime)
|
||||||
|
let ordered = FuelSavingsEstimator.ranked([near, baseline, cheapest], fuel: .diesel,
|
||||||
|
baseline: baseline, settings: briefSettings, amount: briefAmount,
|
||||||
|
journeyMiles: { station in self.straightLine(station.id == "cheapest" ? 250 : 1) }, now: now)
|
||||||
|
XCTAssertEqual(ordered.map(\.id), ["cheapest", "near", "baseline"])
|
||||||
|
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: ordered[0], fuel: .diesel,
|
||||||
|
baseline: baseline, now: now))
|
||||||
|
}
|
||||||
|
|
||||||
func testSortingRanksByNetSavingNotPumpPrice() {
|
func testSortingRanksByNetSavingNotPumpPrice() {
|
||||||
let baseline = station("baseline", price: 171.9)
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
let cheapButFar = station("cheap-but-far", price: 167.9)
|
func pricedStation(_ id: String, price: Double) -> FuelStation {
|
||||||
let pricierButNear = station("pricier-but-near", price: 169.9)
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||||||
|
lat: 51.5, lng: -0.1, prices: [.e10: price],
|
||||||
|
priceUpdated: nil, priceUpdatedByFuel: [.e10: now.timeIntervalSince1970])
|
||||||
|
}
|
||||||
|
let baseline = pricedStation("baseline", price: 171.9)
|
||||||
|
let cheapButFar = pricedStation("cheap-but-far", price: 167.9)
|
||||||
|
let pricierButNear = pricedStation("pricier-but-near", price: 169.9)
|
||||||
|
|
||||||
let ranked = FuelSavingsEstimator.ranked(
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
[baseline, cheapButFar, pricierButNear],
|
[baseline, cheapButFar, pricierButNear],
|
||||||
@@ -444,7 +603,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
case "cheap-but-far": return self.straightLine(7.6)
|
case "cheap-but-far": return self.straightLine(7.6)
|
||||||
default: return self.straightLine(1.2)
|
default: return self.straightLine(1.2)
|
||||||
}
|
}
|
||||||
}
|
}, now: now
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||||||
@@ -454,18 +613,18 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: Row copy
|
// MARK: Row copy
|
||||||
|
|
||||||
func testCompactComparisonLabelUsesGenericNearestStationWording() {
|
func testCompactComparisonLabelUsesClosestEligibleStationWording() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
||||||
"vs closest station"
|
"vs closest eligible station"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
||||||
"vs closest station"
|
"vs closest eligible station"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
||||||
"vs closest station"
|
"vs closest eligible station"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
||||||
@@ -494,7 +653,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The baseline capsule has to say what it IS: it previously repeated the
|
/// The baseline capsule has to say what it IS: it previously repeated the
|
||||||
/// status word above it ("Closest station" twice) and never explained that
|
/// status word above it ("Closest eligible station" twice) and never explained that
|
||||||
/// the savings are measured against it.
|
/// the savings are measured against it.
|
||||||
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
||||||
let baseline = FuelSavingsResult(
|
let baseline = FuelSavingsResult(
|
||||||
@@ -505,7 +664,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
netSavingGBP: 0,
|
netSavingGBP: 0,
|
||||||
status: .baseline
|
status: .baseline
|
||||||
)
|
)
|
||||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
|
XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
|
||||||
|
|
||||||
let saving = FuelSavingsResult(
|
let saving = FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
|
|||||||
@@ -110,10 +110,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
|
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
let stations = [
|
let stations = [
|
||||||
station("baseline", lat: 0, lng: 0, price: 160),
|
station("baseline", lat: 0, lng: 0, price: 160, priceUpdated: now.timeIntervalSince1970),
|
||||||
station("small", lat: 0, lng: 0, price: 154),
|
station("small", lat: 0, lng: 0, price: 154, priceUpdated: now.timeIntervalSince1970),
|
||||||
station("best", lat: 0, lng: 0, price: 148),
|
station("best", lat: 0, lng: 0, price: 148, priceUpdated: now.timeIntervalSince1970),
|
||||||
]
|
]
|
||||||
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
|
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
|
||||||
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||||
@@ -123,7 +124,8 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
settings: settings,
|
settings: settings,
|
||||||
amount: settings.preferredAmount,
|
amount: settings.preferredAmount,
|
||||||
minimumNetSavingGBP: 1.0,
|
minimumNetSavingGBP: 1.0,
|
||||||
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } },
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
XCTAssertEqual(candidate?.station.id, "best")
|
XCTAssertEqual(candidate?.station.id, "best")
|
||||||
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
|
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
|
||||||
@@ -137,12 +139,23 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
|
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation {
|
private func station(_ id: String, lat: Double, lng: Double, price: Double,
|
||||||
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
|
priceUpdated: TimeInterval? = nil) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng,
|
||||||
|
prices: [.e10: price], priceUpdated: priceUpdated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class FuelSavingsSettingsTests: XCTestCase {
|
final class FuelSavingsSettingsTests: XCTestCase {
|
||||||
|
func testSwitchingFillUnitStartsAnExplicitNewComparison() {
|
||||||
|
let budget = FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
let litres = FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .litres)
|
||||||
|
XCTAssertEqual(litres, FuelSavingsFillAmount(value: 30, unit: .litres))
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: litres, to: .pounds),
|
||||||
|
FuelSavingsFillAmount(value: 40, unit: .pounds))
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .pounds), budget)
|
||||||
|
}
|
||||||
|
|
||||||
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
||||||
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
// 3) Load + order stations per widget mode.
|
// 3) Load + order stations per widget mode.
|
||||||
let ordered: [FuelStation]
|
let ordered: [FuelStation]
|
||||||
var bestValueResult: FuelSavingsResult?
|
var bestValueResult: FuelSavingsResult?
|
||||||
|
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||||||
|
var bestValuePriceWarning: String?
|
||||||
if isFavourites {
|
if isFavourites {
|
||||||
let favourites = FuelStore.loadFavourites()
|
let favourites = FuelStore.loadFavourites()
|
||||||
.filter { $0.station.prices[$0.fuel] != nil }
|
.filter { $0.station.prices[$0.fuel] != nil }
|
||||||
@@ -306,7 +308,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
let amount = settings.preferredAmount
|
let amount = settings.preferredAmount
|
||||||
let baseline = FuelSavingsEstimator.baselineStation(filtered, fuel: fuel, journeyMiles: journey)
|
let baseline = FuelSavingsEstimator.bestValueBaselineStation(filtered, fuel: fuel,
|
||||||
|
journeyMiles: journey)
|
||||||
let ranked = FuelSavingsEstimator.ranked(
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
filtered,
|
filtered,
|
||||||
fuel: fuel,
|
fuel: fuel,
|
||||||
@@ -317,6 +320,13 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
)
|
)
|
||||||
ordered = ranked
|
ordered = ranked
|
||||||
if let first = ranked.first, let baseline {
|
if let first = ranked.first, let baseline {
|
||||||
|
bestValuePriceConfidence = FuelPriceFreshness.comparisonConfidence(
|
||||||
|
station: first, fuel: fuel, baseline: baseline)
|
||||||
|
bestValuePriceWarning = FuelPriceFreshness.comparisonWarning(
|
||||||
|
station: first, fuel: fuel, baseline: baseline)
|
||||||
|
}
|
||||||
|
if let first = ranked.first, let baseline,
|
||||||
|
bestValuePriceConfidence != .ineligible {
|
||||||
let baselineJourney = journey(baseline)
|
let baselineJourney = journey(baseline)
|
||||||
let result = FuelSavingsEstimator.availability(
|
let result = FuelSavingsEstimator.availability(
|
||||||
station: first,
|
station: first,
|
||||||
@@ -348,7 +358,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
date: Date(), stations: Array(ordered.prefix(8)),
|
date: Date(), stations: Array(ordered.prefix(8)),
|
||||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||||
location: origin, locationSource: source,
|
location: origin, locationSource: source,
|
||||||
unit: unit, bestValueResult: bestValueResult
|
unit: unit, bestValueResult: bestValueResult,
|
||||||
|
bestValuePriceConfidence: bestValuePriceConfidence,
|
||||||
|
bestValuePriceWarning: bestValuePriceWarning
|
||||||
)
|
)
|
||||||
), originSource)
|
), originSource)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FuelPriceTimestamp {
|
||||||
|
static func parse(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter.date(from: value) ?? {
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: value)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelPriceFreshness {
|
||||||
|
static let warningAge: TimeInterval = 7 * 24 * 60 * 60
|
||||||
|
static let staleAge: TimeInterval = 30 * 24 * 60 * 60
|
||||||
|
static let futureTolerance: TimeInterval = 5 * 60
|
||||||
|
|
||||||
|
enum Source: Equatable { case gradeSpecific, stationWide, unknown }
|
||||||
|
enum Status: Equatable { case fresh, warning, stale, unknown, invalid }
|
||||||
|
enum Confidence: Equatable { case verified, provisional, unavailable }
|
||||||
|
enum RecommendationEligibility: Equatable { case eligible, ineligible }
|
||||||
|
|
||||||
|
struct Assessment: Equatable {
|
||||||
|
let source: Source
|
||||||
|
let status: Status
|
||||||
|
let age: TimeInterval?
|
||||||
|
let confidence: Confidence
|
||||||
|
let recommendationEligibility: RecommendationEligibility
|
||||||
|
let warning: Bool
|
||||||
|
|
||||||
|
var isEligibleForBestValue: Bool {
|
||||||
|
recommendationEligibility == .eligible
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one age More info reports. Production data carries a single
|
||||||
|
/// station-wide timestamp per station, so there is no separate
|
||||||
|
/// grade-level age to show; an absent or unreadable timestamp reads
|
||||||
|
/// "Not provided" rather than implying freshness.
|
||||||
|
var updateAgeLabel: String {
|
||||||
|
guard let age else {
|
||||||
|
return String(localized: "Not provided", comment: "Price update time is missing")
|
||||||
|
}
|
||||||
|
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
||||||
|
Self.ageText(age))
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayLabel: String {
|
||||||
|
switch (source, status, age) {
|
||||||
|
case (_, .unknown, _): return "Price age unknown"
|
||||||
|
case (_, .invalid, _): return "Price timestamp invalid"
|
||||||
|
case (_, .stale, let age?): return "Price updated \(Self.ageText(age)) ago · may be outdated"
|
||||||
|
case (.stationWide, _, let age?): return "Price updated \(Self.ageText(age)) ago · station-wide · provisional"
|
||||||
|
case (_, .warning, let age?): return "Price updated \(Self.ageText(age)) ago"
|
||||||
|
case (.gradeSpecific, .fresh, let age?): return "Price updated \(Self.ageText(age)) ago"
|
||||||
|
default: return "Price age unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ageText(_ age: TimeInterval) -> String {
|
||||||
|
let minutes = max(0, Int(age / 60))
|
||||||
|
if minutes < 60 { return "\(minutes)m" }
|
||||||
|
let hours = minutes / 60
|
||||||
|
if hours < 48 { return "\(hours)h" }
|
||||||
|
return "\(hours / 24)d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func assessment(for station: FuelStation, fuel: FuelType, now: Date = Date()) -> Assessment {
|
||||||
|
if station.priceUpdatedByFuelRaw?[fuel] != nil && station.priceUpdatedByFuel?[fuel] == nil {
|
||||||
|
return invalid(source: .gradeSpecific)
|
||||||
|
}
|
||||||
|
if let timestamp = station.priceUpdatedByFuel?[fuel] {
|
||||||
|
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
|
||||||
|
}
|
||||||
|
guard let timestamp = station.priceUpdated else { return unknown() }
|
||||||
|
return assess(timestamp: timestamp, source: .stationWide, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommendation gate for Best Value. A station-wide timestamp under
|
||||||
|
/// seven days can support a provisional estimate. Older station-wide
|
||||||
|
/// reports cannot establish grade recency; grade-specific timestamps may
|
||||||
|
/// remain eligible up to thirty days, but carry an age warning.
|
||||||
|
static func isEligibleForBestValue(station: FuelStation, fuel: FuelType,
|
||||||
|
now: Date = Date()) -> Bool {
|
||||||
|
assessment(for: station, fuel: fuel, now: now).isEligibleForBestValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func assessment(priceUpdated: String?, priceUpdatedByGrade: [String: String]?,
|
||||||
|
fuel: FuelType, now: Date = Date()) -> Assessment {
|
||||||
|
if let raw = priceUpdatedByGrade?[fuel.rawValue] {
|
||||||
|
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid(source: .gradeSpecific) }
|
||||||
|
return assess(timestamp: date.timeIntervalSince1970, source: .gradeSpecific, now: now)
|
||||||
|
}
|
||||||
|
guard let raw = priceUpdated else { return unknown() }
|
||||||
|
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid() }
|
||||||
|
return assess(timestamp: date.timeIntervalSince1970, source: .stationWide, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func assessment(priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]?,
|
||||||
|
fuel: FuelType, now: Date = Date()) -> Assessment {
|
||||||
|
if let timestamp = priceUpdatedByFuel?[fuel] {
|
||||||
|
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
|
||||||
|
}
|
||||||
|
guard let timestamp = priceUpdated else { return unknown() }
|
||||||
|
return assess(timestamp: timestamp, source: .stationWide, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confidence for a monetary comparison requires both selected-grade prices.
|
||||||
|
/// Keep the eligibility and label decision together for app and widgets.
|
||||||
|
enum ComparisonConfidence: Equatable {
|
||||||
|
case verified, provisional, ineligible
|
||||||
|
|
||||||
|
static func assess(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> Self {
|
||||||
|
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only an ineligible comparison needs a line of its own. The capsule
|
||||||
|
/// already presents a provisional saving as an estimate, so a second
|
||||||
|
/// line saying so crowded the rows and the detail sheet without
|
||||||
|
/// changing anything the user could do.
|
||||||
|
var warningText: String? {
|
||||||
|
switch self {
|
||||||
|
case .verified, .provisional: return nil
|
||||||
|
case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short line for a comparison that cannot be recommended at all; rows
|
||||||
|
/// stay quiet otherwise. Naming the fuel, and which side of the comparison
|
||||||
|
/// was weak, was noise once the saving is already labelled an estimate.
|
||||||
|
static func comparisonWarning(station: FuelStation, fuel: FuelType,
|
||||||
|
baseline: FuelStation?, now: Date = Date()) -> String? {
|
||||||
|
comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now).warningText
|
||||||
|
}
|
||||||
|
|
||||||
|
static func comparisonConfidence(station: FuelStation, fuel: FuelType,
|
||||||
|
baseline: FuelStation?, now: Date = Date()) -> ComparisonConfidence {
|
||||||
|
guard let baseline,
|
||||||
|
let candidatePrice = station.prices[fuel], candidatePrice.isFinite, candidatePrice > 0,
|
||||||
|
let baselinePrice = baseline.prices[fuel], baselinePrice.isFinite, baselinePrice > 0
|
||||||
|
else { return .ineligible }
|
||||||
|
let candidate = assessment(for: station, fuel: fuel, now: now)
|
||||||
|
let reference = assessment(for: baseline, fuel: fuel, now: now)
|
||||||
|
guard candidate.isEligibleForBestValue, reference.isEligibleForBestValue else { return .ineligible }
|
||||||
|
if candidate.confidence == .verified, reference.confidence == .verified,
|
||||||
|
candidate.status == .fresh, reference.status == .fresh { return .verified }
|
||||||
|
return .provisional
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func unknown() -> Assessment {
|
||||||
|
Assessment(source: .unknown, status: .unknown, age: nil,
|
||||||
|
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func invalid(source: Source = .unknown) -> Assessment {
|
||||||
|
Assessment(source: source, status: .invalid, age: nil,
|
||||||
|
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func assess(timestamp: TimeInterval, source: Source, now: Date) -> Assessment {
|
||||||
|
let age = now.timeIntervalSince1970 - timestamp
|
||||||
|
guard age >= -futureTolerance else {
|
||||||
|
return Assessment(source: source, status: .invalid, age: nil,
|
||||||
|
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
|
||||||
|
}
|
||||||
|
let boundedAge = max(0, age)
|
||||||
|
let status: Status = boundedAge > staleAge ? .stale : boundedAge > warningAge ? .warning : .fresh
|
||||||
|
let confidence: Confidence = source == .gradeSpecific ? .verified : .provisional
|
||||||
|
let eligible = status == .fresh || (source == .gradeSpecific && status == .warning)
|
||||||
|
return Assessment(source: source, status: status, age: boundedAge,
|
||||||
|
confidence: confidence,
|
||||||
|
recommendationEligibility: eligible ? .eligible : .ineligible,
|
||||||
|
warning: source != .gradeSpecific || status != .fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,9 @@ enum FuelPriceProvider {
|
|||||||
lat: relay.lat ?? 0,
|
lat: relay.lat ?? 0,
|
||||||
lng: relay.lng ?? 0,
|
lng: relay.lng ?? 0,
|
||||||
prices: relay.allPrices,
|
prices: relay.allPrices,
|
||||||
priceUpdated: nil
|
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970,
|
||||||
|
priceUpdatedByFuel: relay.priceUpdatedByFuel,
|
||||||
|
priceUpdatedByFuelRaw: relay.priceUpdatedByFuelRaw
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,15 @@ enum FuelPriceProvider {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func fuelType(for grade: String) -> FuelType? {
|
||||||
|
switch grade.uppercased() {
|
||||||
|
case "E10": return .e10
|
||||||
|
case "E5": return .e5
|
||||||
|
case "DIESEL", "B7", "B7S", "B7P", "B10": return .diesel
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
||||||
/// About section shows these so the user can see which data source is
|
/// About section shows these so the user can see which data source is
|
||||||
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
||||||
@@ -106,6 +117,41 @@ private struct MirrorResponse: Codable {
|
|||||||
let lng: Double?
|
let lng: Double?
|
||||||
let price: Double?
|
let price: Double?
|
||||||
let prices: [String: Double]?
|
let prices: [String: Double]?
|
||||||
|
let priceUpdated: String?
|
||||||
|
let priceUpdatedByGrade: [String: String]?
|
||||||
|
let priceUpdatedByFuelRawValues: [String: String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, brand, address, postcode, lat, lng, price, prices
|
||||||
|
case priceUpdated = "price_updated"
|
||||||
|
case priceUpdatedByGrade = "price_updated_by_grade"
|
||||||
|
case priceUpdatedByFuelRawValues = "price_updated_by_fuel"
|
||||||
|
}
|
||||||
|
|
||||||
|
var priceUpdatedDate: Date? {
|
||||||
|
FuelPriceTimestamp.parse(priceUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var priceUpdatedByFuelRaw: [FuelType: String]? {
|
||||||
|
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
|
||||||
|
guard let raw else { return nil }
|
||||||
|
let parsed = raw.reduce(into: [FuelType: String]()) { result, item in
|
||||||
|
guard let fuel = FuelPriceProvider.fuelType(for: item.key) else { return }
|
||||||
|
result[fuel] = item.value
|
||||||
|
}
|
||||||
|
return parsed.isEmpty ? nil : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
var priceUpdatedByFuel: [FuelType: TimeInterval]? {
|
||||||
|
let raw = priceUpdatedByGrade ?? priceUpdatedByFuelRawValues
|
||||||
|
guard let raw else { return nil }
|
||||||
|
let parsed = raw.reduce(into: [FuelType: TimeInterval]()) { result, item in
|
||||||
|
guard let fuel = FuelPriceProvider.fuelType(for: item.key),
|
||||||
|
let date = FuelPriceTimestamp.parse(item.value) else { return }
|
||||||
|
result[fuel] = date.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
return parsed.isEmpty ? nil : parsed
|
||||||
|
}
|
||||||
|
|
||||||
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
||||||
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ struct FuelPriceWidgetEntryData {
|
|||||||
let locationSource: String // "live" | "cached" | "none"
|
let locationSource: String // "live" | "cached" | "none"
|
||||||
let unit: DistanceUnit // user's display unit for distances
|
let unit: DistanceUnit // user's display unit for distances
|
||||||
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
|
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
|
||||||
|
/// Both selected-fuel prices are assessed by the shared freshness policy.
|
||||||
|
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||||||
|
/// Candidate-aware warning copy (names the fuel, and the baseline when the
|
||||||
|
/// baseline is the weak link). Falls back to the confidence's own text.
|
||||||
|
var bestValuePriceWarning: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The widget face, parameterised by family so the app can preview it.
|
/// The widget face, parameterised by family so the app can preview it.
|
||||||
@@ -92,10 +97,31 @@ struct FuelPriceWidgetContent: View {
|
|||||||
Text(result.headline(compact: true))
|
Text(result.headline(compact: true))
|
||||||
.font(.title3.weight(.bold))
|
.font(.title3.weight(.bold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(result.status == .baseline ? "your baseline" : "vs closest station")
|
Text(result.status == .baseline ? "your baseline" : "vs closest eligible station")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
if let warning = entry.bestValuePriceWarning ?? entry.bestValuePriceConfidence.warningText {
|
||||||
|
Text(warning)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
} else if entry.sort == .worthTheDrive {
|
||||||
|
if let price = station.prices[entry.fuel] {
|
||||||
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
|
}
|
||||||
|
if entry.location == nil {
|
||||||
|
Text("Best Value unavailable · location needed")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.lineLimit(2)
|
||||||
|
} else {
|
||||||
|
Text("Best Value unavailable · price confidence insufficient")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
} else if let price = station.prices[entry.fuel] {
|
} else if let price = station.prices[entry.fuel] {
|
||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
@@ -137,7 +163,9 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(entry.isFavourites ? "cheapest first"
|
Text(entry.sort == .worthTheDrive && entry.bestValuePriceConfidence == .ineligible
|
||||||
|
? "price order · Best Value unavailable"
|
||||||
|
: entry.isFavourites ? "your order"
|
||||||
: (entry.locationSource == "none" ? "by price" : "near you"))
|
: (entry.locationSource == "none" ? "by price" : "near you"))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
+76
-23
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
|
|||||||
case breakEven
|
case breakEven
|
||||||
case notWorthTheDrive
|
case notWorthTheDrive
|
||||||
/// The comparison baseline itself — the station every other saving is
|
/// The comparison baseline itself — the station every other saving is
|
||||||
/// measured against. Shown as "Closest station", never "Break even".
|
/// measured against. Shown as "Closest eligible station", never "Break even".
|
||||||
case baseline
|
case baseline
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
@@ -147,7 +147,7 @@ enum FuelSavingsFormat {
|
|||||||
/// "Closest" refers to the existing shortest-journey baseline selection;
|
/// "Closest" refers to the existing shortest-journey baseline selection;
|
||||||
/// it does not claim a straight-line comparison.
|
/// it does not claim a straight-line comparison.
|
||||||
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
||||||
status == .baseline ? "your baseline" : "vs closest station"
|
status == .baseline ? "your baseline" : "vs closest eligible station"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
||||||
@@ -174,7 +174,7 @@ enum FuelSavingsFormat {
|
|||||||
|
|
||||||
extension FuelSavingsResult {
|
extension FuelSavingsResult {
|
||||||
/// Baseline row label — required wording.
|
/// Baseline row label — required wording.
|
||||||
static let baselineHeadline = "Closest station"
|
static let baselineHeadline = "Closest eligible station"
|
||||||
/// Neutral result label — required wording.
|
/// Neutral result label — required wording.
|
||||||
static let breakEvenHeadline = "Break even"
|
static let breakEvenHeadline = "Break even"
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ extension FuelSavingsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
||||||
/// travel", "Break even", "Closest station".
|
/// travel", "Break even", "Closest eligible station".
|
||||||
///
|
///
|
||||||
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||||||
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||||||
@@ -343,6 +343,19 @@ enum FuelSavingsCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
static func convert(amount: FuelSavingsFillAmount,
|
||||||
to unit: FuelSavingsFillUnit,
|
to unit: FuelSavingsFillUnit,
|
||||||
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
|
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
|
||||||
@@ -439,21 +452,24 @@ enum FuelSavingsCalculator {
|
|||||||
settings: FuelSavingsSettings,
|
settings: FuelSavingsSettings,
|
||||||
amount: FuelSavingsFillAmount,
|
amount: FuelSavingsFillAmount,
|
||||||
minimumNetSavingGBP: Double,
|
minimumNetSavingGBP: Double,
|
||||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||||
|
now: Date = Date()
|
||||||
) -> BestValueAlertCandidate? {
|
) -> BestValueAlertCandidate? {
|
||||||
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
|
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
|
||||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||||
guard !sellers.isEmpty else { return nil }
|
guard !sellers.isEmpty else { return nil }
|
||||||
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
|
let baseline = FuelSavingsEstimator.bestValueBaselineStation(sellers, fuel: fuel,
|
||||||
|
journeyMiles: journeyMiles, now: now)
|
||||||
|
guard let baseline else { return nil }
|
||||||
var best: BestValueAlertCandidate?
|
var best: BestValueAlertCandidate?
|
||||||
var bestDistance = Double.greatestFiniteMagnitude
|
var bestDistance = Double.greatestFiniteMagnitude
|
||||||
var bestPrice = Double.infinity
|
var bestPrice = Double.infinity
|
||||||
var baselineJourneyCache: FuelSavingsJourney?
|
var baselineJourneyCache: FuelSavingsJourney?
|
||||||
var baselineJourneyLoaded = false
|
var baselineJourneyLoaded = false
|
||||||
for station in sellers {
|
for station in sellers {
|
||||||
|
guard FuelPriceFreshness.isEligibleForBestValue(station: station, fuel: fuel, now: now) else { continue }
|
||||||
let journey = journeyMiles(station)
|
let journey = journeyMiles(station)
|
||||||
let baselineJourney: FuelSavingsJourney? = {
|
let baselineJourney: FuelSavingsJourney? = {
|
||||||
guard let baseline else { return nil }
|
|
||||||
if baseline.id == station.id { return journey }
|
if baseline.id == station.id { return journey }
|
||||||
if !baselineJourneyLoaded {
|
if !baselineJourneyLoaded {
|
||||||
baselineJourneyCache = journeyMiles(baseline)
|
baselineJourneyCache = journeyMiles(baseline)
|
||||||
@@ -497,12 +513,8 @@ enum FuelSavingsCalculator {
|
|||||||
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
||||||
/// rules are unit tested directly.
|
/// rules are unit tested directly.
|
||||||
enum FuelSavingsEstimator {
|
enum FuelSavingsEstimator {
|
||||||
/// Baseline station for Best Value: the station with the shortest journey
|
/// Closest priced station, irrespective of evidence age. Used for ordinary
|
||||||
/// among stations that sell the selected fuel, using the SAME journey source
|
/// Closest sorting, which must continue to include older prices.
|
||||||
/// 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],
|
static func baselineStation(_ stations: [FuelStation],
|
||||||
fuel: FuelType,
|
fuel: FuelType,
|
||||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
|
||||||
@@ -520,6 +532,17 @@ enum FuelSavingsEstimator {
|
|||||||
return bestStation
|
return bestStation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Closest selected-fuel seller with price evidence eligible for a Best Value
|
||||||
|
/// comparison. The ordinary Closest sort still includes every priced station.
|
||||||
|
static func bestValueBaselineStation(_ stations: [FuelStation],
|
||||||
|
fuel: FuelType,
|
||||||
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||||
|
now: Date = Date()) -> FuelStation? {
|
||||||
|
baselineStation(stations.filter {
|
||||||
|
FuelPriceFreshness.comparisonConfidence(station: $0, fuel: fuel, baseline: $0, now: now) != .ineligible
|
||||||
|
}, fuel: fuel, journeyMiles: journeyMiles)
|
||||||
|
}
|
||||||
|
|
||||||
/// Savings for a single station against `baseline`, or nil when there is
|
/// Savings for a single station against `baseline`, or nil when there is
|
||||||
/// nothing to show at all (feature off).
|
/// nothing to show at all (feature off).
|
||||||
static func availability(station: FuelStation,
|
static func availability(station: FuelStation,
|
||||||
@@ -561,20 +584,26 @@ enum FuelSavingsEstimator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ranks a whole pool for the Best Value sort.
|
/// Recommendation gate shared by Best Value order, widgets and alerts.
|
||||||
///
|
/// The baseline and candidate must both pass the selected-fuel freshness
|
||||||
/// EVERY priced station in the pool gets an estimate, not just the handful
|
/// policy. A recent station-wide timestamp remains provisional; missing,
|
||||||
/// whose road routes happen to have been measured: journeys that have no
|
/// invalid and over-threshold timestamps cannot lead a recommendation.
|
||||||
/// measured route fall back to a straight-line out-and-back estimate (see
|
static func recommendationEligible(station: FuelStation, fuel: FuelType,
|
||||||
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
|
baseline: FuelStation?, now: Date = Date()) -> Bool {
|
||||||
/// stations that were a few miles further away — the exact stations the
|
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) != .ineligible
|
||||||
/// sort exists to surface — and left later pages with no savings at all.
|
}
|
||||||
|
|
||||||
|
/// Ranks the whole station pool by valid full-precision savings. Stations
|
||||||
|
/// with an ineligible price stay visible behind eligible candidates; if
|
||||||
|
/// none qualify, the fallback order is selected-fuel pump price (then id),
|
||||||
|
/// not an untrustworthy savings order. The caller labels the fallback.
|
||||||
static func ranked(_ stations: [FuelStation],
|
static func ranked(_ stations: [FuelStation],
|
||||||
fuel: FuelType,
|
fuel: FuelType,
|
||||||
baseline: FuelStation?,
|
baseline: FuelStation?,
|
||||||
settings: FuelSavingsSettings,
|
settings: FuelSavingsSettings,
|
||||||
amount: FuelSavingsFillAmount,
|
amount: FuelSavingsFillAmount,
|
||||||
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?,
|
||||||
|
now: Date = Date()) -> [FuelStation] {
|
||||||
var journeys: [String: FuelSavingsJourney] = [:]
|
var journeys: [String: FuelSavingsJourney] = [:]
|
||||||
journeys.reserveCapacity(stations.count)
|
journeys.reserveCapacity(stations.count)
|
||||||
for station in stations {
|
for station in stations {
|
||||||
@@ -582,7 +611,21 @@ enum FuelSavingsEstimator {
|
|||||||
journeys[station.id] = journey
|
journeys[station.id] = journey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let baselineJourney = baseline.flatMap { journeys[$0.id] }
|
let baselineJourney = baseline.flatMap { journeys[$0.id] ?? journeyMiles($0) }
|
||||||
|
let hasEligibleComparison = stations.contains {
|
||||||
|
recommendationEligible(station: $0, fuel: fuel, baseline: baseline, now: now)
|
||||||
|
}
|
||||||
|
if !hasEligibleComparison {
|
||||||
|
// A net-savings order cannot be offered without trustworthy price
|
||||||
|
// evidence. Keep every station visible in an explicit price order;
|
||||||
|
// the caller labels the fallback as unavailable.
|
||||||
|
return stations.sorted { lhs, rhs in
|
||||||
|
let left = lhs.prices[fuel] ?? .infinity
|
||||||
|
let right = rhs.prices[fuel] ?? .infinity
|
||||||
|
if left != right { return left < right }
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
||||||
let journey = journeys[station.id]
|
let journey = journeys[station.id]
|
||||||
let availability = availability(station: station,
|
let availability = availability(station: station,
|
||||||
@@ -605,7 +648,17 @@ enum FuelSavingsEstimator {
|
|||||||
order[rankedStation.stationID] = offset
|
order[rankedStation.stationID] = offset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Compute freshness once per station, never in the O(n log n)
|
||||||
|
// comparator over the full UK pool.
|
||||||
|
var eligibility: [String: Bool] = [:]
|
||||||
|
eligibility.reserveCapacity(stations.count)
|
||||||
|
for station in stations {
|
||||||
|
eligibility[station.id] = recommendationEligible(station: station, fuel: fuel, baseline: baseline, now: now)
|
||||||
|
}
|
||||||
return stations.sorted { lhs, rhs in
|
return stations.sorted { lhs, rhs in
|
||||||
|
let leftEligible = eligibility[lhs.id] ?? false
|
||||||
|
let rightEligible = eligibility[rhs.id] ?? false
|
||||||
|
if leftEligible != rightEligible { return leftEligible }
|
||||||
let left = order[lhs.id] ?? Int.max
|
let left = order[lhs.id] ?? Int.max
|
||||||
let right = order[rhs.id] ?? Int.max
|
let right = order[rhs.id] ?? Int.max
|
||||||
if left != right { return left < right }
|
if left != right { return left < right }
|
||||||
|
|||||||
@@ -275,6 +275,73 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
let prices: [FuelType: Double]
|
let prices: [FuelType: Double]
|
||||||
/// Updated timestamp (seconds since 1970) — nil for sample data.
|
/// Updated timestamp (seconds since 1970) — nil for sample data.
|
||||||
let priceUpdated: TimeInterval?
|
let priceUpdated: TimeInterval?
|
||||||
|
/// Per-grade timestamps when the source provides them. A station-wide
|
||||||
|
/// `price_updated` must not be copied into this map: it does not prove that
|
||||||
|
/// every grade changed at that time.
|
||||||
|
let priceUpdatedByFuel: [FuelType: TimeInterval]?
|
||||||
|
/// Original per-grade timestamp text, retained so malformed source values
|
||||||
|
/// cannot silently fall back to a station-wide timestamp.
|
||||||
|
let priceUpdatedByFuelRaw: [FuelType: String]?
|
||||||
|
|
||||||
|
init(id: String, name: String, brand: String, address: String, postcode: String,
|
||||||
|
lat: Double, lng: Double, prices: [FuelType: Double],
|
||||||
|
priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]? = nil,
|
||||||
|
priceUpdatedByFuelRaw: [FuelType: String]? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.brand = brand
|
||||||
|
self.address = address
|
||||||
|
self.postcode = postcode
|
||||||
|
self.lat = lat
|
||||||
|
self.lng = lng
|
||||||
|
self.prices = prices
|
||||||
|
self.priceUpdated = priceUpdated
|
||||||
|
self.priceUpdatedByFuel = priceUpdatedByFuel
|
||||||
|
self.priceUpdatedByFuelRaw = priceUpdatedByFuelRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, brand, address, postcode, lat, lng, prices
|
||||||
|
case priceUpdated = "priceUpdated"
|
||||||
|
case priceUpdatedSnake = "price_updated"
|
||||||
|
case priceUpdatedByFuel = "priceUpdatedByFuel"
|
||||||
|
case priceUpdatedByFuelSnake = "price_updated_by_fuel"
|
||||||
|
case priceUpdatedByFuelRaw = "priceUpdatedByFuelRaw"
|
||||||
|
case priceUpdatedByFuelRawSnake = "price_updated_by_fuel_raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
name = try values.decode(String.self, forKey: .name)
|
||||||
|
brand = try values.decode(String.self, forKey: .brand)
|
||||||
|
address = try values.decode(String.self, forKey: .address)
|
||||||
|
postcode = try values.decode(String.self, forKey: .postcode)
|
||||||
|
lat = try values.decode(Double.self, forKey: .lat)
|
||||||
|
lng = try values.decode(Double.self, forKey: .lng)
|
||||||
|
prices = try values.decode([FuelType: Double].self, forKey: .prices)
|
||||||
|
priceUpdated = try values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdated)
|
||||||
|
?? values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdatedSnake)
|
||||||
|
priceUpdatedByFuel = try values.decodeIfPresent([FuelType: TimeInterval].self, forKey: .priceUpdatedByFuel)
|
||||||
|
?? values.decodeIfPresent([FuelType: TimeInterval].self, forKey: .priceUpdatedByFuelSnake)
|
||||||
|
priceUpdatedByFuelRaw = try values.decodeIfPresent([FuelType: String].self, forKey: .priceUpdatedByFuelRaw)
|
||||||
|
?? values.decodeIfPresent([FuelType: String].self, forKey: .priceUpdatedByFuelRawSnake)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try values.encode(id, forKey: .id)
|
||||||
|
try values.encode(name, forKey: .name)
|
||||||
|
try values.encode(brand, forKey: .brand)
|
||||||
|
try values.encode(address, forKey: .address)
|
||||||
|
try values.encode(postcode, forKey: .postcode)
|
||||||
|
try values.encode(lat, forKey: .lat)
|
||||||
|
try values.encode(lng, forKey: .lng)
|
||||||
|
try values.encode(prices, forKey: .prices)
|
||||||
|
try values.encodeIfPresent(priceUpdated, forKey: .priceUpdated)
|
||||||
|
try values.encodeIfPresent(priceUpdatedByFuel, forKey: .priceUpdatedByFuel)
|
||||||
|
try values.encodeIfPresent(priceUpdatedByFuelRaw, forKey: .priceUpdatedByFuelRaw)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cheap haversine distance to a location, in km.
|
/// Cheap haversine distance to a location, in km.
|
||||||
func distanceKM(to lat2: Double, lng2: Double) -> Double {
|
func distanceKM(to lat2: Double, lng2: Double) -> Double {
|
||||||
|
|||||||
Reference in New Issue
Block a user