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:
+51
-27
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
|
||||
case breakEven
|
||||
case notWorthTheDrive
|
||||
/// The comparison baseline itself — the station every other saving is
|
||||
/// measured against. Shown as "Closest station", never "Break even".
|
||||
/// measured against. Shown as "Closest", never "Break even".
|
||||
case baseline
|
||||
|
||||
var displayName: String {
|
||||
@@ -187,8 +187,13 @@ enum FuelSavingsFormat {
|
||||
}
|
||||
|
||||
extension FuelSavingsResult {
|
||||
/// Baseline row label — required wording.
|
||||
static let baselineHeadline = "Closest station"
|
||||
/// The baseline's capsule label.
|
||||
///
|
||||
/// "Closest", not "Closest station": it is a station LABEL sitting alongside
|
||||
/// "Best value" and "Cheapest", and the noun is implied by the row it is on.
|
||||
/// This is also the ONE source for the word, so the closest station's badge and
|
||||
/// its verdict capsule cannot word it differently.
|
||||
static let baselineHeadline = "Closest"
|
||||
/// Neutral result label — required wording.
|
||||
static let breakEvenHeadline = "Break even"
|
||||
|
||||
@@ -209,7 +214,7 @@ extension FuelSavingsResult {
|
||||
}
|
||||
|
||||
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
||||
/// travel", "Break even", "Closest station".
|
||||
/// travel", "Break even", "Closest".
|
||||
///
|
||||
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||||
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||||
@@ -271,7 +276,7 @@ extension FuelSavingsResult {
|
||||
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||||
/// instead of widening the row's fixed price column.
|
||||
struct ListingVerdict: Equatable {
|
||||
/// "Saves", "Costs", "Break even" or "Closest station".
|
||||
/// "Saves", "Costs", "Break even" or "Closest".
|
||||
let headline: String
|
||||
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||||
let amount: String?
|
||||
@@ -393,11 +398,19 @@ struct FuelSavingsRankedStation: Equatable {
|
||||
Item(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
|
||||
]
|
||||
|
||||
/// The station labels, in the order the Key lists them.
|
||||
///
|
||||
/// There is no "Top" entry. Top and Cheapest were two words for one state —
|
||||
/// both driven by the same "cheapest among the stations you are looking at"
|
||||
/// flag, differing only in whether the candidate set was the radius or the
|
||||
/// Favourites — and which word a surface used drifted by device. Cheapest is
|
||||
/// the survivor: it says what it measures, and it is already the word every
|
||||
/// piece of favourites copy uses ("your cheapest E10 favourite", "Cheapest
|
||||
/// favourite" alert). The concept survives; the synonym does not.
|
||||
static let stationLabels = [
|
||||
Item(label: "Best value", description: "Meets your minimum saving after travel costs."),
|
||||
Item(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
|
||||
Item(label: "Closest station", description: "The nearest station by journey distance."),
|
||||
Item(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||
Item(label: "Closest", description: "The closest station by journey distance.")
|
||||
]
|
||||
|
||||
static let favourite = Item(
|
||||
@@ -427,16 +440,16 @@ struct FuelSavingsRankedStation: Equatable {
|
||||
enum StationLabelKind: String, CaseIterable {
|
||||
case bestValue
|
||||
case cheapest
|
||||
case closestStation
|
||||
case top
|
||||
case closest
|
||||
|
||||
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .bestValue: return String(localized: "Best value")
|
||||
case .cheapest: return String(localized: "Cheapest")
|
||||
case .closestStation: return FuelSavingsResult.baselineHeadline
|
||||
case .top: return String(localized: "Top")
|
||||
// "Closest", from the one shared constant, so the badge, the verdict
|
||||
// capsule and the Key cannot word the same label three ways.
|
||||
case .closest: return FuelSavingsResult.baselineHeadline
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,8 +457,7 @@ struct FuelSavingsRankedStation: Equatable {
|
||||
switch self {
|
||||
case .bestValue: return "medal"
|
||||
case .cheapest: return "tag"
|
||||
case .closestStation: return "location.north.fill"
|
||||
case .top: return "trophy"
|
||||
case .closest: return "location.north.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,39 +468,50 @@ struct FuelSavingsRankedStation: Equatable {
|
||||
/// app's existing Best Value gate (the user's alert threshold, the same figure
|
||||
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
|
||||
/// reuses the caller's existing "cheapest in range" flag (the station behind
|
||||
/// `topStationID`). One badge per row: a station that is both the cheapest and
|
||||
/// worth the drive shows BEST VALUE, the stronger of the two.
|
||||
/// `cheapestStationID`). One badge per row: a station that is both the cheapest
|
||||
/// and worth the drive shows BEST VALUE, the stronger of the two.
|
||||
enum StationRecommendationBadge: Equatable {
|
||||
case bestValue
|
||||
case cheapest
|
||||
case closest
|
||||
|
||||
/// One label per row, in this order: Best Value, then Cheapest, then Closest.
|
||||
///
|
||||
/// A station can hold all three at once — the nearest forecourt is often also
|
||||
/// the cheapest — and a row has room for one, so the order is decided here
|
||||
/// rather than left to each caller. Being nearby is the weakest claim: a short
|
||||
/// drive says nothing about the price, and unlike the other two it reports no
|
||||
/// saving at all. Closest therefore surfaces only when the row earns neither
|
||||
/// of the others.
|
||||
static func badge(netSavingGBP: Double?,
|
||||
isCheapest: Bool,
|
||||
isClosest: Bool = false,
|
||||
bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
|
||||
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
|
||||
netSavingGBP >= bestValueThresholdGBP {
|
||||
return .bestValue
|
||||
}
|
||||
return isCheapest ? .cheapest : nil
|
||||
if isCheapest { return .cheapest }
|
||||
return isClosest ? .closest : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Every ranking label a station qualifies for, in display order.
|
||||
///
|
||||
/// A listing row shows ONE badge (Best Value outranks Cheapest, because a row has
|
||||
/// room for one). The More info sheet has room for all of them, so it shows each
|
||||
/// qualification side by side — but it must never invent a rule of its own, so this
|
||||
/// defers to `StationRecommendationBadge.badge` for the Best Value gate and takes
|
||||
/// the other two as the SAME flags the host list gives its rows.
|
||||
/// A listing row shows ONE badge (Best Value outranks Cheapest outranks Closest,
|
||||
/// because a row has room for one). The More info sheet has room for all of them,
|
||||
/// so it shows each qualification side by side — but it must never invent a rule
|
||||
/// of its own, so this defers to `StationRecommendationBadge.badge` for the Best
|
||||
/// Value gate and takes Cheapest and Closest as the SAME flags the host list
|
||||
/// gives its rows.
|
||||
///
|
||||
/// Top and Cheapest are distinct on purpose: Cheapest means "lowest price in the
|
||||
/// range the Stations tab is showing", Top means "lowest price among your
|
||||
/// Favourites". A caller passes whichever context it is listing, never both, so a
|
||||
/// station is not labelled twice for the same achievement.
|
||||
/// There is no Top label any more: both tabs mean the same thing by "cheapest" —
|
||||
/// the lowest price among the stations on screen, whether that set is the radius or
|
||||
/// the Favourites — so a host passes one flag and the sheet renders one capsule.
|
||||
enum StationRanking {
|
||||
static func kinds(netSavingGBP: Double?,
|
||||
isCheapestInRange: Bool,
|
||||
isTopFavourite: Bool,
|
||||
isClosestInRange: Bool = false,
|
||||
bestValueThresholdGBP: Double) -> [StationLabelKind] {
|
||||
var kinds: [StationLabelKind] = []
|
||||
// The same gate the listing row uses, asked without a cheapest flag so the
|
||||
@@ -496,12 +519,13 @@ enum StationRanking {
|
||||
if StationRecommendationBadge.badge(
|
||||
netSavingGBP: netSavingGBP,
|
||||
isCheapest: false,
|
||||
isClosest: false,
|
||||
bestValueThresholdGBP: bestValueThresholdGBP
|
||||
) == .bestValue {
|
||||
kinds.append(.bestValue)
|
||||
}
|
||||
if isTopFavourite { kinds.append(.top) }
|
||||
if isCheapestInRange { kinds.append(.cheapest) }
|
||||
if isClosestInRange { kinds.append(.closest) }
|
||||
return kinds
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user