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
This commit is contained in:
FuelBoard Contributor
2026-08-16 12:31:35 +01:00
parent a6dbe8c46e
commit e0e5b36503
3 changed files with 39 additions and 4 deletions
+4 -2
View File
@@ -284,7 +284,7 @@ struct ContentView: View {
updateLiveActivity() updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy). // Refresh only when the cache is stale (twice-a-day policy).
// Skipped under the force-* hooks so the banner stays up. // 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() } Task { await refresh() }
} }
} else { } else {
@@ -526,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 } }
) )
} }
+12 -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,7 +166,9 @@ struct FavouritesView: View {
TrendsView( TrendsView(
favourites: favourites, favourites: favourites,
selectedFuel: activeFuel, selectedFuel: activeFuel,
priceDisplayStyle: priceDisplayStyle priceDisplayStyle: priceDisplayStyle,
onHistoryUnavailable: onHistoryUnavailable,
onHistoryRecovered: onHistoryRecovered
) )
} }
.onAppear { .onAppear {
+23
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 {