Legend rows now show each station's average over the days shown in brackets (pence; signed +X.Xp in vs-cheapest mode, matching the list-row deltas). Footer under the key explains the figure per mode. averagePence helper (nil for empty series) + 3 tests; -showTrends becomes a permanent QA hook like -showKeySheet.
298 lines
11 KiB
Swift
298 lines
11 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 {
|
|
// 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 {
|
|
VStack(spacing: 12) {
|
|
chart
|
|
legend
|
|
legendFooter
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
|
|
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.
|
|
Chart {
|
|
ForEach(displaySeries) { history in
|
|
ForEach(history.points) { point in
|
|
LineMark(
|
|
x: .value("Date", point.date),
|
|
y: .value("Price", point.pence),
|
|
series: .value("Station", history.name)
|
|
)
|
|
.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)
|
|
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)
|
|
}
|
|
}
|