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 } } } .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) Spacer() } } } .padding(.horizontal, 4) } }