Make Closest a station label and retire Top

Top and Cheapest were two words for one state: both meant "the lowest price
among the stations on screen", differing only in the candidate set (radius vs
Favourites). Which word a surface used had drifted by device — the phone's
rows said Cheapest while the watch said Top for the identical state. Top is
gone from the enum, the Key legend, the badge and the watch's hand-rolled
label; Cheapest survives because it names the measure and is already the word
the favourites copy uses.

Closest is now a real station label rather than a verdict. StationRecommendationBadge
gains .closest with a total precedence — Best value, then Cheapest, then Closest
— because a row has room for one label and proximity is the weakest claim: a
short drive says nothing about the price, and unlike the other two it reports no
saving at all. The badge sits above the price, where the other two sit. The
closest row's verdict capsule is suppressed: its only word for that state was
"Closest", so printing it below as well would state the same fact twice, three
lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged.

"Closest station" becomes "Closest", read from the single
FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key
cannot word it differently. The Key legend renders all three station labels with
the shared StationLabelBadge; the old KeyStationLabelIndicator, which
special-cased the closest station to a wider verdict pill so it read as a
different class of thing from the two above it, is deleted.

One closest icon everywhere. The listing and detail verdict symbols used
location.circle and location.circle.fill for the state the Key taught as
location.north.fill; both now read StationLabelKind.closest.symbol, so the three
surfaces resolve one glyph from one place.

The detail sheet gains the Closest capsule below the map, ordered to match the
Key. Favourites gains the nearest favourite as its Closest, measured on the same
one-way distance its rows print and restricted to favourites with a price for the
active fuel — matching the radius tab, where the closest station is by definition
the savings baseline.

closestStationID flows from the one scaffold baseline the savings lookup already
compares against, so a row's badge and the reference station cannot disagree.
No savings calculation, threshold, ranking, eligibility or station-selection
logic changed.

The Key sheet's detent is now bound rather than fixed, so dragging it up to read
the Station labels section sticks instead of snapping back; a QA launch argument
opens it at full height, since the 0.6 detent puts that section below the fold on
every device. That argument is read unconditionally, unlike the other harness
hooks which are #if DEBUG-gated.

Tests: 278 pass, 9 new — the label precedence ladder across every combination,
capsule order in the sheet, Closest alone, and the retired label pinned absent.
This commit is contained in:
FuelBoard Contributor
2026-10-19 14:59:47 +01:00
parent c69bdb3c5f
commit 8153a36168
13 changed files with 341 additions and 149 deletions
+38 -25
View File
@@ -321,29 +321,29 @@ struct ContentView: View {
return pool.compactMap { $0.prices[selectedFuel] }.min()
}
/// The "best" station — the cheapest in the pool — gets the TOP badge.
/// The cheapest station in the pool — the one that wears the Cheapest badge.
/// First occurrence in display order wins on price ties.
private var topStationID: String? {
private var cheapestStationID: String? {
guard let baselinePrice else { return nil }
return topStationID(in: displayedStations, baselinePrice: baselinePrice)
return cheapestStationID(in: displayedStations, baselinePrice: baselinePrice)
}
/// The TOP-badge station within an ALREADY-SORTED list. Split out so a caller
/// that has the sorted list (the Stations tab) does not sort the whole
/// The Cheapest-badge station within an ALREADY-SORTED list. Split out so a
/// caller that has the sorted list (the Stations tab) does not sort the whole
/// country a second time just to find one id.
private func topStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
private func cheapestStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
list.first { $0.prices[selectedFuel] == baselinePrice }?.id
}
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
/// The app's own "cheapest in range" — the Cheapest-badge station — mirrored
/// into the Settings → Debug section so its "Cheapest in range" row
/// matches the app list (and the widget when the widget's fuel + distance
/// match the app's). The debug section previously showed the ALERT
/// prediction (alertsFuel + alert radius), which is a different pool by
/// design — that mismatch is what this fixes.
private var appCheapestStatus: DebugAppCheapest? {
guard let topID = topStationID,
let station = stations.first(where: { $0.id == topID }),
guard let cheapestID = cheapestStationID,
let station = stations.first(where: { $0.id == cheapestID }),
let location else { return nil }
return DebugAppCheapest(
station: station,
@@ -428,7 +428,7 @@ struct ContentView: View {
///
/// The baseline is chosen with the same journey guards as
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
/// off, the ordinary Closest station still names that row.
/// off, the ordinary closest station still names that row.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) }
guard fuelSavingsSettings.isConfigured else {
@@ -833,7 +833,10 @@ struct ContentView: View {
// The same "cheapest in range" test this view gives its
// rows, so the sheet's Cheapest capsule matches the badge on
// the list it was opened from.
isCheapestInRange: station.id == topStationID,
isCheapestInRange: station.id == cheapestStationID,
// The same "closest" test the rows get, from the ONE
// scaffold baseline the savings lookup also compares against.
isClosestInRange: station.id == scaffold.baseline?.id,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
)
@@ -1528,15 +1531,15 @@ struct ContentView: View {
/// within the compiler's type-check budget.
private var stationsTab: some View {
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its
// count, the TOP badge and every savings label. Asking for
// count, the Cheapest badge and every savings label. Asking for
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
// `topStationID` and `savingsLookup` separately walked the full ~8k list
// six times per render — seconds of main-thread work in Closest mode,
// `cheapestStationID` and `savingsLookup` separately walked the full ~8k
// list six times per render — seconds of main-thread work in Closest mode,
// where the pool is every station in the UK.
let scaffold = savingsScaffold()
let baselineStation = scaffold.baseline
let displayed = displayedStations
let topID = baselinePrice.flatMap { topStationID(in: displayed, baselinePrice: $0) }
let cheapestID = baselinePrice.flatMap { cheapestStationID(in: displayed, baselinePrice: $0) }
return StationsView(
stations: displayed,
totalCount: displayed.count,
@@ -1547,7 +1550,8 @@ struct ContentView: View {
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice,
topStationID: topID,
cheapestStationID: cheapestID,
closestStationID: baselineStation?.id,
location: location,
favouriteIDs: favouriteIDs,
stationTapAction: stationTapAction,
@@ -1801,8 +1805,11 @@ struct StationRow: View {
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
/// This row is the cheapest price in range — the caller's existing
/// `topStationID` test. Feeds the CHEAPEST badge.
let isTopResult: Bool
/// `cheapestStationID` test. Feeds the Cheapest badge.
let isCheapestResult: Bool
/// This row is the station savings are measured against — the caller's
/// `closestStationID` test. Feeds the Closest badge.
let isClosestResult: Bool
let isFavourite: Bool
var tapAction: StationTapAction = .openMap
var fuelSavings: FuelSavingsAvailability?
@@ -1862,12 +1869,13 @@ struct StationRow: View {
}
/// The row's single recommendation badge, if any. BEST VALUE (worth the
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
/// being context rather than a recommendation.
/// drive by the alert threshold) outranks CHEAPEST, which outranks CLOSEST —
/// see `StationRecommendationBadge.badge` for why proximity ranks last.
private var recommendationBadge: StationRecommendationBadge? {
StationRecommendationBadge.badge(
netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapest: isTopResult,
isCheapest: isCheapestResult,
isClosest: isClosestResult,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
@@ -2051,7 +2059,11 @@ struct StationRow: View {
.accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price))
}
if let result = fuelSavings?.result {
// The closest station wears the Closest badge above its price
// and gets no verdict capsule: the capsule's only word for that
// state was "Closest", so printing it here as well would say
// the same thing twice, three lines apart.
if let result = fuelSavings?.result, !isClosestResult {
savingsCapsule(result)
}
}
@@ -2080,9 +2092,9 @@ struct StationRow: View {
}
/// The listing's verdict capsule — ONE component for every status, so the
/// baseline's "Closest station" pill carries the same soft blue fill, white
/// text and shape rules as Saves, Costs and Break even. The detail sheet
/// keeps `FuelSavingsCapsule` to itself, unchanged.
/// baseline's "Closest" pill carries the same fill, text and shape rules as
/// Saves, Costs and Break even. The detail sheet keeps `FuelSavingsCapsule` to
/// itself, unchanged.
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
ListingFuelSavingsCapsule(result: result)
}
@@ -2106,6 +2118,7 @@ private extension StationRecommendationBadge {
switch self {
case .bestValue: return .bestValue
case .cheapest: return .cheapest
case .closest: return .closest
}
}
}