Files
fuelboard/FuelBoard/TrendsView.swift
T
FuelBoard Contributor aab2d02ef5 P1: Favourites Trends graph — FuelHistoryStore + TrendsView (Swift Charts)
- 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.
2026-08-15 10:12:23 +01:00

252 lines
8.6 KiB
Swift

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]
let selectedFuel: FuelType
let priceDisplayStyle: PriceDisplayStyle
@Environment(\.dismiss) private var dismiss
@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?
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
}
}
private func load() async {
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
}
series = fetched
}
private func yLabel(_ pence: Double) -> String {
switch mode {
case .price:
return FuelStore.priceText(pence, style: priceDisplayStyle)
case .vsCheapest:
return String(format: "%.1fp", pence)
}
}
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 {
if isLoading {
Spacer()
ProgressView("Fetching price history…")
Spacer()
} else if loadFailed {
Spacer()
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)
}
Spacer()
} else if !hasAnyData {
emptyState
} else if !hasEnoughData {
emptyState // single point — nothing to draw yet
} else {
VStack(spacing: 12) {
chart
legend
}
}
}
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)
}
private var chart: some View {
Chart(displaySeries) { history in
ForEach(history.points) { point in
LineMark(
x: .value("Date", point.date),
y: .value("Price", point.pence)
)
.foregroundStyle(seriesColor(index(of: history.stationID)))
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: xStride)) { _ in
AxisGridLine()
AxisTick()
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let pence = value.as(Double.self) {
Text(yLabel(pence))
}
}
}
}
.frame(height: 260)
}
private func index(of stationID: String) -> Int {
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
}
private var legend: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in
HStack(spacing: 8) {
Circle()
.fill(seriesColor(index))
.frame(width: 8, height: 8)
Text(history.name)
.font(.caption)
.lineLimit(1)
Spacer()
}
}
}
.padding(.horizontal, 4)
}
}