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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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).
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)
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)
The single-slot beacon couldn't distinguish small vs medium (last writer
wins). Beacon keychain service now embeds the intent type name so small
and medium never overwrite each other; the relay GET now carries the
intent type + entry state as query params so the access log shows exactly
which widget kind ran makeEntry, when, and with what. Diagnostics screen
shows SMALL and MEDIUM beacons side by side — a missing SMALL beacon
after adding the small widget proves its timeline never reaches the
provider (system-level config resolution failure).
The small-widget skeleton survived four resolution fixes; instrument the
widget instead of guessing. Every makeEntry now writes its entry state
(intent type, location source, station count, fuel, sort, favourites flag,
first station) to a keychain beacon (app-readable) and fire-and-forgets a
GET to the relay's /api/v1/widget-diag so its access log records whether a
widget timeline actually runs in the extension and what it produced.
Settings → Debug gains a Widget Diagnostics section: the keychain beacon +
the installed-widget inventory from WidgetCenter. Relay gains the
/api/v1/widget-diag route (204, log-only).
- Alerts section restyled to mirror the Live Activity section: one section
with toggle + Fuel picker + Radius picker (was segmented fuel + slider)
- Alert radius options expanded to 1/2/3/5/8/10/15/20 (display unit, snapped
to nearest option; stored km unchanged; validation widened to 33 km)
- Live Activity section moved from Settings into the Alerts tab so both
'notify while driving' surfaces sit together
- SettingsView loses the Live Activity bindings/section
- Settings → Live Activity now has Fuel and Distance pickers (5/10/15,
unit-aware) stored under fuelboard.liveActivityFuel / liveActivityRadiusMiles
- LiveActivityManager uses these instead of the Stations-tab selectedFuel /
stationLimit; Stations changes no longer re-target the pill
- Fuel moved from Activity attributes into ContentState so changing fuel in
Settings updates the RUNNING activity in place (no end+restart needed)
- Stations tab stays a pure data-interrogation + favourites surface
- Relay envelope now carries source (api|csv), stations_count, and
data_updated (freshest price_last_updated the GOV.UK server reports,
independent of relay sync time).
- App decodes the new envelope metadata (RelayMeta), persists it after
each full fetch, and shows it in Settings → About → Data source:
Connection (API/CSV), Stations count, Data updated (local-formatted).
- Older relays without the fields decode cleanly (nil meta → em-dash).
- 2 new tests for meta decode + absent-meta tolerance; 37/37 pass.
- Debug section now shows the last device fix (lat/lng to 5dp) and its age.
- In-range indicator uses the SAME criteria as live alerts: stations selling
the monitored fuel within the alert radius of the fix. Green dot + count
when in range, red when none, grey while waiting for a fix.
- Shows the cheapest in-range station (name, distance, price).
- ProximityMonitor recomputes the snapshot on every location/stations
change and on Debug-section appear; ContentView passes the live status.
- SettingsView extracted to its own property to keep TabView body within
the compiler type-check budget. 35 tests pass.
- Test-alert buttons moved out of the Alerts section into a Debug section.
- Debug section only renders when the debug flag is on; completely hidden
otherwise (no user-facing toggle).
- Hidden toggle: tap Settings → About → Version five times to flip the
flag (keychain + app-group storage, survives reinstall).
- About section added with version/build number.
- 35 tests pass.
- The widget now uses the native maps://?daddr= scheme for taps in both
sizes. In CarPlay there is no containing app to relay through, but since
Apple Maps IS a CarPlay app, the system may route the tap straight to
Maps on the car display (WWDC25: widgets can launch apps in CarPlay).
- iOS keeps working via onOpenURL: the app now forwards maps:// URLs (if
the Home Screen delivers them to the app), plus legacy fuelboard:// and
http://maps.apple.com links from older cached timelines.
- No public API exists for a widget to detect CarPlay context, so the
single maps:// link serves both; the app-side forward is the iOS safety
net. 35 tests pass.
- WidgetKit widgets can only open their containing app — the Link URL is
delivered to FuelBoard via onOpenURL, never opened directly in Maps. The
widget now links to a registered fuelboard://directions?lat=&lng= URL,
and FuelBoardApp forwards it to native maps://?daddr= directions.
(http://maps.apple.com and raw maps:// both silently dropped in-app.)
- Widget rows (small + medium) now show the full station name (station.name)
instead of just the brand.
- Onboarding network page: removed the 'Check again' secondary screen after
the Local Network prompt — Open Settings remains the only retry path.
35 tests pass.
- Widget tap-to-directions now uses the native maps:// scheme instead of
http://maps.apple.com. The universal-link http URL failed to open from a
widget and fell back to launching the containing app; maps:// opens
Apple Maps directly for both small (whole widget) and medium (per row).
- Onboarding: removed the Skip button (mandatory flow), and system prompts
(location, notifications, local network) now fire only when the user taps
Continue/Allow on each page — not when the page appears. Pages
auto-advance once a permission is granted.
- After 'Start Using FuelBoard' the first data fetch is forced, so a
reinstall that leaves a recent lastRefresh in app-group defaults can no
longer skip the fetch and leave the station list empty. 35 tests pass.
When several stations share the cheapest price within the radius (within
0.01p), the alert registers a dynamic UNNotificationCategory with up to
4 action buttons (brand + distance, nearest first) and taps open
directions to the chosen station. Shared FuelStore.tiedStations helper
used by both the live geofence path and the Settings real-data test;
35 tests pass.
The proximity monitor was tied to the Stations-tab fuel selection, so
switching the list to another fuel silently re-targeted geofences. Now the
Alerts tab has its own 'Fuel to monitor' picker (persisted as
fuelboard.alertsFuel):
- Monitor reads loadAlertsFuel() on init/background relaunch
- Changing the alerts fuel re-registers geofences around stations selling
that fuel only
- Favourites priority slots stay scoped to the monitored fuel
- Tests: alerts fuel round-trip + default (33/33 passing)
Fixing the bug where favouriting an Unleaded entry also created a Diesel
favourite. Favourites are now [FavouriteEntry] = (station, fuel) pairs:
- Starring a row pins it only for the fuel being viewed
- Favourites tabs show only fuels that actually have favourites
- Geofence monitor gives priority slots only to favourites matching the
monitored fuel
- Tests updated + new fuel-scoping test (31/31 passing)
- FuelType labels: 'Unleaded (E10)'/'Premium (E5)' -> 'Unleaded'/'Premium'
- New DistanceUnit (miles/km) preference; all app + widget distances,
radii and alert text convert/display in the chosen unit
- Settings tab (4th tab): segmented miles/km picker, 'Show introduction'
replay button (onboarding moved out of Alerts tab), StoreKit tip £4.99
- ProximityMonitor + widget notifications use the chosen unit
- Tests: label expectations updated, DistanceUnit conversion + format tests