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)
The no-data states previously floated or centred vertically (Spacer
sandwich), so the sheet rendered them halfway down the page while the
chart-with-data anchors to the top. All chartArea states now share one
top-aligned container that fills the space below the pickers — the
empty state sits exactly where the chart will render, and the layout no
longer jumps between states. 83 tests, Release build green.
- AppIcon / AppIcon-dark / AppIcon-tinted swapped for the drip design
(1024px, dark + tinted variants), onboarding artwork matched
- Alerts tab: live explainer under the Radius picker — situational
guidance per option (Follow search = set-and-forget mirror of the
Stations distance; 1 = town driving; 2 = city/suburb commute;
3 = mixed; 5 = A-roads/motorway exits; 8 = long-distance) — plus the
trade-off line in the section footer
- 83 tests, Release build green
Settings → Debug → 'Debug fence (delivery test)': registers a 100 m
CLCircularRegion at the current fix that exists solely to prove iOS
delivers region events on this install. Entering it ticks 'Region events
this session', logs 'debug fence entered' in the Alert trace, and fires a
plain notification — zero alert gates. Cleared via button; survives the
station re-registration loop (update() now skips debug-fence regions);
cleared when alerts are disabled.
The Test alert / Plain test buttons now also write to the Alert trace
('test alert scheduled — …'), so the trace reflects every probe, not just
live geofence entries. 83 tests, Release build green.
ProximityMonitor now records each stage the live chain reaches (Settings →
Debug → Alert trace, in-memory, newest-first, capped at 8):
- didEnterRegion arrival (regionEventCount + entry line) — distinguishes
'geofence never fires' from 'gate blocked the alert'
- gate misses: station not in loaded list, hourly dedup (with minutes ago),
no fuel sellers within radius after fresh fetch, entered-not-cheapest
(with the cheaper station + price)
- the previously-silent fresh-fetch catch now logs the error
- 'alert scheduled' once UNUserNotificationCenter.add is called — if that
line appears but no banner shows, the failure is delivery/permission,
not alert logic
SettingsView shows the trace (symbol per kind) + region-event count; the
existing Test-alert button already probes notification permission and
reports denial inline. 83 tests, Release build green.
- Shared/FuelHistory.swift: GitHub mirror read path (latest.json pointer +
history/YYYY-MM-DD.json day files); day math (UTC noon keys), slim day decode
with the shared price band, series + deltaSeries (vs-cheapest rebase),
favourites-only app-group cache (90-day prune), parallel per-day fetches,
404/network = graph gap never error.
- FuelPriceProvider: shared priceBand + mapGrades (single source for live +
history decoding).
- FuelBoard/TrendsView.swift: line chart in the Favourites tab via toolbar
button + sheet; fuel capsule (fuels with favourites only), 7/30/90 range,
Price/vs-cheapest toggle, per-station legend, price-display style on y-axis,
empty/loading/retry states with honest copy; no widget in v1.
- Localizable.strings: Trends keys.
- 83 tests (13 history + URL regression): day math, band guard, series gaps,
delta rebasing, prune, and the appendingPathComponent regression (relative
URL resolution dropped /main — all fetches 404'd).
- Sim-verified: favourites rows + TOP/deltas; sheet controls + building-up
state with live first-snapshot date (archive has 1 day; lines render once
>=2 snapshots).
- Backlog: P1 Trends DONE (unmerged); P0 REMAINING = live provider chain +
telemetry beacon.
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 welcome page's green-gradient circle + SF symbol is replaced by the
app icon itself (AppIconArtwork imageset mirroring AppIcon.png), clipped
to the standard iOS rounded-rect corner radius (21pt at 96pt — ~22%),
so onboarding matches the Home Screen icon.
New Icon Composer project artwork (gas-station vector, layered + background)
rendered via ictool at 1024pt: Default / Dark / TintedLight renditions
replacing all three asset-catalog slots.
The export zip contained both a pre-rendered appiconset AND the
Fuelboard.icon composer project; the pre-baked PNGs were a different
(flatter) render. Re-rendered the .icon bundle with ictool (Xcode 26
Icon Composer executable) — Default/Dark/TintedLight renditions at
1024pt — and replaced the three asset-catalog slots.
Replaces the single 1024px icon with the three-variant set (universal,
dark-luminosity, tinted) generated for FuelBoard; Contents.json updated to
the iOS-only slots.
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.
Final structural difference from the working medium widget. The
dependent-picker machinery resolves the favourite parameter through the
hidden favouriteFuel parameter during fresh-widget default-config
resolution (default Sort = Cheapest hides favouriteFuel), and that
resolution fails on-device — the whole default config fails and the
small widget strands on the skeleton. Favourite picker now lists ALL
favourites, each row labelled with its fuel (the fuel-scoped filter was
the dependency's only purpose). Configured Favourites-mode widgets are
unaffected. Per-intent beacons remain in place to verify the outcome.
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).
Granted label now reads just 'Connected' with the green tick and uses
the same .subheadline confirmation styling as the Location and
Notifications pages.
Replaying onboarding showed a visible probe on the 4th page because
iOS exposes no status API for the Local Network permission — the
connection attempt IS the only detector. Location and Notifications
query their status at init, so their ticks are already known when the
page appears; Local Network could only be known by probing.
Now, when onboarding is re-opened (already completed), a pre-flight
probe runs silently at open: granted connects in ms, denied reports
.waiting/.localNetworkDenied immediately, and a plain .waiting
(permission undetermined after a reinstall) cancels so page 4's
Continue still fires the prompt in context. By the time the data page
appears its tick (or denied text) is already on screen, matching the
other permission pages. First run is unchanged.
The probe result landed while the Local Network prompt was still
dismissing, so the granted tick was skipped and the page jumped
straight to the final slide. Hold 'Connected — prices will load'
on screen for a beat before auto-advancing, matching the visible
tick flow of the Location and Notifications pages.