Show ranking capsules and value breakdown in station details

The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type.

The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed.

Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched.

Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic.

Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
This commit is contained in:
FuelBoard Contributor
2026-10-19 13:20:59 +01:00
parent 5d9e71f7ac
commit c69bdb3c5f
10 changed files with 286 additions and 18 deletions
+44 -1
View File
@@ -405,7 +405,17 @@ struct FuelSavingsRankedStation: Equatable {
description: "Star a station to save it to your Favourites."
)
static let helpTitle = "How does Best Value work?"
/// The link that opens the explanation sheet. Names the question the sheet
/// answers rather than the feature ("How does Best Value work?"), because the
/// sheet explains the whole calculation — fill-up, return trip, extra travel,
/// total — not only the Best Value gate at the end of it.
static let helpTitle = "How does FuelBoard calculate value?"
/// The explanation sheet's own title: a SHORT form of the same question, not
/// the link's words. A navigation title is clamped to the bar, and the full
/// question truncated to "How does FuelBoard…" in BOTH inline and large
/// display modes — so the row carries the full question and the sheet it opens
/// names the same topic in words that render in full.
static let helpSheetTitle = "How value is calculated"
}
/// A station label's meaning, independent of its copy.
@@ -463,6 +473,39 @@ enum StationRecommendationBadge: Equatable {
}
}
/// 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.
///
/// 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.
enum StationRanking {
static func kinds(netSavingGBP: Double?,
isCheapestInRange: Bool,
isTopFavourite: Bool,
bestValueThresholdGBP: Double) -> [StationLabelKind] {
var kinds: [StationLabelKind] = []
// The same gate the listing row uses, asked without a cheapest flag so the
// ranking is decided only by the saving.
if StationRecommendationBadge.badge(
netSavingGBP: netSavingGBP,
isCheapest: false,
bestValueThresholdGBP: bestValueThresholdGBP
) == .bestValue {
kinds.append(.bestValue)
}
if isTopFavourite { kinds.append(.top) }
if isCheapestInRange { kinds.append(.cheapest) }
return kinds
}
}
struct BestValueAlertCandidate: Equatable {
let station: FuelStation
let result: FuelSavingsResult