From e0e5b36503f3df43c1c956634ae167180a35bc3c Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sun, 16 Aug 2026 12:31:35 +0100 Subject: [PATCH] Trends: price-history fetch failure with no data raises the connection banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FuelBoard/ContentView.swift | 6 ++++-- FuelBoard/FavouritesView.swift | 14 ++++++++++++-- FuelBoard/TrendsView.swift | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 1934340..08f5711 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -284,7 +284,7 @@ struct ContentView: View { updateLiveActivity() // Refresh only when the cache is stale (twice-a-day policy). // Skipped under the force-* hooks so the banner stays up. - if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") { + if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") { Task { await refresh() } } } else { @@ -526,7 +526,9 @@ struct ContentView: View { distanceUnit: distanceUnit, priceDisplayStyle: priceDisplayStyle, onToggleFavourite: toggleFavourite, - onReorder: reorderFavourites + onReorder: reorderFavourites, + onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } }, + onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } } ) } diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index b814cdc..dbc9463 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -17,6 +17,10 @@ struct FavouritesView: View { var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } /// Persists a reordered favourites array (after drag-and-drop). 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 /// only tabs shown (a fuel with no favourites gets no tab). @@ -62,7 +66,9 @@ struct FavouritesView: View { distanceUnit: DistanceUnit, priceDisplayStyle: PriceDisplayStyle, 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.selectedFuel = selectedFuel self.location = location @@ -70,6 +76,8 @@ struct FavouritesView: View { self.priceDisplayStyle = priceDisplayStyle self.onToggleFavourite = onToggleFavourite self.onReorder = onReorder + self.onHistoryUnavailable = onHistoryUnavailable + self.onHistoryRecovered = onHistoryRecovered _fuel = State(initialValue: selectedFuel) } @@ -158,7 +166,9 @@ struct FavouritesView: View { TrendsView( favourites: favourites, selectedFuel: activeFuel, - priceDisplayStyle: priceDisplayStyle + priceDisplayStyle: priceDisplayStyle, + onHistoryUnavailable: onHistoryUnavailable, + onHistoryRecovered: onHistoryRecovered ) } .onAppear { diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index 6d00adb..dfef426 100644 --- a/FuelBoard/TrendsView.swift +++ b/FuelBoard/TrendsView.swift @@ -16,6 +16,12 @@ struct TrendsView: View { let selectedFuel: FuelType 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 @State private var fuel: FuelType = .e10 @@ -80,6 +86,15 @@ struct TrendsView: View { } 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 loadFailed = false defer { isLoading = false } @@ -98,6 +113,14 @@ struct TrendsView: View { loadFailed = firstSnapshot == nil } 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 {