- 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.
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.
Dated pricing had crowded More info and the station rows, and most of the
extra copy described a distinction the data cannot make: every station
carries a single station-wide timestamp, so "grade-specific vs
station-wide" never varied in practice.
- More info shows one row, "Price updated 28h ago" (or "Not provided"),
replacing the separate station-report and fuel-update-time rows and the
blanket "price freshness is not confirmed" label.
- Rows and the detail sheet stay quiet when a saving is shown; only an
ineligible comparison explains itself. Provisional savings already read
as estimates from the capsule, so the fuel-named warning lines are gone.
- Freshness/eligibility logic is unchanged: it still gates the baseline and
still supports grade-specific timestamps for when the relay emits them.
- Tests updated; localisation keys for the removed copy dropped.
- RoadDistanceService builds route endpoints with MKMapItem(location:address:)
instead of the MKPlacemark path deprecated in iOS 26.
- LiveActivityManager updates through ActivityContent, replacing the
ActivityKit update(using:) call deprecated in iOS 16.2.
- The location-update hook no longer captures an unused state object; the
closure body already runs on self, so behaviour is unchanged.
- 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.
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared
comparison confidence, so a saving is presented as verified only when
both the candidate and the baseline pass the gate for the fuel on screen.
- Rank against the closest station with an eligible selected-fuel price
instead of the physically closest station, so a single stale neighbour
can no longer invalidate every comparison in the list. Ordinary Closest
sorting still includes every priced station.
- Keep ineligible prices visible, fall back to pump-price order, and label
Best Value unavailable when no comparison qualifies.
- Widget: same baseline and confidence path as the app.
- Tests: stale-closest baseline, alert baseline, eligibility policy,
boundary and missing-timestamp regressions.
The More info row went inside the Best Value card's `if let fuelSavings`, but the
Key sheet row was left ungated, so it stayed visible with Best Value switched off.
It is now gated on `fuelSavingsSettings.isConfigured` — exactly the condition
`savingsForStation` uses, i.e. the same gate as the card — so the two rows appear
and disappear together.
Verified on the simulator by toggling Best Value in Settings and scrolling the Key
sheet to its end (four drags, sheet still up and its footer in view each time):
- OFF: the row is absent at every scroll position, and the More info sheet shows
neither the card nor the row;
- ON: the row is present, and the More info card and its row are both back.
210 tests pass; Release builds.
The first onboarding screen showed the app icon inside AnimatedOnboardingIcon's
soft circle — a 10% tinted disc with an 18% ring around it. The wrapper now takes
`showsHalo`, defaulting to true so the other five pages and BestValueIntroView keep
theirs unchanged; the welcome page passes false.
Verified with before/after installs on the simulator:
- the icon's artwork is pixel-identical and unmoved (centre 603,661, 95 pt);
- the halo is gone — the tile's non-background footprint falls from the 118 pt
ring to exactly the 96 pt tile, and the ring pixels at r=59 pt lose their tint;
- page 1 differs only by the status-bar clock, so the other pages are untouched.
The copy below the icon tightens by ~11 pt: the page block is centred between two
spacers and the icon's container no longer contributes 22 pt of height. The page
dots and the Continue button do not move. The icon keeps its soft accent shadow.
The capsule label was just "Copied", which reads oddly above a row whose hint is
"Copies the postcode". It is now "Postcode copied".
Still centred over the address row (centre x 603 of a 1206 px screen) and still a
pure overlay: the bands above and below the capsule are pixel-identical while it
is up (no reflow), and the row stays tappable straight through it.
The "Copied" localisation key goes with its only use; "Postcode copied" replaces it.
The explanation used to be a section inside the Key sheet. It is a standalone
HowBestValueSheet now, opened from a tappable row with a chevron: at the bottom of
the Key sheet, and at the bottom of the More info sheet below the Best Value card.
Gating: the More info row sits INSIDE the same `if let fuelSavings` as the card it
follows, so it disappears with the feature. Verified with Best Value toggled off in
Settings: the More info sheet then shows neither the card nor the row. The Key
sheet row is deliberately ungated — that sheet is the legend and is reachable
whatever the feature is doing.
Copy is unchanged: the same Localizable.strings strings are reused, and the old
section header became the sheet's title. A DEBUG-only `-showBestValueInfo` hook
opens the sheet without a tap.
The confirmation belongs to the address row, so hovering it over the map read as
belonging to the map. It now overlays the address row itself, horizontally centred
on the address line, as .overlay(alignment: .center) on the row with
allowsHitTesting(false) still keeping the row tappable through the capsule.
Verified: with the capsule up, the OCR box sits at y 1189-1231 inside the address
band (map bottom is 1075), the tint blob is centred at x 602.5 against a screen
centre of 603, the row's ink runs outside the capsule footprint are byte-identical
to the idle frame, and a real tap on the capsule's own footprint still copies.
The address row is back to a single line: the copy glyph now sits on the address
line itself (trailing, plain glyph — the confirmation says it elsewhere), and the
always-reserved second line is gone.
The "Copied" confirmation is now a green-tinted capsule that floats over the
bottom of the map, directly above the address and horizontally centred. It is an
overlay with allowsHitTesting(false), so nothing reflows while it is up (verified:
the address row's ink, column for column, does not move) and the map's tap target
stays live underneath (verified: tapping where the capsule floats still opens
Apple Maps). A material base keeps it legible against map tiles, where a 14% wash
alone — the notice-capsule recipe — disappears. Timing is unchanged: 2s, then
fades, with the success haptic on copy.
The map already reads as its own block, so the row separator below it only
added clutter. Removed with .listRowSeparator(.hidden, edges: .bottom) on the map
row, leaving the Address/Distance and Distance/actions hairlines in place.
The map, the address, the distance, any notices and the Directions/Report issue
row now share a single section, in that order, with the buttons as the last row.
The list's own row hairlines separate them (inset like Address/Distance), so no
hand-rolled Divider is added — that would double the line.
The map loses .listRowInsets(EdgeInsets()) so it sits inset inside the card with
its rounded corners, aligned with the hairlines and text rather than butting the
card edges.
Directions/Report issue keep .buttonStyle(.borderless); verified with real
simulated taps in the merged card that each fires alone.
Directions and Report issue both fired on any tap inside their row: with the
default button style a List row has a single row-wide tap target, so tapping
one button triggered the other's action too (tapping Report issue opened Apple
Maps, and tapping Directions showed the report alert).
`.buttonStyle(.borderless)` gives each button its own hit area over its
half-row frame. Verified with real simulated taps on both buttons.
`-qaScreenMarker` paints the screen flat magenta, which lets a macOS capture
locate the device-screen rectangle exactly and map simulator-screenshot pixels
to click coordinates. `simctl` cannot drive taps and the project has no UI-test
target, so this is the only way to test real touch behaviour on the sheet.
Guarded by #if DEBUG, exactly like the existing -qaCopyPostcode hook.
Sharing the map's section made the buttons read as part of the map card and
the map tap target. They now sit in their own section, rendering as a
separate card directly beneath the map.
The two station actions now sit together directly beneath the map card, each
taking half the width, instead of being two separate rows at the foot of the
sheet.
The map tap target is now an OVERLAY button on top of the Map. A Map wrapped
in a Button inside a List row never received the touch, so the first attempt
left the card inert. The Map stays non-interactive and the overlay owns an
explicit rounded-rect content shape.
The copy glyph and its Copied confirmation move onto their own always-present
second line, so the confirmation can no longer reflow the address above it.
Verified: the address line's ink-pixel runs are identical with and without
the confirmation.
Distances (the "Approx. 1.5 mi" report):
- Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix
within 3 km of the position already held; a coarse fix in another city is
real travel and is still accepted).
- Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin,
used by rows, radius filter, sorting, savings figures and the Live Activity,
so a stray fix can no longer re-base the list outside the route cache's 600 m
ring while the routed values sit unused in the cache.
- Treat an incomplete route set as a state: retry it after 30 s rather than the
full 10-minute throttle, and repair a stale incomplete flag once every
in-radius station has a route (nothing left to route, flag stuck down).
- Widget extension resolves the same origin for fetch, radius, ordering and
labels, and keeps its own fix in fuelboard.widgetLocation instead of
overwriting the app's GPS fix.
Alerts:
- Best Value as a distinct alert mode on the existing geofence/notification
pipeline, with a persisted threshold; reverts to cheapest when the feature is
unconfigured and is not selectable while disabled.
Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate,
accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a
synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag
state, and the route-fill status is surfaced in Settings behind debug mode.
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.
The declined state had its own "Not now" label, on the theory that declining
should read as a decision rather than a skip. In practice the toggle above it
already shows which answer is being given, so the second label only added a
second thing to read. The label is now an unconditional Continue for both
states, which also let primaryTitle drop the settings value it no longer
consulted.
Nothing else about the page changed: the toggle still hides the sliders when
off, the "can be turned on in Settings" note still appears when declined, and
the settings still save as the controls move.
Verified on the simulator (OCR of the rendered screens): the announcement page,
and the onboarding page with the toggle both off and on, all show Continue as
the bottom button. No "Not now" remains in the app's source. 180 tests,
0 failures.
Users who finished onboarding before Best Value existed are "completed, not
fresh" forever, so the first-run gate never fires again for them and the feature
stayed invisible unless they went looking in Settings. They now get the Best
Value page on its own, once per announcement version — deliberately NOT a replay
of the onboarding flow, which would re-run permission pages they answered long
ago and has no skip.
- FuelStore: "fuelboard.bestValueIntroVersion", stored through the same
saveString/loadString path as the onboarding flag and compared against a
CODE-SIDE "currentBestValueIntroVersion". Deliberately not
CFBundleShortVersionString: keying on the bundle version would re-announce on
every bugfix release. Bump the constant to re-announce on purpose.
- FuelStore.shouldAnnounceBestValue(settings:hasSeen:onboardingDue:isSuppressed:)
holds the rule, with every fact injectable so it is unit-tested instead of
being driven through a simulator. It declines when onboarding is due or on
screen, when this announcement version has been seen, and when the user has
already configured Best Value themselves.
- BestValueAnnouncementHost wraps the app's content and owns the presentation
state, so ContentView's change is a single wrapper and the whole feature lives
in the new file. It reloads the stored settings on dismiss, because the page
writes straight to the store rather than through ContentView's binding.
- The page is now ONE view, BestValueIntroContent, used by first-run onboarding
(page 5 of 6) and by the announcement, so the copy, the sliders and the
"declining is a decision" rule cannot drift. OnboardingView drops its private
duplicate of the page, its bindings and its read-outs, and finish() marks the
announcement seen so a new user never sees the page twice.
QA hooks (DEBUG): -showBestValueIntro forces the announcement, and -skipOnboarding
suppresses it. Worth knowing now that a returning install announces on a PLAIN
launch, so tab screenshots need -skipOnboarding.
Verified on the simulator (OCR of the rendered screens): a plain launch of an
install that has completed onboarding shows the single announcement page — Best
Value title, explanation, "Turn on Best Value", the "Settings, under Fuel
Savings" note and a "Not now" button, with no page dots; -skipOnboarding shows
no cover at all; -showBestValueIntro forces it. 180 tests, 0 failures (8 new).
New page 5 of 6, sitting between "Prices, ready when you are" and "You're all
set":
- Explains the feature: the cheapest pump isn't always the cheapest fill-up.
- Asks whether to switch it on, with a toggle.
- Switched ON, it asks for the two figures the ranking needs — average fuel
economy and typical fill-up — using exactly the Settings slider pattern
(read-out above a slider, £/Litres picker, no text entry anywhere).
- Switched OFF, it says where to switch Best Value on later ("Settings, under
Fuel Savings") instead of showing controls the user has just declined, and
the button reads "Not now" rather than "Continue" so declining reads as a
decision.
Chosen figures are written to the store as the controls move — the same
immediate-persist rule as Settings — so swiping past the page cannot lose a
choice. ContentView now reloads the stored savings settings when onboarding
dismisses: onboarding is a full-screen cover writing straight to the store
rather than through ContentView's binding, so without the reload the choice
would not take effect until the next launch.
QA hooks (DEBUG only, alongside -tab / -skipOnboarding): -showOnboarding forces
the cover up (the app-group onboarding flag survives an app delete, so a
reinstall alone won't bring it back), -onboardingPage N opens straight on a
page, -onboardingBestValue on|off forces the page's choice.
Verified on the simulator: with the toggle off the page shows the title,
explanation, toggle, the "set it up in Settings" note and a "Not now" button;
with it on it shows the MPG slider (45 MPG), the £/Litres picker, the fill-up
slider (£40), the "change these in Settings" note and "Continue". Page 5 is
still "You're all set". Checked on an iPhone 17 Pro and on an iPhone SE (3rd
gen) screen — nothing clips on the small one.
172 tests, 0 failures.
It was the third block in the List (after Units and Tap action), so the feature's
own controls — and the MPG/fill-up figures that drive the Best Value ranking —
sat below unrelated settings. The section (header, toggle, sliders, footer) is now
the first thing in the List; Units and Tap action follow in their existing order,
unchanged.
The move is a pure reorder: no copy, no binding, no gating changed. The section
still greys its controls out when Best Value is switched off, and the footer
narrative is unchanged.
Verified on the simulator: Settings opens with Fuel Savings -> Best Value ->
Average fuel economy / 45 MPG, with Units and Tap action below.
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.
The third sort segment, the caption line it drives on the Stations tab, and the
Settings sentence about sorting now read "Best Value" — that is
SortMode.worthTheDrive.displayName plus the two copy sites that name the sort.
The feature that backs it keeps the name "Worth the Drive": the Settings toggle
("Enable Worth the Drive"), the Fuel Savings section header and footer, the sheet
header "Worth the drive?", the "Worth the Drive unavailable: …" text and
FuelSavingsStatus.worthTheDrive's own wording are all unchanged.
The enum's raw value stays `worthTheDrive`, so stored sort preferences and widget
configs keep decoding — only the label moved.
3 new tests pin the three sort names, the stability of the raw values, and the
fact that the feature's own wording did not change (172 total, 0 failures).
Verified on the simulator: the Stations segmented control reads
Closest | Cheapest | Best Value.
The sheet's header (navigationTitle, .inline) shows the station name, and the
address row no longer leads with the name either, so the Name row restated the
header immediately above the address. The section now opens straight with
Address; distance, Worth the drive and the buttons are untouched.
Verified on the simulator: the sheet reads "Sainsbury's Halifax" in the header
with rows "Address / WADE STREET, HX1 1SB" and "Distance / 0.5 mi", and no Name
row. No other surface in the app prints a Name row.
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".
With "Enable Worth the Drive" off, the MPG slider, the fill-up unit picker
and the fill-up slider stayed live — editable settings for a switched-off
feature. They now sit in a Group disabled by `enabled`, so they grey out but
stay on screen and keep their values (the same rule as the distance picker
in Closest mode), and re-enabling restores exactly what was there.
The surfaces that USE the feature were already gated on `isConfigured`
(which includes `enabled`), so the in-list fill-up control and the Worth the
Drive sort option still disappear entirely rather than greying out.
Verified on the simulator with the toggle off vs forced on: the MPG slider
row and the unit picker row both measurably dim — MPG row mean RGB
33.06/35.62/39.49 off vs 37.61/42.70/48.43 on; picker 40.00/40.00/42.62 vs
53.35/53.36/56.82 — while the toggle row itself changes as expected
(54.85/64.77/58.96 with the switch on).
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.
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.
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.
Expanded leading/trailing, compact and minimal island views had
no Link, so tapping them just opened the app with no destination.
Every island region now links to the same tap destination as the
Lock Screen card (Maps vs in-app detail).
Tap hint captions follow the setting too: Tap for details vs
Tap for directions.
Small widgetURL and medium list Links route through
StationTapAction via FuelStation.tapDestinationURL: Open map
keeps the Maps URL, More info uses a fuelboard://station
deep link (stationID plus coordinate/name fallback).
Deep link carries fallback coords; ContentView presents the
detail sheet when the station is cached, else the map sheet.
Live Activity detailURL upgraded to the same rich link.
Notification body/action taps route through StationTapAction:
Open map opens Apple Maps (existing); More info presents the
in-app detail sheet (falls back to the map sheet when the
station is not in cache).
Live Activity Lock Screen card and island station row pick
their Link destination the same way: Maps URL for Open map,
fuelboard://station deep link for More info, handled in
FuelBoardApp and observed by ContentView.
Also adds the small non-interactive map preview above the
Directions button in the More info sheet.
Moves the Tap station picker out of Units into a dedicated Tap
action section, with the tap descriptor text as its footer. Units
footer reverts to distances/prices only.
Settings → Units gains a Tap station picker: Open map (default,
historic behaviour — tap opens Apple Maps directions) or More info
(tap shows a detail sheet with name, address, distance from current
location, and a Directions button). Applies to Stations and
Favourites tabs; persisted keychain-first as
fuelboard.stationTapAction. Stations footer hint follows the setting.
Siri was hit-and-miss on 'unleaded near me' (\u2018can't do that, searching
in app\u2019 fallback). The generic parameterized AppShortcut ('cheapest
${fuel}') competed with the fixed-fuel literal-word shortcuts for the same
intent, degrading NLU ranking; it was already documented as flaky. Remove it
(now 9 shortcuts, under the 10 cap) and add natural phrase variants for
unleaded/petrol ('What's the cheapest unleaded near me', 'Cheapest unleaded
near me', 'Where's the cheapest petrol'). All phrases keep .applicationName
per the iOS 26 metadata requirement. Build-verified in extract.actionsdata:
generic gone, new phrases registered.
Callers previously morphed the existing lines on a reload, so picking a new
range only glided in place. Now every range/fuel selection (the only reloads;
the sheet has no periodic refetch) replays the staggered per-series draw-in
via revealSeries(), so the lines visibly redraw left->right when the user
switches 7/30/90. Price<->vs-cheapest still morphs (pure view toggle, no
reload). Reduce Motion jump-cuts.
The single-waterline trace-in was invisible with sparse data (2 points draw
in one step). Replace it with a per-series waterline: each station's line now
traces left->right on first appearance, series revealed 0.18s apart in a
cascade so the draw is clearly visible. Newly-added favourites get a full
reveal immediately on reload; Reduce Motion still jump-cuts. Range/mode
morphs and numeric headline transition unchanged.
- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing
lines + y-axis to the new data via withAnimation/.animation(value: mode)
instead of snapping.
- One-time left->right trace-in on first data appearance by revealing an
increasing prefix of each series' points (area + line stay in sync); later
switches morph rather than re-trace.
- Headline average rolls its digits with .contentTransition(.numericText).
- All animation honours .accessibilityReduceMotion (jump-cut to final state).
Kept two-pass area/line ordering, explicit series:, bounded fill bands, and
tight chartYScale unchanged.
The road-distance cache was keyed purely by station ID with no coordinate, so
a value routed to one pin could be served for a same-ID station whose stored
coordinate came from a different source (live fetch vs bundled offline dump
vs a corrected pin), reproducing Maps mismatches.
- RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters,
lat, lng}) and roadDistanceMeters() only serves a value when the displayed
station's coordinate matches the pinned one (within ~11 m).
- Kept the existing origin-distance staleness guard.
- Verified embedded sample data (ids y1/se1/...) never collides with real
relay IDs, and the widget STRICT radius filter drops far-offline samples, so
no actual leak existed in practice — this closes the theoretical stale-pin
channel and future-proofs against coordinate fixes.
The keychain-first completed flag persists across reinstalls (same bundle id),
so a new install was treated as already-completed and onboarding silently
skipped while permissions still prompted. Add install identity:
- Store an install id in BOTH the app-own container defaults (wiped on
reinstall, persists across launches) and keychain (survives reinstall).
- isFreshInstall() is true on first-ever launch or when the local id is
missing/different from keychain (i.e. reinstalled); it seeds a fresh common
id so the next launch of the same install is not 'fresh' again.
- shouldShowOnboarding() = not completed || fresh install -> onboarding now
replays once per fresh/reinstall, never on ordinary launches.
Works on free SideStore (no app-group), using the app's own defaults domain
as the same-install signal.
Road-distance routing previously only fired once a fresh GPS fix arrived via
the location hook/onChange. A returning user on a cold launch waited for that
fix, and if onboarding/flag issues kept tracking from starting, distances
stayed straight-line. Now:
- refreshRoadDistancesIfNeeded() uses the last saved location when the live
fix isn't set yet, so it can run immediately.
- It's called explicitly on launch (already-onboarded branch), on foreground
activation, and on the existing location paths.
The completed flag lived only in app-group UserDefaults
(UserDefaults(suiteName: appGroupSuite)). On free SideStore accounts the
app-group container isn't provisioned, so the suite read as nil: save was a
silent no-op and load always returned false -> onboarding re-appeared on
every launch even after finishing and granting all permissions.
Switch to saveString/loadString (keychain-first, app-group mirror) under
onboardingCompletedKey, matching favourites/distance-unit persistence which
survives reinstall and works without a provisioning group. loadString falls
back to the app-group mirror, so anyone who previously completed on a
provisioned group keeps their flag; users on unprovisioned groups complete
once more and then it sticks.
Stations past the nearest-12 cutoff silently fell back to straight-line,
which explained the remaining app-vs-Apple-Maps gaps (e.g. 3.5 straight-line
vs 5.9 road). Raise the per-pass cap to 40 and route every station within a
25 km straight-line radius (covers the 15 mi max search window) so any station
the widget/Live Activity/list can show gets a real road distance.
Also harden the new cache tests against persistent keychain state across
invocations (reset in setUp) so they pass deterministically.
The widget/Live Activity showed straight-line haversine distance (0.8 mi)
while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget
execution + ~40-70/day refresh budget, so the APP now computes it:
- New RoadDistanceService (app target): for the nearest 12 stations each
pass, calls MapKit MKDirections (free, no API key, matches Apple Maps)
and caches metres keyed by station ID.
- Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it
even on free SideStore accounts with no app-group container; only valid
within 600 m of the location it was built from.
- Throttled: recompute max every 10 min, or when the user moves > 400 m;
wired into the location-update hook + location onChange.
- Widget face, app station rows + Live Activity show the cached road
distance, falling back to straight-line when absent.
- 4 new cache tests (105 total).
GeometryReader as the layout root pinned content top-left, and forcing
maxHeight:.infinity over-claimed the whole proposed height, centring the
content below true vertical centre (bigger gap above) on the Lock Screen.
Demote GeometryReader to a background side-channel that only measures the
slot width for the rich-vs-compact branch; the content stays intrinsic-sized
so the system vertically centres it itself.
User: 'Remove the white background circle from the live activity'. Drop
the explicit Circle badge and render the fuel-tinted fuelpump.fill on its
own (sized up 19 -> 28 since it no longer sits inside a disc).
fuelpump.circle.fill's first hierarchical style hits the PUMP, not the
circle, so the earlier darkened colour landed on the pump and the circle
stayed white (user: 'I didn't want the fuel pump darker I wanted the
white circle darker'). Rebuild the brand glyph from two explicit shapes:
a light-grey (.86) Circle badge + a bright fuel-tinted fuelpump.fill, so
circle vs pump colours are unambiguous and the badge centres cleanly with
the two-line text block.
Add a DEBUG-only -qaLiveActivity <fuel> launch hook that starts a Live
Activity with a long station name so the Lock Screen can be rendered in
the Simulator and verified by pixels (pump bright green 43,193,81; badge
grey 202,201,202).
A GeometryReader hands its content the full proposed frame and pins it
top-left, so the Lock Screen card sat high instead of vertically centred
when the activity container offered extra height. Wrap the branch in
.frame(maxWidth:.infinity, maxHeight:.infinity, alignment:.center).
Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so
the white pump symbol keeps contrast against bright Lock Screen/card, esp.
for the light yellow premium / cyan diesel palette colours.
Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A,
cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and
Live Activity all read one definition (was app-only in StationsView, so the
widget couldn't see it). Tint the Live Activity rich card pump icon and the
Dynamic Island compactLeading pump by context.state.fuel.tintColor.
ViewThatFits measures each child's IDEAL (untruncated) width to decide
fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap
that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations)
inflated richBody's ideal past the iPhone Lock Screen width and fell back
to the compact strip. Replace the ViewThatFits branch with a
GeometryReader that reads the ACTUAL proposed width and shows the full
three-column card at/above 280pt, compact below. Content-independent, so a
long name can no longer collapse the iPhone card.
The compact CarPlay/Watch strip's second line had escaped interpolation
(\\( instead of \\() so it rendered the literal placeholder text
"context.state.stationName · context.state.distanceText" instead of the
actual station name and distance. Normalize to single-backslash interpolation.
Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact
fallback so CarPlay's small slot renders our shared compact strip (fuel type,
bold price, station · distance) instead of falling back to the Dynamic Island
compact closure, which showed the app name and omitted the distance the user
wants. Full iPhone/iPad card is preserved via the richMinWidth gate on
richBody plus its flexible, truncating middle column (long names can no longer
collapse it).
Bring watch branch up to date with main (banner fade, onboarding timing,
Live Activity full-width layout). Resolved the bundled fuel dump data-refresh
conflict by taking main's newer snapshot (data_updated 2026-08-17T15:01, 8028
stations).
Long station names inflated richBody's IDEAL width, so ViewThatFits (which
measures ideal, untruncated text) decided 'rich doesn't fit' and fell back to
the compact strip — that's why 5-mi / long-named activities rendered small
while 10-15-mi / short names stayed full. With .supplementalActivityFamilies
already removed, the body is only ever Lock-Screen width, so drop ViewThatFits
and compactBody entirely and always render the full three-column card; the
station caption line-limits + scale-downs + tail-truncates in place.
The active activity was rendering the narrow .small form on the iPhone Lock
Screen (squeezed, small text) because .supplementalActivityFamilies([.small])
made iOS eligible to present it that way. Remove it so iPhone/iPad always get
the full three-column card; CarPlay's small form still comes from the Dynamic
Island compact closures. Keep compactBody as a narrow-width safety fallback.
The adaptive layout picks the full three-column view when there's Lock
Screen width and a compact price strip in the small CarPlay/Watch slot.
Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad
always get the full view and only genuinely small slots get the compact
strip. Long station names used to clip at inconsistent points across
the rich/compact/Dynamic Island views; both bodies now scale + tail-
truncate so the caption stays consistent regardless of station name.
SwiftUI's removal transition never actually played despite .id keying — the
tip banner vanished instantly on dismiss. Replace it with explicit opacity/
offset driven by withAnimation in reflectBanner (gated by a .task(id:
activeBanner) so presentation is reliable and prompt): a dismissal now
fades out + slides up over 0.35s, and a tip→network swap crossfades. Uses
the same state-driven pattern that fixed the HermesCall ticker fades.
The banner sits in a single if-let slot; without an identity key, clearing a
tip that was covering a network/offline banner swapped content in place with
no transition (instantly vanishing). Keying the banner by its value (.id)
makes every change — tip→network or tip→nil — run the shared removal
transition, so the tip slides up and fades out when it auto-dismisses or is
tapped.
- Banner floats at the very top (over the nav/title area), never pushing the
content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
fails instead of silently re-serving a cached pointer+dump as a 'success'
(which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
the system prompt appears after the page is read and never covers the next
page's animation (grant auto-advances).
Position the floating banner just below the nav bar so it no longer overlays the iOS 26 Liquid Glass back/toolbar chrome behind it — tapping retry can no longer also select the control beneath. Banner now slides up + fades on dismiss/timeout (one style for all notifications); full-rect contentShape for a solid tap target.
Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
Tip purchase results now ride the SAME banner chrome as the network/offline
strip above the tabs instead of a hard-to-see Settings footnote. TipStore is
lifted to ContentView (the banner owner) and shared down to Settings. Each
outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss.
Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle:
trailingIcon:action:) used by both the network strip (retry) and tips (x).
A history fetch that comes back empty BECAUSE the mirror is unreachable
(pointer probe failed) was only an in-sheet retry card — no global signal.
Now it also raises the red connection banner ('Check your internet
connection / Tap to try again'), so a network problem is visible on every
tab, not just inside the Trends sheet. A successful load clears the
banner (only when the banner is the connection banner — never clobbers
the offline-dump banner).
- TrendsView: onHistoryUnavailable/onHistoryRecovered closures fired from
load() (loadFailed -> unavailable; hasAnyData -> recovered);
-forceHistoryFailure QA hook (forces the unreachable state, skips
auto-refresh like the other force-* hooks)
- FavouritesView: closures threaded through the Trends sheet init
- ContentView: wires them to dataStatus (.live -> .connectionProblem on
failure; recovered clears only .connectionProblem)
- fuelboard-development skill: hook + wiring documented
Fix 1 — the offline banner's subtitle claimed 'pull to refresh' while that
gesture only exists on the Stations tab, yet the banner pins across ALL
tabs. Copy now says where the gesture lives: 'Pull to refresh on the
Stations tab'. The banner itself stays tappable everywhere (tap = retry),
so the retry path is never trapped on one tab.
Fix 2 — fetch-failed-with-cache case showed stale saved prices with NO
feedback (looked like a working live app). New second banner state
'Check your internet connection / Tap to try again' (red wifi.exclamation
mark, tap = retry) now appears whenever the fetch fails but a saved cache
is on screen. It clears on the next successful fetch.
- ContentView: offlineDataDate:String? -> dataStatus enum (.live /
.offlineDump(date) / .connectionProblem) driving a shared statusBanner
(icon/tint/title/subtitle); -forceConnectionProblem QA launch hook;
auto-refresh skipped under either force-* hook
- Localizable.strings: subtitle key updated, +5 new keys (incl. date-less
'Offline data' fallback + a11y labels)
- Tests unchanged (101 green) — pure view-layer change
The no-network last resort (bundled real dump) now shows a tappable banner
above the tabs — 'Offline data from 15 Aug / Pull to refresh to update
prices' — so stale snapshot data is never presented as live. Banner date
comes from the dump's own data_updated stamp (FuelStore.offlineDataLabel,
ISO 8601 with/without fractional seconds). Cleared on any successful fetch.
- BundledDumpProvider.dataUpdatedStamp (JSONSerialization, app target)
- ContentView: offlineDataDate state, offlineBanner view (tip-card styling,
orange wifi.slash + arrow.clockwise, tap = force refresh), refresh()
clears/sets it, -forceOfflineDump QA launch hook (serves the dump and
skips auto-refresh so the banner stays up for captures)
- Localizable.strings: 3 keys (banner title/subtitle/a11y)
- +3 tests: OfflineDataLabelTests (101 total)
Legend rows now show each station's average over the days shown
in brackets (pence; signed +X.Xp in vs-cheapest mode, matching the
list-row deltas). Footer under the key explains the figure per mode.
averagePence helper (nil for empty series) + 3 tests; -showTrends
becomes a permanent QA hook like -showKeySheet.
LineMark lacked a series: value, so Swift Charts merged every
station's points into one polyline — only the first station's
line was recognisable. Adding series: per station splits the
chart into N coloured lines (legend colours now match).
Sheet grows from .medium to .fraction(0.6) so the footer ('Colours
match each station's rating on the list') clears the bottom edge with
comfortable margin (verified: footer clearance +33pt). Sheet background
switched from the translucent default to a solid
systemGroupedBackground via .presentationBackground so the stations
list no longer shows through.
The two bottom cards (TOP, star) previously had no padding — their
background hugged the text. Add .padding(.horizontal, 14).padding(.vertical, 12),
matching (slightly exceeding) the RAG cards' vertical padding so the
info cards read as proper cards.
Price-rating legend now renders as five accent cards (tip-section style):
Best value (green, checkmark) / Okay (orange, equal) / Pricey (red,
exclamation) each with a RAG dot + threshold (≤1.5p / ≤4p / >4p) on the
right in the row's accent colour; TOP (blue capsule) and star (yellow)
as accent cards below a divider. Footer: 'Colours match each station's
rating on the list'. Copy split into title/blurb keys in Localizable.
Sheet chrome (medium detent, Done, info button) unchanged.
Three stacked rounded cards, one per tier, each with an accent-tinted
fuel-pump icon and price (Splash & Dash #64D2FF blue, Half a Tank
#30D158 green, Fill 'Er Up #FFD60A amber — the app's fuel palette),
name + blurb on the left, bold accent price on the right. Cards sit on
clear list rows with their own rounded fill + subtle accent tint.
Purchase flow, per-tier live prices, disabled state and footer copy
unchanged.
Adds the three tip IAPs (tip099/tip299/tip499 at £0.99/£2.99/£4.99) to
the USER-owner ASC tasks, and the In-App Purchase capability to the
App ID blocker alongside the App Group.
Splash & Dash (£0.99), Half a Tank (£2.99), Fill 'Er Up (£4.99) —
each a consumable (com.apt.fuelboard.tip099/tip299/tip499) with its own
blurb row. TipStore now loads all three products, prices fall back to
the intended storefront values until ASC products exist, and the
Transaction.updates listener acknowledges any of the three tiers.
Footer copy: 'A tip helps keep this app free and supports development.
Thank you'.
The £4.99 consumable was never finished — StoreKit kept re-delivering
it through Transaction.updates on every launch. Verified purchases now
call transaction.finish() (direct path), and a Transaction.updates
listener finishes + acknowledges purchases that complete outside the
direct call (Ask to Buy approvals, other-device purchases on the same
Apple ID). Untrusted (unverified) transactions are never finished or
delivered; only our own product ID is handled.
- AppIconPreview now byte-identical to the real AppIcon.png (the preview
had been pointing at an older Design/ export); added AppIconPreviewDark
from AppIcon-dark.png so the mock shows the correct icon per appearance
- Re-captured 9-widgets-dark + 10-widgets-light
- 95/95 tests green; Release IPA rebuilt + re-served
- PreviewWallpaper image set: the actual iPhone wallpaper extracted from the
iOS 26.3 sim runtime (ActionButtonSelector Display.ca, 1320x2870 native
res, JPEG ~180 KB) — replaces the gradient backdrop in the widget mock
- -seedFavourite <substring> launch hook: pins the first matching station
for Unleaded via the normal keychain+app-group save path, so the
Favourites tab can be captured populated (Ring Road Service Station,
129.9, 59.1 mi, TOP badge)
- Re-captured 2-favourites (light), 6-favourites-dark, 9-widgets-dark,
10-widgets-light with real data
- 95/95 tests green; Release IPA rebuilt + re-served
- Small widget card sized to actual system-small content (158×158pt + padding)
instead of filling available width; medium keeps 338×158pt
- Wallpaper now mimics the iOS default (dark navy→black in dark mode,
soft blue-grey in light) via colorScheme-adaptive gradient
- Added 10-widgets-light.png; re-captured 9-widgets-dark.png with data
- 95/95 tests green; Release IPA rebuilt + re-served
- Move widget entry view into Shared/FuelPriceWidgetViews.swift (FuelPriceWidgetContent,
FuelPriceWidgetEntryData) so the app can render the REAL widget faces; widget
extension now wraps it (FuelPriceEntry.data) — one source of truth for the face
- WidgetMockScreen (-widgets launch arg): app icon + small + medium widget previews
for App Store captures; AppIconPreview image set (1024 drip master)
- AppStoreScreenshots: 5-8 dark-mode tab captures with real Halifax data, 9 widgets
- 95/95 tests green; Release IPA rebuilt + re-served
Raw tab captures for the yuzu-hub App Store Screenshot Generator:
stations, favourites, alerts, settings. Captured via simulator launch
args (-skipOnboarding / -tab).
b634c06 fired on page arrival, so swiping stacked Location + Notifications
before a screen was read. Now each permission fires only when the user
ADVANCES PAST its page (swipe forward or Continue) — the screen has been
read by then. Forward-only (swiping back never fires), idempotent
(already-determined → status refresh), so replay and auto-advance stay
clean. Continue buttons are now pure navigation; denied paths unchanged.
Swiping past the Location/Notifications pages never triggered the system
prompts — only tapping Continue did. The prompt now fires the moment its
page comes into view (onChange(of: page)): swipe, auto-advance after a
grant, or Continue all land on the page and request the permission. Both
request methods are idempotent (already-determined → status refresh, no
duplicate alert), so replay and the auto-advance path stay clean.
The N/TOTAL stations-updated explainer now sits in a plain caption Section
cell at the bottom of the tab (font .caption, secondary) — matching the
Favourites tab's bottom callout instead of rendering as a section footer.
The header section was removed entirely — the tab now opens with Fuel type.
The explainer lives as a section footer at the bottom of the tab:
- Cheapest: 'Cheapest Unleaded within 5 miles — 46/8022 stations updated ·
tap a station for directions.'
- Closest: 'Closest Unleaded stations — nearest first, best value within
5 miles · 46/8022 stations updated'
- no-location variant keeps mode + tap-for-directions hint
Numerator = stations in the current list pool (as before); denominator =
full UK station total from the last chain fetch (loadStationCount, 8,022
mirror snapshot) — ratio hidden until the first fetch lands.
The LAN relay is unreachable from any phone outside the developer's LAN, so
for App Store users a relay fallback was dead weight AND harmful: attempting
the private IP fires the iOS Local Network prompt on a stranger's phone and
adds a timeout before the bundled dump. GitHub raw is reachable anywhere the
relay would be, and more reliably.
- LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no
longer relay-first); LAN relay only joins behind the hidden dev flag
fuelboard.relayFallback (Settings → Debug, keychain-first, off by default)
- MirrorFuelProvider: focused() projection replaces the relay's server-side
radius endpoint (fuel filter + within-radius + nearest-first + limit 500)
— the alert path re-checks radius/fuel itself, so behaviour is identical
- Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache
shared through the app group); relay + widget-diag GET gated on the same
dev flag — off-LAN widgets no longer attempt local network at all
- FuelBeacon: fires only when the dev flag is on — consumers make zero
local-network attempts; /stats app-hit spike = GitHub down + dev flag on
- Onboarding: Local Network machinery removed entirely (no probe, no prompt,
no 12 s stall) — data page is now informational: prices download from the
internet, no permissions needed. Replay on cellular can no longer hang.
- Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording
- 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror
focused projection x3, beacon dev-flag gate)
- MirrorFuelProvider: fetches CURRENT prices from raw.githubusercontent
(latest.json pointer → history/<day>.json full dump), app-group day-cache
so the ~2.8 MB dump is re-downloaded only when the mirror pushes a new day
- LiveChainProvider: full path (app refresh) GitHub → relay; focused path
(background alert checks) relay-first so alerts never pull the full dump
over mobile data; records which leg served for About + telemetry
- FuelBeacon: fire-and-forget X-Client app ping to the relay's existing
widget-diag route (zero relay changes) — attribution + cadence on-LAN,
silent skip off-LAN is the reachability datum; /stats app-hit spike =
GitHub path failing
- Bundled dump: FuelBoardDump dataset (real 2026-08-15 snapshot, 8,022
stations) + BundledDumpProvider + scripts/refresh_bundled_dump.sh —
no-network last resort replaces the demo sample set
- ContentView: About meta from the chain; catch falls back cached → bundled
→ sample
- 89 tests (6 new: chain order x4, cache decision, beacon URL); Release
build green; beacon route verified against the live relay (204 + logged)
2026-08-15 13:00:24 +01:00
502 changed files with 14942 additions and 1423 deletions