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.
This commit is contained in:
@@ -30,6 +30,9 @@ struct FavouritesView: View {
|
||||
/// favourites (e.g. the last one is un-starred while viewing it).
|
||||
@State private var fuel: FuelType = .e10
|
||||
|
||||
/// Trends sheet (price history chart) presentation state.
|
||||
@State private var showTrends = false
|
||||
|
||||
private var activeFuel: FuelType {
|
||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||
}
|
||||
@@ -140,9 +143,24 @@ struct FavouritesView: View {
|
||||
.navigationTitle("Favourites")
|
||||
.toolbar {
|
||||
if !favourites.isEmpty {
|
||||
EditButton()
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showTrends = true
|
||||
} label: {
|
||||
Image(systemName: "chart.xyaxis.line")
|
||||
.accessibilityLabel("Trends")
|
||||
}
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTrends) {
|
||||
TrendsView(
|
||||
favourites: favourites,
|
||||
selectedFuel: activeFuel,
|
||||
priceDisplayStyle: priceDisplayStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -134,3 +134,17 @@
|
||||
|
||||
/* Station row */
|
||||
"best" = "best";
|
||||
|
||||
/* Trends — price history chart */
|
||||
"Trends" = "Trends";
|
||||
"Range" = "Range";
|
||||
"Mode" = "Mode";
|
||||
"Price" = "Price";
|
||||
"vs cheapest" = "vs cheapest";
|
||||
"%lld days" = "%lld days";
|
||||
"Fetching price history…" = "Fetching price history…";
|
||||
"Couldn't load price history" = "Couldn't load price history";
|
||||
"Retry" = "Retry";
|
||||
"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.";
|
||||
"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.";
|
||||
|
||||
Reference in New Issue
Block a user