Commit Graph
27 Commits
Author SHA1 Message Date
FuelBoard Contributor 6edab35400 Give the closest capsule the rounded rect whenever it wraps
The capsule's shape was keyed to the verdict's declared line count, so
"Closest station" — one declared line — always drew as a Capsule while
its text wrapped to two lines in the row's narrow column, leaving the
semicircular ends crowding the words they wrapped. Saves never showed
this because its amount line makes the declared count two.

The shape now follows the DRAWN height: a GeometryReader behind the
text block reports what it actually rendered, divided by the caption's
line height taken through ScaledMetric so the test holds at every
Dynamic Type size. One drawn line keeps the capsule; two or more get
the same 8pt rounded rect the other verdicts use. The first layout
pass, before there is a height, falls back to the declared count.
2026-10-18 20:16:30 +01:00
FuelBoard Contributor 244f9be640 Refine the station listing: muted verdict capsules, three-line names
The listing's verdict capsule now carries white text on muted semantic
fills instead of the neutral secondary tint: Saves #45825E, Costs
#A9635B, Break even #707070, Closest #58789D.

Fixed tones, not translucent tints, because white text on a 15% tint
over a white row measures ~1.8:1. Each tone sits at a relative
luminance of ~0.18 — the band where white clears 4.5:1 AND the pill
still separates ~3:1 from both a white row and the dark card fill — so
one value each works in light and dark without a dynamic colour. The
saturated palette colours cannot carry white text at all: systemGreen
measures 2.2:1, systemRed 3.6:1.

"Save" becomes "Saves", matching the detail sheet and reading as a
statement of the outcome; amounts, symbols, padding, radius and the
shape-follows-line-count rule are unchanged. Saves and Costs share an
84pt minimum width so their trailing edges line up down the list.

The price and its capsule are now one vertically grouped element (5pt),
with the badge keeping the column's full 12pt above, so the row no
longer reads as three equal lines.

Station names cap at three lines with a tail ellipsis: unbounded wrap
let one very long forecourt name set the height of the whole row. 59 of
the 8,073 names in the dump exceed three lines and now ellipsise; the
full name is always in Station Details. Addresses still wrap freely.

The baseline pill moves onto ListingFuelSavingsCapsule so the listing
shows the same soft blue and white text as its siblings, leaving
FuelSavingsCapsule to the detail sheet, which it now alone uses.
Badges keep their tinted styling: they annotate a price rather than
report an outcome.

Verified: 261/261 swift test; Debug simulator and Release device
builds; all four fills and their white text measured from simulator
pixels in both appearances, and the detail sheet's .blue absent from
the listing entirely.
2026-10-18 19:45:19 +01:00
FuelBoard Contributor c46a7748e6 Show the saving in the listing capsule on its own line
Station and Favourite rows share one capsule, so both now read
"Save / £3.10" and "Costs / £1.20 more" instead of the amount-free
"Saves" / "Costs more". "(approx.)" is appended on its own line when the
journey behind the figure was estimated. Break even carries no figure —
±£0.05 is noise — so it stays a single line unless the qualifier adds one.

- Split `listingCapsuleHeadline()` into a structured `ListingVerdict`
  (headline / amount / qualifier) drawn as one Text per line, so where the
  capsule breaks is decided by the data rather than by the width the price
  column happened to hand it: the figure can never straddle two lines.
- The capsule's shape follows its line count: one line stays a Capsule, two
  or more switch to a rounded rect at 8pt, whose corners do not crowd a
  taller block the way a pill's semicircular ends do.
- The Key sheet's legend renders the same component with `showsAmount:
  false`, so it demonstrates the wording without displaying the placeholder
  £1.00 figure it was built from.
- The new words ("Save", "Costs", "(approx.)", "%@ more") go into
  Localizable.strings so the copy editor owns them; amounts run through
  FuelSavingsFormat.money and the amount line is monospaced.
- VoiceOver keeps the single-line form, since a drawn break is a pause
  mid-phrase when spoken.

Tests cover the line structure for all four statuses, the estimated
qualifier and the amount-free legend. Pixel measurement on the simulator
confirms the shape rule: one line measures a radius of h/2 (a pill), two
lines measure 8pt, in both light and dark.
2026-10-18 18:25:59 +01:00
FuelBoard Contributor 7aa2485994 Move detail approximation after verdict amount 2026-10-17 20:24:21 +01:00
FuelBoard Contributor c6e252d194 Simplify station financial verdicts 2026-10-17 19:55:55 +01:00
FuelBoard Contributor 7aaaffa1c5 Revert station capsule layout changes 2026-10-17 18:31:45 +01:00
FuelBoard Contributor 7111ce38af Unify disabled states and preserve station verdicts 2026-10-17 18:22:55 +01:00
FuelBoard Contributor f261b0b10a Remove redundant comparison mode notice 2026-10-17 14:15:54 +01:00
FuelBoard Contributor 1b6184c035 Simplify shared fill-up wording and help 2026-10-17 14:03:45 +01:00
FuelBoard Contributor 05c76f21a9 Redesign the More info sheet
The sheet leads with the station name and address inside the card, above the map, then a two-column facts row (fuel price on the left, distance and return on the right, split by a hairline) and the two station actions divided the same way. The Best Value block becomes a tinted card: verdict badge, the two figures the estimate is made of, the working and the total. "How Best Value works" moves to its own row below it, using the shared HowBestValueRow the Key sheet already shows.

Presentation only: every figure still comes from the shared sources (FuelStore.priceText, stationDistanceDisplay, FuelSavingsResult), so the sheet cannot disagree with the listing row, the widget or the Live Activity, and no calculation, ranking or freshness rule changed.

StationDistanceDisplay.returnLabel(unit:) and FuelSavingsFormat.extraTravelDistanceLabel(...) are split out of their caption forms so a surface printing the return or extra-travel figure on its own line takes it from the same place, confidence marker included; their existing output is unchanged, pinned by tests. FuelSavingsSummaryView is deleted - the sheet's card supersedes it and nothing referenced it.

DEBUG-only QA hooks: -qaConfigureSavings configures a Fuel Savings plan (the Best Value card and the Worth the Drive sort are gated on it), -qaDetailBottom scrolls to that card (defaultScrollAnchor is a no-op on a List) and -qaCopyPostcode holds the copied confirmation long enough to screenshot.
2026-10-16 14:51:36 +01:00
FuelBoard Contributor ff8a1e18d0 Redesign the station listing and move the Best Value controls
Two columns split by a 1pt hairline: identity on the left at a fixed 52%
share so rows line up when scrolling, price and savings on the right,
leading-aligned on one axis with the price dominant. The favourites star
sits under the station logo, freeing the width the pounds-and-pence price
needs to render in full rather than scaled or truncated.

Badges stay tinted rather than filled — BEST VALUE green, CHEAPEST blue,
none for nearest — and the verdict capsule is one shared view across the
listing, the detail sheet and the compact summary, so wording and colour
cannot drift between them.

Best Value's minimum saving moves from Alerts to Settings, Fuel Savings,
beside the fill size and MPG that produce the saving; the Alerts tab reads
it back instead of offering it twice. Its options are now 50p steps to £5
(was 50p/£1/£2/£5), so £2.50 and £3 are reachable, with a test pinning the
step, the ceiling and the store's string round-trip.

The verdict capsule scales to 0.7 before truncating. Measured on a 393pt
screen its copy is handed ~69pt while "Closest station" needs ~90pt, so the
old 0.8 floor could not reach it and rows showed "Closest stati..." instead.

Verified: Debug build clean, swift test 249/249.
2026-10-16 13:38:11 +01:00
FuelBoard Contributor e960ef85c6 Make station price age informational only
The relay's price_updated is the maximum price_last_updated across the grades it maps, so it cannot vouch for the selected grade: stop using it to score, gate or warn about Best Value.

- FuelPriceFreshness is now station-level and display-only: one age per station, fresh/outdated/unknown/invalid, "May be outdated" past 30 days, with future-tolerance and non-finite guards.
- Best Value restores the closest-station-selling-the-selected-fuel baseline and its savings ranking; eligibility gating, eligible-first sorting and the unavailable banner are gone.
- Price age appears in the More info sheet only - no listing-row warnings, no extra banners.
- Copy reverted from "closest eligible station" to "closest station"; stale price-confidence strings removed, "May be outdated" added.
- Dormant per-grade timestamp plumbing removed, and regression tests added for every fuel type, missing/invalid/future stamps and the bundled offline dump.
2026-10-16 09:48:01 +01:00
FuelBoard Contributor 5900a81473 Station detail: price above its age, and the verdict caption on its own line
- More info lists the fuel price first, with "Price updated" beneath it:
  the price is what the row is about, the age qualifies it.
- The detail sheet stacks "vs closest eligible station" under the verdict
  capsule, matching the listing rows, so the caption no longer shares a
  line with the verdict it describes.
2026-10-15 22:48:07 +01:00
FuelBoard Contributor ed3a5137cd Best Value rows: capsule on its own line, comparison caption beneath
The verdict capsule shared a line with "vs closest eligible station"; the
caption's layoutPriority squeezed the capsule until it truncated
("Approx. £2.75 better off after tra…"), and in this sort mode the verdict
is the point of the row.

- The capsule now owns the line and the caption sits under it.
- The capsule text also shrinks rather than truncating at large Dynamic
  Type sizes, since nothing competes with it for width.
- The detail sheet keeps its side-by-side layout: it has the room, and the
  verdict there is the sheet's headline.
2026-10-15 22:25:53 +01:00
FuelBoard Contributor 9e77ee2947 Best Value: explain price age per surface instead of one blanket warning
- Station detail now separates the station's own report age from the
  selected fuel's update time, so a recent station-wide report no longer
  reads as an old price.
- Warning copy names the fuel and says which side is weak: the station's
  price is not individually dated, or the baseline's is. The misleading
  "unverified or older than 7 days" line is gone.
- Wording stays "Closest" throughout; new copy uses Unleaded / Premium /
  Diesel rather than the internal grade codes.
- Localisation: new and updated keys for the added copy.
2026-10-15 21:37:35 +01:00
FuelBoard Contributor 3819b8a6ab Add Best Value small widget and standardise Closest wording 2026-10-15 17:14:02 +01:00
FuelBoard Contributor a5688cbac2 Clarify fuel savings comparison labels 2026-10-15 11:38:24 +01:00
FuelBoard Contributor 558f956d8d Update FuelBoard onboarding copy, title formatting, key sheet card wrapping, and best-value button placement 2026-10-14 16:22:31 +01:00
FuelBoard Contributor 6ea675499e Best Value detail sheet: keep the verdict capsule left-aligned
The capsule was right-aligned on its own line; the user asked for it back at
the left margin, leaving the Total row as it is.
2026-10-14 11:31:59 +01:00
FuelBoard Contributor 3c424ab397 Best Value: candidate-priced fills, key copy, detail total 2026-10-14 10:43:12 +01:00
FuelBoard Contributor 2a2823c180 Accurate distances toggle, Approx. distance markers, and Closest-mode speedup
Distance confidence
- Every distance surface takes the figure AND its "Approx." marker from one
  FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget,
  Live Activity, watch, Siri), so a surface can never mark one figure approximate
  while showing a routed one. Radius filters and Closest ordering test that same
  displayed distance, so no row can read a distance outside the radius that
  filtered it (the reported "14.6 mi inside a 10-mile list").

Accurate distances (Settings -> Distances, default off)
- Off: the browse layer serves the straight line, marked Approx., for every row,
  so two installs agree to the metre and the app makes no route requests at all.
- On: routed distances for the whole search area, unmarked, served only from a
  COMPLETE set anchored within 600 m — a partial fill reverts every row at once
  rather than mixing road and straight-line bases.
- Best Value maths sits deliberately outside the toggle: it keeps using measured
  road routes and its own confidence marking, so money figures never move with
  the switch.
- Fills run only while the toggle is on and the app is frontmost; capacity raised
  to 600 stations per pass; completeness tolerates pins Apple Maps has no route
  for, which otherwise left an area permanently incomplete and the whole list
  stuck on estimates.

Performance (the reported slowdown switching to Closest / launching with it)
- FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per
  call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per
  Closest sort. Memoised, with the setter keeping it in step.
- Both keyed sorts decorate their keys — one distance per station, carried as an
  index instead of copying whole stations into the decorated array — and the
  comparison baseline plus per-station journeys now come from ONE pass instead of
  two full scans per tab. The Stations tab also reuses its single sorted list for
  the count and the TOP badge instead of sorting the country three times.
- Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per
  list, Cheapest 6 ms, Best Value 11 ms.
- DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and
  FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when
  a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where
  devicectl eats a leading-dash argument.

Tests: 198, 0 failures. Device and simulator Debug builds green.
2026-10-13 18:01:45 +01:00
FuelBoard Contributor 9aa8a2ede1 Clarify Best Value route confidence 2026-10-13 10:30:41 +01:00
FuelBoard Contributor d348157a45 Best Value: rename the remaining user-facing copy, hide the sheet section when off
Following the sort-segment rename, the rest of the user-facing "Worth the Drive"
copy moves to "Best Value".

- Settings: the toggle is now just "Best Value" (was "Enable Worth the Drive"),
  and the narrative footer opens "Best Value estimates fuel cost only, …".
- More info sheet: the section header is "Best Value" (was "Worth the drive?"),
  and the section itself is now conditional. `fuelSavings` is nil whenever the
  feature is off — `savingsLookup` returns nil when it is not configured — so
  the heading is never left dangling over an empty section while the feature is
  disabled.
- The unavailable text reads "Best Value unavailable: …".
- Comments and doc comments that named the sort were updated so they match the
  UI instead of contradicting it.

Deliberately unchanged: the enum's raw value stays `worthTheDrive`, so stored
sort preferences and widget configs keep decoding, and FuelSavingsStatus keeps
its verdict wording ("Worth the drive" / "Not worth the drive") — that describes
a station's verdict rather than the feature, and is not shown in the UI.

Verified on the simulator: with the feature OFF the more info sheet shows
Address / Distance / Directions and no heading at all; with it ON the sheet
shows the "Best Value" section; Settings reads "Fuel Savings" / "Best Value"
with no "Enable" anywhere. 172 tests, 0 failures.
2026-10-11 20:11:57 +01:00
FuelBoard Contributor bb053de8b1 Listing rows: verdict text hugs its icon, and the address stops repeating the name
Two things the listing row was saying twice, or too loosely.

- The verdict capsule used a Label, and a Label lays its icon and title out
  across the width it is given: in a listing row that left 22pt of empty space
  between the symbol and "Save £1.07 after travel". It is now an HStack with
  an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
  icon and the capsule background hugs the pair. Measured on the same row: the
  capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.

- The address line printed the station name a second time. The official Fuel
  Finder address field usually opens with the station's own name ("CHIDSWELL
  SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
  the dump's addresses start with the exact name, and 13% also end with the
  postcode the row appended again. StationAddressLine (new, in Shared/, so the
  row and the detail sheet share one rule) drops a leading repeat of the name
  and writes the postcode exactly once.

The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.

Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
2026-10-11 19:33:26 +01:00
FuelBoard Contributor cf3abb3fa7 Fuel Savings: MPG and fill-up are sliders, not typed fields
The two Fuel Savings figures and the in-list fill-up control were text
fields. A typed figure could sit outside the slider's own bounds, so the
control and the number it claimed to show could disagree, and the decimal
pad needed a Done button to escape.

- Settings: "Average fuel economy" and "Typical fill-up" are now sliders
  with a read-out ("45 MPG", "£40" / "40 L") in place of the field. The
  warning copy reads "Set a realistic..." rather than "Enter...".
- FuelSavingsAdjustmentView (shared by the Stations Worth-the-Drive header
  and the Favourites tab) drops its text field and gains the same read-out;
  the typed-value machinery and the -focusFillAmount QA hook go with it.
- Slider bounds live in FuelSavingsCalculator (fillAmountSliderRange/Step,
  mpgSliderRange/Step) so the Settings control and the in-list control can
  never offer different ranges, and the fill-up slider range stays inside
  the calculator's validity ceiling (asserted by test).
- Two tests pin those invariants: every slider position is a valid amount,
  and both MPG bounds are valid MPG values. 155 tests, 0 failures.
- Comments that described the removed fields were updated; the keyboard
  Done-bar plumbing is kept (no numeric field remains in the app, so it can
  no longer fire) rather than deleted, so any field added later still has an
  escape route.

Verified on the simulator: Settings shows "Average fuel economy / 45 MPG"
with no field, and the Worth-the-Drive fill-up control shows the unit picker
with a "£40" read-out and a slider. The Settings "Typical fill-up" row sits
below the fold on every simulator available here (and synthetic input cannot
scroll it), so those pixels are unverified.
2026-10-11 17:41:53 +01:00
FuelBoard Contributor d0a89d97d5 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.
2026-10-11 17:19:10 +01:00
FuelBoard Contributor d7a0ecb79a Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.).

The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving.

Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
2026-10-11 14:34:53 +01:00