- 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.
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.
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.
- 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
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 iOS 18+ long-press app-icon menu (turn icon into a widget) shows
the supported SIZES of the FIRST-registered widget kind only — it
does not list widget kinds and is not keyed by intent type (two
distinct intents in 6375af4 changed nothing on-device). The bundle's
first kind (FuelPriceSmallWidget, small-first since 38b47ee) therefore
decided the menu offered only the small tile.
The early build worked because FuelPriceWidget was registered first
back then. Reordering to medium-first would fix the menu but flip the
gallery order (38b47ee). Instead: this first kind now supports BOTH
.systemSmall and .systemMedium, so the icon menu offers both size
tiles (ChatGPT/Safari behave the same way — their first kind supports
several families). The gallery still lists both widget kinds in bundle
order; the Small entry just gains a medium variant. The shared view
already branches on family, so medium renders the station list.
iOS keys the long-press app-icon contextual menu by configuration-intent
TYPE, so sharing one intent across kinds collapsed the menu to the
first-registered kind (small) — the medium 'FuelBoard Prices' vanished
from the icon menu while the + gallery (kind-keyed) still listed both.
dafc96f's skeleton fix was never the problem; the extra favourite
AppEntity params were.
FIX: FuelBoardSmallWidgetConfigurationIntent — a distinct intent TYPE
with the IDENTICAL minimal parameter set (fuel/sort/distance only, no
AppEntity params). Fresh-widget default-config resolution stays intact;
the appex metadata now registers two WidgetConfiguration actions.
Verified: build green, 45/45 tests, extract.actionsdata lists both
intents, fresh IPA packaged + signed per component.
The pump glyph in the widget header (both the small single-station face and
the medium list face) was hardcoded green; it now uses the app's fuel
colour wheel — green #30D158 unleaded, yellow #FFD60A premium, cyan
#64D2FF diesel — mirroring the per-fuel pumps in the app's segmented
picker. Palette duplicated in the widget target (Shared is Foundation-only
by design); cross-referenced to StationsView.swift's FuelType.tintColor.
ROOT CAUSE (proven by A/B test on-device): the small widget's custom
intent (FuelBoardSmallWidgetConfigurationIntent with favouriteFuel +
favourite AppEntity params) fails fresh-widget default-config resolution
at the system level — the whole default config fails, no timeline is
ever requested, makeEntry never runs, widget stays on the skeleton.
A minimal small widget sharing the MEDIUM intent (fuel/sort/distance
only) populated immediately in the same extension on the same device.
Neither removing defaultResult() (4d35b08) nor the
@IntentParameterDependency (8a9e775) helped — the extra entity params
themselves break resolution.
FIX: FuelPriceSmallWidget now uses FuelBoardWidgetConfigurationIntent
exactly like the working test widget. FuelBoardSmallWidgetConfiguration
Intent, FavouriteFuel(+Query), WidgetFavourite(+Query) deleted; the
pinned-favourite picker is gone (approved tradeoff). Favourites sort
still works on both faces via the list branch — the small face renders
the cheapest favourite of the chosen fuel. Beacon tag distinguishes the
small widget in diagnostics; test widget removed, gallery order
restored (small, medium, live activity).
Temporary FuelBoardTestSmallWidget kind: a small-face widget driven by
the PROVEN medium intent (fuel/sort/distance only — no favourite params,
no dependency, no custom entity query), registered first in the gallery.
Isolates small SIZE vs small INTENT: if it populates while
FuelPriceSmallWidget skeletons, the small intent's extra machinery is the
culprit; if it also skeletons, the failure is small-family/system-level.
Provider gains a beaconTag so the test widget's beacons are
distinguishable in the relay log and keychain slots. Removed once the
root cause is confirmed.
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).
The small-widget skeleton (fresh widget stranded on the placeholder while
the medium populated in the gallery) traced to the small intent's default
configuration resolution: FavouriteFuelQuery/WidgetFavouriteQuery
defaultResult() read keychain in the gallery-preview/fresh-add path,
failing the whole default config and leaving no timeline. Configured
widgets bypassed defaultResult (stored values → lenient entities) — the
exact favourites-works/cheapest-closest-doesn't split.
- Remove defaultResult() from both queries: fresh configs now resolve
from the static @Parameter(default:) values through lenient
entities(for:) with ZERO storage reads.
- WidgetFavouriteQuery.entities(for:) skips the keychain name lookup for
id-only (empty station) values.
- Auto-populate moves to makeEntry: empty pinned favourite → first
favourite of the fuel (same rendered result as the old sheet default).
- Sheet keeps the dependent picker via suggestedEntities() + dependency;
fresh widgets show static defaults until picked (approved tradeoff).
- IPHONEOS_DEPLOYMENT_TARGET 17.0 -> 26.0 (iPhone 11+, Apple's current floor)
- Widget CarPlay disfavor is now unconditional (no #available guard)
- Live Activity gains supplementalActivityFamilies([.small]) so it renders
in the car's small slot on CarPlay Ultra (display-only: FuelBoard has no
CarPlay entitlement, car taps can't launch anything)
1. CarPlay widget taps could never launch Maps — FuelBoard is not a
CarPlay app, and Apple only lets a widget launch its own app in
CarPlay when that app is CarPlay-enabled. Marked the widget as a
disfavored CarPlay location (iOS 26+; no-op below): read-only in
the car, no dead tap. Header comment updated to match reality.
2. Widgets now track the user's location: timeline refresh 15m -> 5m,
and the app's location manager reloads all widget timelines after
every significant move (250m filter, throttled to 60s), so the
widget re-orders around the new position while driving instead of
waiting for the next tick.
3. Debug 'Cheapest in range' no longer shows the ALERT prediction
(alertsFuel + alert radius — a different pool by design). It now
mirrors the app list exactly: the TOP-badge station for the
selected fuel within the stationLimit radius (new DebugAppCheapest
computed in ContentView, passed to Settings). Alert-prediction
cheapest kept as fallback when the app view hasn't resolved.
The 3-row cap was hardcoded for the medium widget height; large widgets
have ~2x the height and fit 5 compact rows. Timeline now carries up to
8 stations (was 4) so the large list never starves. Distance picker
still controls which stations are in range; the visible row count now
depends on widget size, not the distance setting.
- New Sort by value 'Favourites' in Edit Widget (per-widget config).
- ParameterSummary hides the Distance picker when Favourites is selected
(favourites are not radius-bound).
- Favourites mode reads keychain favourites (shared access group works on
SideStore free even without App Groups), filters to the widget's fuel,
refreshes prices from a focused relay fetch when in range, and ranks
cheapest-first with distance tiebreak. Not radius-filtered.
- App now re-saves refreshed favourites to keychain after every fetch so
the widget shows current prices instead of star-time snapshots.
- Empty state + headings adapt to favourites mode. 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.
- New Distance picker in Edit Widget (5/10/15 miles), independent of the
app's distance filter; used for both the relay fetch radius and the
strict in-widget filter.
- Brand text is now title-cased via sanitizedStationTitle (relay sends
all-caps brands like SHELL/TESCO/MORRISONS).
- Prices now show as £1.399 (monospaced) matching the app, instead of
139.9p.
- Directions deep-link (mapsDirectionsURL) already per-row on medium and
whole-widget on small; unchanged. 35 tests pass.
The widget previously read stations only from the app-group cache, which
SideStore free accounts don't provision — the widget process saw empty
data and silently fell back to sample stations (stuck on Morrisons).
Now the widget makes its own focused relay fetch around its location
(radius + limit 500, 5s timeout) whenever it has a fix, falling back to
the shared cache then samples. Widget data now reflects the relay
independently of the app. 35 tests pass.
FuelPriceWidget now uses AppIntentConfiguration with a per-widget
FuelBoardWidgetConfigurationIntent (fuel + sort). Each widget placed on
the home screen carries its OWN fuel type (Unleaded/Premium/Diesel) and
ordering (Cheapest/Closest), edited by long-press -> Edit Widget —
independent of the app's selection, so multiple widgets can show
different data side by side. Widget no longer reads the app's selected
fuel. 35 tests pass.
- 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