Files
fuelboard/FuelBoard/TrendsView.swift
T
FuelBoard Contributor 69c3c17606 trends: animate the price-history lines (subtle, Reduce-Motion aware)
- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing
  lines + y-axis to the new data via withAnimation/.animation(value: mode)
  instead of snapping.
- One-time left->right trace-in on first data appearance by revealing an
  increasing prefix of each series' points (area + line stay in sync); later
  switches morph rather than re-trace.
- Headline average rolls its digits with .contentTransition(.numericText).
- All animation honours .accessibilityReduceMotion (jump-cut to final state).
Kept two-pass area/line ordering, explicit series:, bounded fill bands, and
tight chartYScale unchanged.
2026-08-20 17:54:55 +01:00

561 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Charts
/// Trends — the Favourites price-history chart.
///
/// Plots one line per favourited station for the active fuel across the
/// selected range (7/30/90 days), fed by the GitHub price mirror
/// (`FuelHistoryStore`). Default shows absolute prices; "vs cheapest" rebases
/// each day to the cheapest favourite (0 baseline, signed pence above it) —
/// the same delta pattern the list rows already use. Missing days are gaps,
/// never fabricated. No widget in v1 (one-kind rule).
struct TrendsView: View {
/// All favourites (fuel-scoped entries) — the sheet derives the active
/// fuel's stations and which fuels have favourites.
let favourites: [FavouriteEntry]
/// The fuel the Favourites tab is showing when the Trends button is
/// tapped — the sheet must open on the same fuel, not always .e10.
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
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30
@State private var mode: TrendsMode = .price
@State private var series: [StationHistory] = []
@State private var isLoading = false
@State private var loadFailed = false
@State private var firstSnapshot: String?
/// Lines trace-in left→right on first appearance by revealing an
/// increasing prefix of each series' points. Stays at the full count after
/// the first reveal so range/mode switches morph instead of re-tracing.
@State private var revealCount: Int = 0
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
/// opens where the user was — same pattern as FavouritesView.
init(favourites: [FavouriteEntry],
selectedFuel: FuelType,
priceDisplayStyle: PriceDisplayStyle,
onHistoryUnavailable: (() -> Void)? = nil,
onHistoryRecovered: (() -> Void)? = nil) {
self.favourites = favourites
self.selectedFuel = selectedFuel
self.priceDisplayStyle = priceDisplayStyle
self.onHistoryUnavailable = onHistoryUnavailable
self.onHistoryRecovered = onHistoryRecovered
_fuel = State(initialValue: selectedFuel)
}
enum TrendsMode: String, CaseIterable, Identifiable {
case price
case vsCheapest
var id: String { rawValue }
}
/// Fuels that currently have at least one favourite — only these tabs show.
private var availableFuels: [FuelType] {
FuelType.allCases.filter { fuel in
favourites.contains { $0.fuel == fuel }
}
}
/// The active fuel, with the same fallback as the Favourites tab.
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
/// Stations favourited for the active fuel, in the user's stored order —
/// the chart keeps this order so line colours are stable.
private var orderedStations: [(id: String, name: String)] {
favourites.filter { $0.fuel == activeFuel }.map { ($0.station.id, $0.station.name) }
}
private var displaySeries: [StationHistory] {
mode == .price ? series : FuelHistoryStore.deltaSeries(series)
}
/// A line needs at least two points to draw; anything less is the
/// "building up" state, not a broken chart.
private var hasEnoughData: Bool {
series.contains { $0.points.count >= 2 }
}
private var hasAnyData: Bool {
series.contains { !$0.points.isEmpty }
}
private func seriesColor(_ index: Int) -> Color {
let palette: [Color] = [.blue, .orange, .purple, .pink, .teal, .indigo, .brown, .green]
return palette[index % palette.count]
}
/// X-axis tick density — a tick per day for short ranges, monthly for the
/// 90-day view so labels never collide.
private var xStride: Int {
switch rangeDays {
case ...14: return 1
case 15...60: return 7
default: return 30
}
}
/// Y-domain hugging the displayed data (RevenueCat-style tight scale).
/// Without it Swift Charts pads a sparse series (e.g. 3 days of ~150.7p)
/// out to a 0→200 axis, wasting the plot area. Pad by ~4% of the range
/// (min 0.5p) so the line doesn't kiss the top edge.
private var yDomain: ClosedRange<Double> {
let values = displaySeries.flatMap { $0.points.map(\.pence) }
guard let lo = values.min(), let hi = values.max() else {
return 0...1
}
let pad = max((hi - lo) * 0.04, 0.5)
return (lo - pad)...(hi + pad)
}
/// Depth of each series' gradient fill band, in pence. Band instead of
/// fill-to-axis: with 2+ favourites, full-height unstacked fills overlap
/// and wash out the lower lines (observed on device 2026-08-17). A band
/// of ~18% of the y-range under each line keeps every area visible and
/// gives the RevenueCat "fade under the line" look.
private var fillBand: Double {
let values = displaySeries.flatMap { $0.points.map(\.pence) }
guard let lo = values.min(), let hi = values.max(), hi > lo else {
return 1.0
}
return max((hi - lo) * 0.18, 0.8)
}
private func load() async {
#if DEBUG
// 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
}
#endif
isLoading = true
loadFailed = false
defer { isLoading = false }
// The pointer is a non-fatal hint for the empty state; history fetch
// failures surface as the retry state.
firstSnapshot = await FuelHistoryStore.fetchLatest()?.availableFrom
let fetched = await FuelHistoryStore.fetchHistory(
stations: orderedStations,
fuel: activeFuel,
days: rangeDays
)
if fetched.allSatisfy({ $0.points.isEmpty }), !orderedStations.isEmpty {
// All days missing — either the mirror is unreachable (retry) or
// genuinely empty (the building-up state). Distinguish by a quick
// pointer probe already done above.
loadFailed = firstSnapshot == nil
}
// Morph vs trace-in: a reload (range/fuel switch) glides the existing
// lines to the new data; the first real draw traces each line in
// left→right. Reduce Motion jumps straight to the final state.
let hadData = hasAnyData
if hadData {
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.35)) {
series = fetched
}
} else {
series = fetched
let maxPoints = fetched.reduce(0) { max($0, $1.points.count) }
if maxPoints > 0 {
if reduceMotion {
revealCount = maxPoints
} else {
revealCount = 0
withAnimation(.easeOut(duration: 0.5)) {
revealCount = maxPoints
}
}
}
}
// 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 {
switch mode {
case .price:
return FuelStore.priceText(pence, style: priceDisplayStyle)
case .vsCheapest:
return String(format: "%.1fp", pence)
}
}
// MARK: - RevenueCat-style headline metric
/// Overall average across displayed series (mean of per-station means) —
/// the big figure in the card header, Price and vs-cheapest mode both.
private var headlineAverage: Double? {
let avgs = displaySeries.compactMap { FuelHistoryStore.averagePence($0.points) }
guard !avgs.isEmpty else { return nil }
return avgs.reduce(0, +) / Double(avgs.count)
}
/// Change over the window: mean of each series' (last first) point.
/// Positive = prices/gaps rose; negative = fell. Hidden while loading or
/// when any displayed series lacks two points.
private var headlineDelta: Double? {
let deltas = displaySeries.compactMap { history -> Double? in
guard let first = history.points.first?.pence,
let last = history.points.last?.pence else { return nil }
return last - first
}
guard deltas.count == displaySeries.count else { return nil }
return deltas.reduce(0, +) / Double(deltas.count)
}
/// Good = prices fell (saving money) or the gap to cheapest narrowed.
private var deltaIsGood: Bool {
(headlineDelta ?? 0) <= 0
}
private var periodLabel: String {
"Last \(rangeDays) days"
}
var body: some View {
NavigationStack {
VStack(spacing: 14) {
FuelTypeSegmentedPicker(selection: $fuel, fuels: availableFuels)
HStack(spacing: 10) {
Picker("Range", selection: $rangeDays) {
ForEach(FuelHistoryStore.rangeOptions, id: \.self) { days in
Text("\(days) days").tag(days)
}
}
.pickerStyle(.segmented)
Picker("Mode", selection: $mode) {
Text("Price").tag(TrendsMode.price)
Text("vs cheapest").tag(TrendsMode.vsCheapest)
}
.pickerStyle(.segmented)
}
chartArea
}
.padding()
.navigationTitle("Trends")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.task(id: "\(activeFuel.rawValue)-\(rangeDays)") {
await load()
}
}
}
@ViewBuilder
private var chartArea: some View {
// Every state anchors to the TOP of the chart slot — the same spot the
// chart occupies when data exists. The empty/loading/error states must
// not float or centre in the sheet, or the layout jumps between states.
Group {
if isLoading {
VStack(spacing: 12) {
ProgressView("Fetching price history…")
}
.padding(.top, 24)
} else if loadFailed {
VStack(spacing: 10) {
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("Couldn't load price history")
.font(.headline)
Button("Retry") { Task { await load() } }
.buttonStyle(.bordered)
}
.padding(.top, 24)
} else if !hasAnyData {
emptyState
} else if !hasEnoughData {
emptyState // single point — nothing to draw yet
} else {
revenueCatCard
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("No price history yet")
.font(.headline)
if let firstSnapshot {
Text("First snapshot \(firstSnapshot) — a few days are needed to draw a trend.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Text("Prices are recorded each day FuelBoard's relay runs — check back in a few days.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
/// RevenueCat-style card: rounded surface with a headline metric (period
/// average + change), the multi-series area chart, then the key. Mirrors
/// the mockup approved after the dashboard reference screenshot.
private var revenueCatCard: some View {
VStack(alignment: .leading, spacing: 10) {
metricHeader
metricFooter
chart
legend
legendFooter
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
)
}
/// Headline row: big average figure, signed change, right-aligned period.
/// Colours stay on the app palette — green when the move is good
/// (prices fell / gap narrowed), the accent tint when it rose.
@ViewBuilder
private var metricHeader: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
if let headAvg = headlineAverage {
Text(yLabel(headAvg))
.font(.system(size: 30, weight: .bold, design: .default))
.monospacedDigit()
.foregroundStyle(.primary)
// Roll the digits to the new figure on range/mode change
// (fires inside the animated transaction above).
.contentTransition(.numericText(value: headAvg))
if let delta = headlineDelta, delta != 0 {
Label(
"\(deltaIsGood ? "" : "+")\(abs(delta), specifier: "%.1f")p",
systemImage: deltaIsGood ? "arrow.down.right" : "arrow.up.right"
)
.font(.system(size: 13, weight: .semibold))
.labelStyle(.titleAndIcon)
.foregroundStyle(deltaIsGood ? Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) : Color.accentColor)
}
}
Spacer()
Text(periodLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
/// One-line descriptor under the headline so the big figure (period
/// average across favourites) and the arrowed delta (change over the
/// window) are self-explanatory.
private var metricFooter: some View {
Text(chartFooterText)
.font(.caption2)
.foregroundStyle(.secondary)
}
private var chartFooterText: String {
switch mode {
case .price:
return "Average across favourites · change since the first day"
case .vsCheapest:
return "Average gap above the day's cheapest · change since the first day"
}
}
private var chart: some View {
// NOTE: each LineMark MUST carry an explicit `series:` — without it
// Swift Charts merges every station's points into ONE polyline
// (points connect across stations, so only the first station's line
// is recognisable). The outer ForEach keeps one chart with N series;
// per-mark foregroundStyle then colours each series from the palette.
// The AreaMark under each LineMark adds the gradient fill.
//
// stacking: .unstacked is essential — the standard stacking mode
// piles each series' area ON TOP of the previous series' area, so
// with 2+ favourites the fills land in the wrong place (seen on
// device 2026-08-17: fills shifted/overlapping vs the lines).
// .unstacked draws each area from its own line down to the axis,
// the RevenueCat look.
// Two passes: ALL AreaMarks first, then ALL LineMarks. Same-series
// marks composite in insertion order — if fills and lines interleave,
// a later series' fill paints OVER an earlier series' line (the
// bottom favourite's line vanished under the next fill; seen on
// device 2026-08-17). Drawing every fill before every line keeps all
// lines on top of all fills, RevenueCat style. (Mark builders are
// extracted into small helpers — the inline expression grew past the
// type-checker's budget.)
Chart {
ForEach(displaySeries) { history in
ForEach(history.points.prefix(revealCount)) { point in
areaMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
ForEach(displaySeries) { history in
ForEach(history.points.prefix(revealCount)) { point in
lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: xStride)) { _ in
AxisGridLine()
AxisTick()
AxisValueLabel(format: .dateTime.month().day())
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYScale(domain: yDomain)
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let pence = value.as(Double.self) {
Text(yLabel(pence))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.frame(height: 190)
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
// lines + axis gliding to the rebased series.
.animation(.easeInOut(duration: 0.35), value: mode)
}
/// One gradient-filled area band under a single point of a series.
/// yStart/yEnd bound the fill to `fillBand` pence under the line
/// instead of to the axis: full-height fills overlap and bury lower
/// lines when several favourites overlap (observed on device
/// 2026-08-17).
private func areaMark(_ point: PricePoint, series: String,
color: Color) -> some ChartContent {
AreaMark(
x: .value("Date", point.date),
yStart: .value("Price start", point.pence),
yEnd: .value("Price end", point.pence - fillBand),
series: .value("Station", series)
)
.foregroundStyle(
LinearGradient(
colors: [color.opacity(0.30), color.opacity(0.02)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.monotone)
}
/// One line segment point for a series, drawn above every fill.
private func lineMark(_ point: PricePoint, series: String,
color: Color) -> some ChartContent {
LineMark(
x: .value("Date", point.date),
y: .value("Price", point.pence),
series: .value("Station", series)
)
.foregroundStyle(color)
.lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
.interpolationMethod(.monotone)
}
private func index(of stationID: String) -> Int {
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
}
/// Key rows in the same vertical order as the chart: most expensive at the
/// top working down to cheapest at the bottom (the chart's y-axis puts
/// the cheapest line lowest). Sorts by average price — the bracketed
/// figure — descending, so the first key row matches the top line.
private var legendSeries: [StationHistory] {
displaySeries.sorted {
(FuelHistoryStore.averagePence($0.points) ?? 0)
> (FuelHistoryStore.averagePence($1.points) ?? 0)
}
}
private var legend: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(legendSeries, id: \.stationID) { history in
HStack(spacing: 8) {
Circle()
.fill(seriesColor(index(of: history.stationID)))
.frame(width: 8, height: 8)
Text(history.name)
.font(.caption)
.lineLimit(1)
if let avg = FuelHistoryStore.averagePence(history.points) {
Text(legendFigure(avg))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Spacer()
}
}
}
.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)
}
}