10 Commits
Author SHA1 Message Date
FuelBoard Contributor 49503bef99 backlog: relay+mirror moved to Proxmox CT 111 (fuelboard-relay), live push verified 2026-08-16 18:55:07 +01:00
FuelBoard Contributor e0e5b36503 Trends: price-history fetch failure with no data raises the connection banner
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
2026-08-16 12:31:35 +01:00
FuelBoard Contributor a6dbe8c46e Banners: mention Stations-tab refresh; add connection-problem banner
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
2026-08-16 12:14:13 +01:00
FuelBoard Contributor bfeb494c03 Offline banner: label the bundled-dump fallback with its snapshot date
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)
2026-08-16 11:52:19 +01:00
FuelBoard Contributor 430006be94 Trends: chart key shows average price per station + descriptor footer
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.
2026-08-16 10:39:49 +01:00
FuelBoard Contributor 9aea478317 Trends: explicit series so each favourite draws its own line
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).
2026-08-16 10:19:51 +01:00
FuelBoard Contributor 3416ad563e Key sheet: taller detent + opaque background
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.
2026-08-16 09:57:58 +01:00
FuelBoard Contributor 87d66eae94 Key sheet: padding on TOP + star cards
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.
2026-08-16 09:48:33 +01:00
FuelBoard Contributor 86385d53a2 Key sheet: card redesign matching the tip section
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.
2026-08-16 09:18:27 +01:00
FuelBoard Contributor 9fdcc7370e Tip section: card design per user mockup
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.
2026-08-16 08:57:40 +01:00
12 changed files with 474 additions and 81 deletions
+23 -13
View File
@@ -13,20 +13,30 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
## P1 — Soon ## P1 — Soon
- [ ] **Move relay + mirror off the Mac mini (plan for 2026-08-16)** — dedicated - [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh` Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
(CT ~110, hostname `fuelboard-relay`, 1 core/1 GB/8 GB, static IP (CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
~192.168.1.150, plain LXC — NO Docker layer). Scope: relay AND mirror both **192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
move (full Mac independence; relay binds loopback only, GitHub is the only both moved (full Mac independence; relays bind **loopback only**, GitHub is
public surface). Steps: user creates CT on pve → install Python (trixie = the only public surface). What landed: Python 3.13.5 (trixie) boots both
3.13 vs Mac 3.14 — verify relay boots) + relay source + `.env` (GOV.UK relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
token) → copy deploy key `~/.ssh/fuelboard_deploy` + `github-fuelboard-data` ~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
ssh config → systemd `fuelboard-relay.service` (Restart=always, (venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
127.0.0.1:8789/8788) + `fuelboard-mirror.timer` (10:00 + 16:00) + service → `github-fuelboard-data` ssh config + `fuelboard-data` clone at
relocate `mirror_push.py` (RELAY line → localhost on CT) + fuelboard-data `/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
clone → verify one live push → remove Mac launchd `mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
`com.apt.fuelboard-{relay,mirror}`. Open: confirm IP; logs move to `127.0.0.1:8789`); systemd `fuelboard-relay.service` +
journalctl. `fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
armed — next Mon 10:00); first live push VERIFIED from outside
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
SSH (`~/.ssh/fuelboard_ct` → `/root/scripts/watchdog_fetch.py`). Logs via
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
tight; run `pct resize 111 rootfs 8G` on pve when convenient.**
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent` - [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first (App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
+10
View File
@@ -15,4 +15,14 @@ enum BundledDumpProvider {
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil } guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
return try? FuelPriceProvider.decodeStations(from: asset.data) return try? FuelPriceProvider.decodeStations(from: asset.data)
} }
/// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601).
/// Labels the offline-data banner honestly ("Offline data from 15 Aug")
/// instead of presenting the build's snapshot as live.
static var dataUpdatedStamp: String? {
guard let asset = NSDataAsset(name: "FuelBoardDump"),
let obj = try? JSONSerialization.jsonObject(with: asset.data)
as? [String: Any] else { return nil }
return obj["data_updated"] as? String
}
} }
+125 -5
View File
@@ -32,6 +32,19 @@ struct ContentView: View {
} }
@State private var isLoading = false @State private var isLoading = false
@State private var statusMessage = "" @State private var statusMessage = ""
/// What data is on screen, driving which (if any) status banner shows
/// above the tabs:
/// - `.live`: fetched or cached data — nothing to say.
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot —
/// the banner labels it honestly with the snapshot's own date.
/// - `.connectionProblem`: fetch failed but a saved cache is showing —
/// the banner says to check connectivity (tap = retry).
enum DataSourceStatus: Equatable {
case live
case offlineDump(date: String)
case connectionProblem
}
@State private var dataStatus: DataSourceStatus = .live
@State private var showOnboarding = false @State private var showOnboarding = false
@State private var showWidgetMock = false @State private var showWidgetMock = false
@State private var selectedTab = 0 @State private var selectedTab = 0
@@ -139,7 +152,31 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
TabView(selection: $selectedTab) { VStack(spacing: 0) {
switch dataStatus {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBanner(
icon: "wifi.slash",
tint: .orange,
title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
)
case .connectionProblem:
statusBanner(
icon: "wifi.exclamationmark",
tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
)
case .live:
EmptyView()
}
TabView(selection: $selectedTab) {
stationsTab stationsTab
.tabItem { Label("Stations", systemImage: "fuelpump.fill") } .tabItem { Label("Stations", systemImage: "fuelpump.fill") }
.tag(0) .tag(0)
@@ -204,6 +241,19 @@ struct ContentView: View {
default: selectedTab = 0 default: selectedTab = 0
} }
} }
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
// failure legs for the screenshot harness. The auto-refresh below
// is skipped so the banner stays up (a live fetch would clear it).
if args.contains("-forceOfflineDump") {
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
}
if args.contains("-forceConnectionProblem") {
stations = FuelStore.loadStations().isEmpty
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
: FuelStore.loadStations()
dataStatus = .connectionProblem
}
// Onboarding runs first on a fresh install — it owns the initial // Onboarding runs first on a fresh install — it owns the initial
// permission prompts (location, notifications, and the data/local // permission prompts (location, notifications, and the data/local
// network probe on the Data page). Location tracking and the first // network probe on the Data page). Location tracking and the first
@@ -233,7 +283,10 @@ struct ContentView: View {
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
updateLiveActivity() updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy). // Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() } // Skipped under the force-* hooks so the banner stays up.
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") {
Task { await refresh() }
}
} else { } else {
showOnboarding = true showOnboarding = true
} }
@@ -367,6 +420,59 @@ struct ContentView: View {
// so reading storage here could push the OLD style. // so reading storage here could push the OLD style.
updateLiveActivity(priceDisplayStyleOverride: newValue) updateLiveActivity(priceDisplayStyleOverride: newValue)
} }
} // VStack: status banner + TabView
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
}
/// The banner title for the bundled-snapshot case: date when the stamp
/// parsed, plain "Offline data" otherwise.
private func offlineTitle(date: String) -> String {
date.isEmpty
? NSLocalizedString("Offline data", comment: "")
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
}
/// Shared status-strip chrome: a tappable card pinned above the tabs.
/// Tapping retries the live fetch from ANY screen — no pull gesture
/// needed, so the offline banner isn't trapped on the Stations tab.
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
Button {
Task { await refresh(force: true) }
} label: {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
)
.padding(.horizontal, 12)
.padding(.bottom, 6)
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.transition(.move(edge: .top).combined(with: .opacity))
} }
/// Pushes the current best-in-radius station into the Live Activity. /// Pushes the current best-in-radius station into the Live Activity.
@@ -420,7 +526,9 @@ struct ContentView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle, priceDisplayStyle: priceDisplayStyle,
onToggleFavourite: toggleFavourite, onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
) )
} }
@@ -504,14 +612,26 @@ struct ContentView: View {
FuelStore.saveFavourites(refreshedFavourites) FuelStore.saveFavourites(refreshedFavourites)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
// Live data restored — any status banner no longer applies.
dataStatus = .live
} catch { } catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data." statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty { if FuelStore.loadStations().isEmpty {
// No cached prices — last resort is the bundled REAL dump // No cached prices — last resort is the bundled REAL dump
// (stale but genuine), then the demo sample set. // (stale but genuine), then the demo sample set. The banner
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations // labels the bundled snapshot honestly with its own date.
if let dump = BundledDumpProvider.stations {
stations = dump
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
} else {
stations = SampleFuelProvider.sampleStations
dataStatus = .live
}
} else { } else {
// Saved prices are still on screen — but the fetch failed, so
// say so: a stale cache must not look like a live app.
stations = FuelStore.loadStations() stations = FuelStore.loadStations()
dataStatus = .connectionProblem
} }
} }
// Keep monitor geofences in sync with the freshest data. // Keep monitor geofences in sync with the freshest data.
+19 -2
View File
@@ -17,6 +17,10 @@ struct FavouritesView: View {
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop). /// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in } var onReorder: ([FavouriteEntry]) -> Void = { _ in }
/// Propagated from ContentView — Trends' history failure with no data
/// raises the global connection banner; recovery clears it.
var onHistoryUnavailable: (() -> Void)? = nil
var onHistoryRecovered: (() -> Void)? = nil
/// Fuel types that currently have at least one favourite — these are the /// Fuel types that currently have at least one favourite — these are the
/// only tabs shown (a fuel with no favourites gets no tab). /// only tabs shown (a fuel with no favourites gets no tab).
@@ -62,7 +66,9 @@ struct FavouritesView: View {
distanceUnit: DistanceUnit, distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle, priceDisplayStyle: PriceDisplayStyle,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) { onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
onHistoryRecovered: (() -> Void)? = nil) {
self.favourites = favourites self.favourites = favourites
self.selectedFuel = selectedFuel self.selectedFuel = selectedFuel
self.location = location self.location = location
@@ -70,6 +76,8 @@ struct FavouritesView: View {
self.priceDisplayStyle = priceDisplayStyle self.priceDisplayStyle = priceDisplayStyle
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
self.onHistoryRecovered = onHistoryRecovered
_fuel = State(initialValue: selectedFuel) _fuel = State(initialValue: selectedFuel)
} }
@@ -158,9 +166,18 @@ struct FavouritesView: View {
TrendsView( TrendsView(
favourites: favourites, favourites: favourites,
selectedFuel: activeFuel, selectedFuel: activeFuel,
priceDisplayStyle: priceDisplayStyle priceDisplayStyle: priceDisplayStyle,
onHistoryUnavailable: onHistoryUnavailable,
onHistoryRecovered: onHistoryRecovered
) )
} }
.onAppear {
// QA hook: launch with `-showTrends` to open the sheet
// without a tap (same pattern as -showKeySheet).
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
showTrends = true
}
}
} }
} }
} }
+44 -18
View File
@@ -298,25 +298,47 @@ struct SettingsView: View {
} }
Section { Section {
ForEach(TipStore.tiers) { tier in VStack(spacing: 10) {
Button { ForEach(TipStore.tiers) { tier in
Task { await tipStore.purchase(tier) } Button {
} label: { Task { await tipStore.purchase(tier) }
HStack { } label: {
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 12) {
Text(tier.name) Image(systemName: "fuelpump.fill")
Text(tier.blurb) .font(.title3)
.font(.caption) .foregroundStyle(tier.accent)
.foregroundStyle(.secondary) .frame(width: 34)
VStack(alignment: .leading, spacing: 2) {
Text(tier.name)
.font(.headline)
Text(tier.blurb)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text(tipStore.displayPrice(for: tier))
.font(.headline.weight(.bold))
.foregroundStyle(tier.accent)
.monospacedDigit()
} }
Spacer() .padding(.horizontal, 14)
Text(tipStore.displayPrice(for: tier)) .padding(.vertical, 12)
.foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading)
.monospacedDigit() .background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 12)
.fill(tier.accent.opacity(0.05))
)
)
} }
.buttonStyle(.plain)
.disabled(tipStore.purchaseInProgress)
} }
.disabled(tipStore.purchaseInProgress)
} }
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} header: { } header: {
Text("Support FuelBoard") Text("Support FuelBoard")
} footer: { } footer: {
@@ -569,15 +591,19 @@ final class TipStore: ObservableObject {
let name: String let name: String
let blurb: String let blurb: String
let fallbackPrice: String let fallbackPrice: String
let accent: Color // card/price accent (matches the fuel palette)
} }
static let tiers: [TipTier] = [ static let tiers: [TipTier] = [
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash", TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99"), blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank", TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
blurb: "A generous top-up for development.", fallbackPrice: "£2.99"), blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up", TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99"), blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
] ]
@Published private(set) var products: [String: Product] = [:] @Published private(set) var products: [String: Product] = [:]
+94 -33
View File
@@ -193,39 +193,58 @@ struct StationsView: View {
.sheet(isPresented: $showKey) { .sheet(isPresented: $showKey) {
NavigationStack { NavigationStack {
List { List {
Section("Key") { Section {
HStack(spacing: 8) { VStack(spacing: 10) {
Circle().fill(.green).frame(width: 12, height: 12) keyCard(
Text("Best value — within 1.5p of the cheapest") icon: "checkmark.circle.fill", color: .green,
.font(.caption) title: "Best value", blurb: "Within 1.5p of the cheapest",
} threshold: "≤1.5p")
HStack(spacing: 8) { keyCard(
Circle().fill(.orange).frame(width: 12, height: 12) icon: "equal.circle.fill", color: .orange,
Text("Okay — within 4p of the cheapest") title: "Okay", blurb: "Within 4p of the cheapest",
.font(.caption) threshold: "≤4p")
} keyCard(
HStack(spacing: 8) { icon: "exclamationmark.circle.fill", color: .red,
Circle().fill(.red).frame(width: 12, height: 12) title: "Pricey", blurb: "More than 4p over the cheapest",
Text("Pricey — more than 4p over the cheapest") threshold: ">4p")
.font(.caption) Rectangle()
} .fill(Color(.separator))
HStack(spacing: 8) { .frame(height: 0.5)
Text("TOP") .padding(.vertical, 2)
.font(.caption2.bold()) HStack(spacing: 12) {
.padding(.horizontal, 5) Text("TOP")
.padding(.vertical, 1) .font(.caption2.bold())
.background(Capsule().fill(.blue.opacity(0.15))) .padding(.horizontal, 7)
.foregroundStyle(.blue) .padding(.vertical, 3)
Text("Top result for the current sort") .background(Capsule().fill(.blue.opacity(0.16)))
.font(.caption) .foregroundStyle(.blue)
} Text("Top result for the current sort")
HStack(spacing: 8) { .font(.subheadline.weight(.semibold))
Image(systemName: "star.fill") Spacer(minLength: 0)
.font(.caption2) }
.foregroundStyle(.yellow) .padding(.horizontal, 14)
Text("Star a station to add it to Favourites") .padding(.vertical, 12)
.font(.caption) .keyCardFill(color: .blue)
HStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.yellow)
.frame(width: 34)
Text("Star a station to add it to Favourites")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .yellow)
Text("Colours match each station's rating on the list")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.top, 4)
} }
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} }
} }
.navigationTitle("Key") .navigationTitle("Key")
@@ -236,10 +255,52 @@ struct StationsView: View {
} }
} }
} }
.presentationDetents([.medium]) .presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
} }
} }
} }
/// One price-rating card for the Key sheet: accent icon, title + blurb,
/// and the rating threshold in the accent colour (mirrors the tip cards).
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 34)
VStack(alignment: .leading, spacing: 2) {
Text(LocalizedStringKey(title)).font(.headline)
Text(LocalizedStringKey(blurb))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
HStack(spacing: 6) {
Circle().fill(color).frame(width: 10, height: 10)
Text(threshold)
.font(.headline.weight(.bold))
.foregroundStyle(color)
.monospacedDigit()
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.keyCardFill(color: color)
}
}
/// Card background shared by the Key sheet cards — subtle accent tint over
/// the grouped background, matching the tip-section card style.
private extension View {
func keyCardFill(color: Color) -> some View {
background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
)
}
} }
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only) // MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
+70 -7
View File
@@ -16,6 +16,12 @@ struct TrendsView: View {
let selectedFuel: FuelType let selectedFuel: FuelType
let priceDisplayStyle: PriceDisplayStyle let priceDisplayStyle: PriceDisplayStyle
/// Propagated up to ContentView so a price-history fetch that fails with
/// NO data raises the global connection banner (same red banner as the
/// stations fetch). `onHistoryRecovered` fires once data loads again.
var onHistoryUnavailable: (() -> Void)? = nil
var onHistoryRecovered: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var fuel: FuelType = .e10 @State private var fuel: FuelType = .e10
@@ -80,6 +86,15 @@ struct TrendsView: View {
} }
private func load() async { private func load() async {
// QA hook: force the unreachable state for screenshots (same pattern
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
// the retry state renders immediately with no spinner flash.
if ProcessInfo.processInfo.arguments.contains("-forceHistoryFailure") {
series = []
loadFailed = true
onHistoryUnavailable?()
return
}
isLoading = true isLoading = true
loadFailed = false loadFailed = false
defer { isLoading = false } defer { isLoading = false }
@@ -98,6 +113,14 @@ struct TrendsView: View {
loadFailed = firstSnapshot == nil loadFailed = firstSnapshot == nil
} }
series = fetched series = fetched
// A failure with no data IS a connection problem — raise the global
// banner so the user isn't stuck with a silent retry state. Success
// clears it (only if the banner is the connection banner).
if loadFailed {
onHistoryUnavailable?()
} else if hasAnyData {
onHistoryRecovered?()
}
} }
private func yLabel(_ pence: Double) -> String { private func yLabel(_ pence: Double) -> String {
@@ -175,6 +198,7 @@ struct TrendsView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
chart chart
legend legend
legendFooter
} }
} }
} }
@@ -205,13 +229,21 @@ struct TrendsView: View {
} }
private var chart: some View { private var chart: some View {
Chart(displaySeries) { history in // NOTE: each LineMark MUST carry an explicit `series:` — without it
ForEach(history.points) { point in // Swift Charts merges every station's points into ONE polyline
LineMark( // (points connect across stations, so only the first station's line
x: .value("Date", point.date), // is recognisable). The outer ForEach keeps one chart with N series;
y: .value("Price", point.pence) // per-mark foregroundStyle then colours each series from the palette.
) Chart {
.foregroundStyle(seriesColor(index(of: history.stationID))) ForEach(displaySeries) { history in
ForEach(history.points) { point in
LineMark(
x: .value("Date", point.date),
y: .value("Price", point.pence),
series: .value("Station", history.name)
)
.foregroundStyle(seriesColor(index(of: history.stationID)))
}
} }
} }
.chartXAxis { .chartXAxis {
@@ -248,10 +280,41 @@ struct TrendsView: View {
Text(history.name) Text(history.name)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
if let avg = FuelHistoryStore.averagePence(history.points) {
Text(legendFigure(avg))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Spacer() Spacer()
} }
} }
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
/// The bracket figure in the chart key: absolute pence in Price mode,
/// signed pence above the day's cheapest in vs-cheapest mode — always
/// pence, matching the list rows (the y-axis follows the display toggle).
private func legendFigure(_ pence: Double) -> String {
switch mode {
case .price:
return String(format: "%.1fp", pence)
case .vsCheapest:
return pence > 0 ? String(format: "+%.1fp", pence) : String(format: "%.1fp", pence)
}
}
/// One-line descriptor under the key so the brackets are self-explanatory.
private var legendFooter: some View {
Group {
if mode == .price {
Text("Average price over the days shown")
} else {
Text("Average pence above the day's cheapest favourite")
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
} }
+19 -3
View File
@@ -23,11 +23,15 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)"; "Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown"; "All %lld stations shown" = "All %lld stations shown";
"Key" = "Key"; "Key" = "Key";
"Best value — within 1.5p of the cheapest" = "Best value — within 1.5p of the cheapest"; "Best value" = "Best value";
"Okay — within 4p of the cheapest" = "Okay — within 4p of the cheapest"; "Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Pricey — more than 4p over the cheapest" = "Pricey — more than 4p over the cheapest"; "Okay" = "Okay";
"Within 4p of the cheapest" = "Within 4p of the cheapest";
"Pricey" = "Pricey";
"More than 4p over the cheapest" = "More than 4p over the cheapest";
"Top result for the current sort" = "Top result for the current sort"; "Top result for the current sort" = "Top result for the current sort";
"Star a station to add it to Favourites" = "Star a station to add it to Favourites"; "Star a station to add it to Favourites" = "Star a station to add it to Favourites";
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
/* Favourites tab */ /* Favourites tab */
"No favourites yet" = "No favourites yet"; "No favourites yet" = "No favourites yet";
@@ -152,3 +156,15 @@
"No price history yet" = "No price history yet"; "No price history yet" = "No price history yet";
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend."; "First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days."; "Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
"Average price over the days shown" = "Average price over the days shown";
"Average pence above the day's cheapest favourite" = "Average pence above the day's cheapest favourite";
/* Offline data banner */
"Offline data from %@" = "Offline data from %@";
"Offline data" = "Offline data";
"Pull to refresh on the Stations tab" = "Pull to refresh on the Stations tab";
"Offline data from %@. Pull to refresh on the Stations tab" = "Offline data from %@. Pull to refresh on the Stations tab";
"Offline data. Pull to refresh on the Stations tab" = "Offline data. Pull to refresh on the Stations tab";
"Check your internet connection" = "Check your internet connection";
"Tap to try again" = "Tap to try again";
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";
@@ -587,3 +587,22 @@ final class PriceDisplayTests: XCTestCase {
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009") XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
} }
} }
// MARK: - Offline data banner label
final class OfflineDataLabelTests: XCTestCase {
func testOfflineDataLabelFormatsStampWithFractionalSeconds() {
// The bundled dump's real envelope stamp.
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33.000Z"), "15 Aug")
}
func testOfflineDataLabelToleratesPlainISODate() {
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33Z"), "15 Aug")
}
func testOfflineDataLabelNilWhenMissingOrUnparseable() {
XCTAssertNil(FuelStore.offlineDataLabel(from: nil))
XCTAssertNil(FuelStore.offlineDataLabel(from: ""))
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
}
}
@@ -164,6 +164,29 @@ final class FuelHistoryTests: XCTestCase {
XCTAssertEqual(pruned.count, days.count) XCTAssertEqual(pruned.count, days.count)
} }
// MARK: Chart-key average
func testAveragePenceEmptyIsNil() {
XCTAssertNil(FuelHistoryStore.averagePence([]))
}
func testAveragePenceSinglePoint() {
let d = FuelHistoryStore.date(fromDay: "2026-08-15")!
XCTAssertEqual(FuelHistoryStore.averagePence([PricePoint(date: d, pence: 156.7)]), 156.7)
}
func testAveragePenceMultiplePoints() {
let d1 = FuelHistoryStore.date(fromDay: "2026-08-15")!
let d2 = FuelHistoryStore.date(fromDay: "2026-08-16")!
// (153.9 + 156.7) / 2 = 155.3 — exact in binary? 153.9+156.7=310.6, /2=155.3
let avg = FuelHistoryStore.averagePence([
PricePoint(date: d1, pence: 153.9),
PricePoint(date: d2, pence: 156.7),
])
XCTAssertNotNil(avg)
XCTAssertEqual(avg!, 155.3, accuracy: 0.0001)
}
// MARK: Mirror URLs // MARK: Mirror URLs
func testHistoryFileURLKeepsBaseLastSegment() { func testHistoryFileURLKeepsBaseLastSegment() {
+7
View File
@@ -148,6 +148,13 @@ enum FuelHistoryStore {
} }
} }
/// Average pence over a series' points; nil when there are no points
/// (a station with no data shows no bracket figure in the chart key).
static func averagePence(_ points: [PricePoint]) -> Double? {
guard !points.isEmpty else { return nil }
return points.reduce(0) { $0 + $1.pence } / Double(points.count)
}
/// Rebase every station's series so each day's CHEAPEST favourite sits at /// Rebase every station's series so each day's CHEAPEST favourite sits at
/// 0 and the others show signed pence above it (mirrors the list's /// 0 and the others show signed pence above it (mirrors the list's
/// baseline delta pattern). Days where a station has no point are gaps. /// baseline delta pattern). Days where a station has no point are gaps.
+21
View File
@@ -782,6 +782,27 @@ struct FuelStore {
loadString(service: dataUpdatedKey) loadString(service: dataUpdatedKey)
} }
/// A short label for the offline-data banner: "15 Aug" from a GOV.UK
/// `data_updated` ISO 8601 stamp (with or without fractional seconds).
/// Nil when the stamp is missing or unparseable — callers then hide the
/// banner rather than label data with a wrong date.
static func offlineDataLabel(from stamp: String?) -> String? {
guard let stamp, !stamp.isEmpty else { return nil }
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = withFraction.date(from: stamp)
if date == nil {
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
date = plain.date(from: stamp)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.dateFormat = "d MMM"
formatter.locale = Locale(identifier: "en_GB")
return formatter.string(from: date)
}
/// True when the cached data is fresh enough that a scheduled auto-refresh /// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy). /// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool { static var isCacheFresh: Bool {