- 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.
193 lines
12 KiB
Markdown
193 lines
12 KiB
Markdown
# FuelBoard Backlog
|
||
|
||
FuelBoard — England-wide petrol-station price widget + app.
|
||
Relay mirrors the UK gov data (hourly CSV); app fetches the full-UK dump
|
||
(~8,038 stations, all prices) once per 12 h and filters/sorts on-device.
|
||
|
||
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
|
||
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||
|
||
---
|
||
|
||
## P0 — Next build
|
||
|
||
- [ ] **GitHub price-history mirror (warm the data for Trends)** — the relay does
|
||
the GOV.UK heavy lifting (OAuth token stays in relay `.env`) and pushes a daily
|
||
snapshot to a PUBLIC repo; the app reads from GitHub, so this doubles as the
|
||
production data source (App Review can't reach the LAN relay) AND the history
|
||
archive the Trends graph (P1) reads. Shape: relay writes `latest.json` (tiny
|
||
pointer: date, station_count, data_updated, available range) +
|
||
`history/YYYY-MM-DD.json` (FULL dump — 2.58 MB/day, decision Option 2) and
|
||
pushes once/day; the app fetches
|
||
`raw.githubusercontent.com/aptonline/fuelboard-data/main/latest.json`, then only
|
||
the missing day files for starred (station, fuel) pairs (deterministic URLs —
|
||
404 = no data that day, leave a graph gap, never error). LAN relay stays the
|
||
fallback source. Telemetry implication: raw GitHub fetches are invisible to repo
|
||
analytics (page views/clones only) — keep the widget-diag beacon firing
|
||
regardless of data source (build/stale-build checks survive) and add an
|
||
opportunistic fire-and-forget X-Client app beacon on fetch (LAN = attribution +
|
||
cadence; off-LAN skip is itself a reachability datum). `/stats` keeps the
|
||
intrusion canary and gains a new signal: app-hit spikes = GitHub path failing →
|
||
fallback active. App Review: public repo required (reviewer fetches the data
|
||
themselves); HTTPS/ATS fine; plain JSON is not "remote code" (2.5.2-safe); bundle
|
||
a small fallback dump so the app never demos empty. Retention: 180 days (~470 MB,
|
||
pruning enforced by the push script — full dump, NOT the earlier ~200 KB
|
||
price-only estimate). *PUSH SIDE DONE 2026-08-15 — repo live
|
||
(https://github.com/aptonline/fuelboard-data); auth = SSH deploy key
|
||
~/.ssh/fuelboard_deploy via alias github-fuelboard-data (no token anywhere);
|
||
clone ~/workspace/fuelboard-data; launchd com.apt.fuelboard-mirror daily 10:00
|
||
(script ~/.hermes/scripts/mirror_push.py, log
|
||
~/Library/Logs/fuelboard-mirror.log); manual trigger `launchctl start
|
||
com.apt.fuelboard-mirror`; first snapshot 2026-08-15 landed + verified raw 200.
|
||
REMAINING: live provider chain (GitHub → relay → bundled dump for CURRENT
|
||
prices) + the telemetry beacon — NOTE: the HISTORY read path shipped with P1
|
||
(FuelHistoryStore: latest.json + day files for starred stations, favourites-only
|
||
app-group cache, 404 = gap never error, 90-day prune).*
|
||
|
||
## P1 — Soon
|
||
|
||
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
||
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
|
||
with freshness label ("as of 6am"), last-known-location fallback. Constraint:
|
||
fresh-data path needs a reachable source off-LAN — cached answers work anywhere
|
||
the app has previously fetched (see P3 data mirror). Interim: the relay on
|
||
Tailscale (100.120.217.98:8789) is reachable off-LAN for testing; relay will
|
||
move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped
|
||
lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
|
||
per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus
|
||
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
|
||
- [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by
|
||
distance instead of price. Free second phrase in the same `AppShortcutsProvider`.
|
||
- [x] **Favourites Trends graph** — price history chart for starred stations.
|
||
Swift Charts (iOS 26 target) line chart in the Favourites tab; one series per
|
||
favourite, fuel-scoped (station, fuel) pairs map 1:1 to the archive. Default =
|
||
absolute price lines; toggle "vs cheapest favourite" re-baselines to 0 as signed
|
||
pence (mirrors the Cheapest/Closest delta pattern users already know). Reuse the
|
||
capsule fuel picker; range control 7/30/90 days; respects the price-display
|
||
toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from
|
||
each refresh — check back in a few days"). No widget in v1 (one-kind rule).
|
||
Depends on the P0 archive. *DONE 2026-08-15 on feature/trends-history (unmerged):
|
||
`Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared
|
||
band, series + deltaSeries, app-group favourites-only cache, parallel per-day
|
||
fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs
|
||
cheapest, per-station legend, empty/loading/retry states) + toolbar entry in
|
||
FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent
|
||
— URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified:
|
||
favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first-
|
||
snapshot date. Pending: merge to main after device sideload test; lines render
|
||
once ≥2 snapshots (archive warming daily).*
|
||
|
||
## P2 — Later
|
||
|
||
- [ ] **Shortcuts: station price lookup** — `StationEntity` + `EntityQuery` so
|
||
Shortcuts/Siri pick a station by name; `ReturnsValue<Double>` enables user
|
||
automations ("get cheapest diesel → notify").
|
||
- [x] **Siri: favourite station price** — "How much is my favourite unleaded?"
|
||
`FavouriteFuelPriceIntent`: TOP favourite per fuel (first in the manual
|
||
Favourites order — `SiriCheapestLookup.topFavourite`, refresh-vs-dump with
|
||
offline cached-snapshot fallback); no location required (distance shown only
|
||
when the saved fix exists). `DirectionsToFavouriteFuelIntent` (Maps
|
||
hand-off, station announced first) stays in code but carries no voice
|
||
phrases — every favourite price card has the interactive Directions button
|
||
(`6d7254e`), and the voice slots went to Directions-to-Cheapest (`8a4c472`).
|
||
*DONE — favourites already carried station snapshots, so no `StationEntity`
|
||
dependency.*
|
||
- [ ] **Siri: on-screen awareness (View Annotations + StationEntity)** — iOS
|
||
26/27 Siri prefers the visible screen over the App Shortcut when the app is
|
||
frontmost; without view annotations it answers from the rendered screen
|
||
(unstructured "screenshot" parse) — visible tab/sort/radius, not the full
|
||
dataset or the intent's logic. Fix: `StationEntity` (shared with the station
|
||
lookup P2 above) + `.appEntityIdentifier` on station rows (collection
|
||
annotation on the List) + `NSUserActivity` on the station detail/map +
|
||
App Schema conformance (optional `IndexedEntity` for Spotlight semantic
|
||
search). Verify annotations with AppIntentsTesting (P3). Off-screen/locked
|
||
requests already route to `CheapestFuelIntent` correctly; this closes the
|
||
frontmost gap. WWDC26: "Build intelligent Siri experiences with App Schemas"
|
||
+ "Explore advanced App Intents features".
|
||
- [ ] **Lock screen widgets** — new `FuelPriceLockScreen` kind (fuel-only config
|
||
intent, reuses `FuelPriceTimelineProvider`): `accessoryRectangular` (cheapest
|
||
station + price + distance), `accessoryCircular` (price ring), `accessoryInline`
|
||
("⛽ 139.9p"). Provider + `containerBackground` already exist; must branch the
|
||
view for accessory families (current `else` renders `stationList`). Bonus:
|
||
StandBy mode. ~1 session.
|
||
|
||
- [ ] **CarPlay** — widgets render but can't launch Maps; consider a proper CarPlay
|
||
scene (CPListTemplate) for navigation deep-links.
|
||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
||
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
|
||
(currently `refreshable` fires but no visible progress in the row list).
|
||
- [ ] **Offline first-run** — first launch without network currently shows empty
|
||
states; bundle a sample dump so the app demos offline.
|
||
- [ ] **Localization** — UK English only today; audit string catalog for l10n.
|
||
- [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot
|
||
contrast under dark appearance on-device.
|
||
- [ ] **Trends: national average overlay + per-day min/max** — the full dump is
|
||
already held, so the UK/regional average line is computable locally; archive can
|
||
store per-day min/max so the chart shows intraday spikes, not just the daily point.
|
||
|
||
## P3 — Ideas / Research
|
||
|
||
- [ ] **Siri: create fuel alert** — voice-create an alert for a station. Deferred:
|
||
alert semantics (cheapest-within-radius + hourly dedup) are hard to express in
|
||
a dialog; needs the alert engine surfaced as an intent.
|
||
- [ ] **App Intents testing** — AppIntentsTesting (iOS 27) for intent coverage
|
||
instead of XCUITest when the Siri features land.
|
||
|
||
- [ ] **Fuel-price prediction** — "price tends up/down" arrow derived from the
|
||
price-history archive (P0). Cheap once the archive + Trends graph exist; keep a
|
||
separate phase. Backlog ancestry: this was the original "relay could keep a
|
||
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
|
||
visible half.
|
||
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
|
||
trend, after the Trends UI lands (one-kind rule preserved).
|
||
- [ ] **Price alerts for a specific station** — notify when a starred station's
|
||
price drops below a user threshold.
|
||
- [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card.
|
||
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared
|
||
app-group store; watch target would need to be added to the project).
|
||
- [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install.
|
||
- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row
|
||
label is cached by the system at sheet-render time and only re-resolves on
|
||
picker interaction, so "mirror the app unit" goes stale. Fix: label options
|
||
with both units ("5 miles (8 km)") so nothing can go stale; widget rows keep
|
||
mirroring the live unit.
|
||
- [ ] **Localizable.strings: normalize keys to match copy** — today 8 onboarding
|
||
keys diverge from their values (key = old source literal, value = tightened
|
||
copy, e.g. "…updated twice a day" key vs "…updated daily" value). Option to
|
||
rename keys so key == value everywhere. Requires sweeping every Swift
|
||
occurrence of the old literal (Text/NSLocalizedString) AND updating key +
|
||
value together in one commit + rebuild — renaming the file entry alone
|
||
silently reverts copy to the Swift literal at next build. Not recommended:
|
||
keys are invisible anchors; value edits already change copy without code
|
||
changes, and normalization reintroduces divergence on the next value edit.
|
||
|
||
## Done (recent)
|
||
|
||
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
||
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
||
live in relay `.env` only, never in the IPA
|
||
- [x] Widget configuration — per-widget fuel/sort/distance via
|
||
`WidgetConfigurationIntent` (`AppIntentConfiguration`), distance options as
|
||
`WidgetDistance` AppEntity (`26e724f`); small widget gained Favourites
|
||
(`d0bd4e7`) + `.systemMedium` (`b94f769`)
|
||
- [x] Unit test target for the app — FuelBoardShared XCTest target, 37 tests
|
||
(title sanitizer, decode price guard, RAG thresholds, cheapest/closest baseline,
|
||
miles↔km)
|
||
- [x] Backlog hygiene — implemented items marked DONE, obsolete pruned
|
||
- [x] Sync retry with exponential backoff — relay-api retries failed syncs
|
||
15s→15min cap; `/health` exposes retry state (`6a58275`)
|
||
- [x] England-wide full-UK dump, on-device filtering (relay `142061b`, app `cf9765b`)
|
||
- [x] Cache-first, 12 h gate, pull-to-refresh only
|
||
- [x] Distance filter 5/10/15 miles, local-only changes (no re-fetch)
|
||
- [x] Closest mode = whole country, distance picker disabled
|
||
- [x] Delta/TOP baseline = cheapest-within-radius in both modes
|
||
- [x] Three-layer price sanitisation (relay band 50–500p / app decode guard / title-case)
|
||
- [x] Title-case station names (chunk-aware sanitizer)
|
||
- [x] Native large-title navigation (Mail-style collapse) — `313fe84`
|
||
- [x] E10/E5 bracket labels in picker + descriptions — `05f3ef0`
|
||
- [x] Gitea remotes created + pushed (`apt/fuelboard`, `apt/fuelboard-relay`)
|
||
- [x] Brand logos: proper 2x/3x assets, 75% smaller (315 KB → 79 KB)
|
||
- [x] Security audit (see SECURITY.md)
|
||
- [x] App Store approval gotcha review (see APPSTORE.md)
|