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 { 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) } }