Worth the Drive: calculation audit + listing/detail polish
Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):
- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
used in the savings path. Effect ~2 ppm (invisible), but the conversions
are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
price), pump saving, round-trip extra distance clamped at zero, travel fuel
priced at the candidate station, +/-5p break even, baseline labelled
"Closest station", sorting on the unrounded net saving. The brief's "£0.18"
for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
"2.0 mi"), not a rounding bug in the money.
UI (uncommitted since d7a0ecb):
- Done bar drawn in the bottom safe area from keyboard notifications:
.toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
(the compact banner does not print it today; a future row layout would).
153 tests, 0 failures. Simulator and device builds succeed.
This commit is contained in:
+67
-18
@@ -296,7 +296,7 @@ struct ContentView: View {
|
||||
/// that every station the app lists can carry a saving.
|
||||
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
|
||||
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
|
||||
return FuelSavingsJourney(miles: roadMeters / 1609.34, basis: .roadRoute)
|
||||
return FuelSavingsJourney(miles: roadMeters / FuelSavingsCalculator.metresPerMile, basis: .roadRoute)
|
||||
}
|
||||
return FuelSavingsJourney(
|
||||
miles: savingsFallbackMiles(for: station, location: location),
|
||||
@@ -305,7 +305,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func savingsFallbackMiles(for station: FuelStation, location: Coordinate) -> Double {
|
||||
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) * 0.621371 * 2
|
||||
FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) / FuelSavingsCalculator.kilometresPerMile * 2
|
||||
}
|
||||
|
||||
private func sortedForWorthTheDrive(_ pool: [FuelStation]) -> [FuelStation] {
|
||||
@@ -424,7 +424,12 @@ struct ContentView: View {
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit
|
||||
distanceUnit: distanceUnit,
|
||||
fuelSavings: savingsLookup(baseline: nearestSavingsBaseline)(station),
|
||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||
savingsBaselineStationName: nearestSavingsBaseline?.name,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||
@@ -525,6 +530,13 @@ struct ContentView: View {
|
||||
fuelSavingsSettings = forced
|
||||
sortMode = .worthTheDrive
|
||||
}
|
||||
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
|
||||
// "Tap station = More info". The stored default is open-Apple-Maps,
|
||||
// so on a simulator a tap bounces out to Maps with no way back and
|
||||
// the Worth the Drive detail section can't be checked at all.
|
||||
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
|
||||
stationTapAction = .showDetails
|
||||
}
|
||||
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||
// failure legs for the screenshot harness. The auto-refresh below
|
||||
// is skipped so the banner stays up (a live fetch would clear it).
|
||||
@@ -1242,6 +1254,10 @@ struct StationRow: View {
|
||||
var topBadgeText: String = "TOP"
|
||||
var tapAction: StationTapAction = .openMap
|
||||
var fuelSavings: FuelSavingsAvailability?
|
||||
/// The session fill-up figure (the one the adjustment control edits). The
|
||||
/// compact banner does not print it today, but a row that carried a hard
|
||||
/// zero would print "for £0" the moment it did — pass the real figure.
|
||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
var onToggleFavourite: () -> Void = {}
|
||||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||
|
||||
@@ -1263,6 +1279,13 @@ struct StationRow: View {
|
||||
return String(format: "%+.1fp", delta)
|
||||
}
|
||||
|
||||
/// True when this row IS the cheapest in range — its delta reads "best",
|
||||
/// so labelling it "vs cheapest" would be nonsense.
|
||||
private var isCheapestPrice: Bool {
|
||||
guard let price = station.prices[fuel], let baselinePrice else { return false }
|
||||
return abs(price - baselinePrice) <= 0.05
|
||||
}
|
||||
|
||||
private func handleTap() {
|
||||
switch tapAction {
|
||||
case .openMap:
|
||||
@@ -1279,6 +1302,7 @@ struct StationRow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||
Group {
|
||||
@@ -1306,18 +1330,6 @@ struct StationRow: View {
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if isTopResult {
|
||||
// The badge always marks the CHEAPEST PUMP PRICE (never
|
||||
// "best saving"), so in Worth the Drive order — where
|
||||
// the top row is the best net saving — it may sit lower
|
||||
// down the list, and says so.
|
||||
Text(topBadgeText)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
ForEach(StationNotice.notices(for: station)) { notice in
|
||||
Image(systemName: notice.symbolName)
|
||||
.font(.caption2.weight(.semibold))
|
||||
@@ -1336,14 +1348,27 @@ struct StationRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
if fuelSavings != nil {
|
||||
FuelSavingsSummaryView(availability: fuelSavings, amount: .init(value: 0, unit: .pounds), baselineStationName: nil, compact: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// RIGHT — price in £ (no p) + difference vs cheapest
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if isTopResult {
|
||||
// The badge always marks the CHEAPEST PUMP PRICE (never
|
||||
// "best saving"), so in Worth the Drive order — where the
|
||||
// top row is the best net saving — it may sit lower down
|
||||
// the list, and says so. It sits ABOVE the price it
|
||||
// annotates rather than beside the name, so it reads as
|
||||
// part of the price and the name keeps its width.
|
||||
Text(topBadgeText)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||
.foregroundStyle(.blue)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
if let price = station.prices[fuel] {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
@@ -1355,6 +1380,16 @@ struct StationRow: View {
|
||||
Text(deltaText)
|
||||
.font(.caption2.bold().monospaced())
|
||||
.foregroundStyle(ragColor)
|
||||
if !isCheapestPrice {
|
||||
// "+8.0p" on its own never said what it measured
|
||||
// against; the delta is against the cheapest price
|
||||
// in range in EVERY sort mode.
|
||||
Text("vs cheapest")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1367,6 +1402,20 @@ struct StationRow: View {
|
||||
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
if fuelSavings != nil {
|
||||
// Full-width verdict banner under the row. Inline in the middle
|
||||
// column it truncated ("Save £2.75 after tra…") because the
|
||||
// price block squeezes that column — and the verdict is the
|
||||
// point of this sort mode, so it gets its own line.
|
||||
FuelSavingsSummaryView(
|
||||
availability: fuelSavings,
|
||||
amount: fuelSavingsAmount,
|
||||
baselineStationName: nil,
|
||||
compact: true,
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: handleTap)
|
||||
|
||||
Reference in New Issue
Block a user