Refine trends and debug harness; clean up warnings
This commit is contained in:
+221
-14
@@ -13,6 +13,8 @@ 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
|
||||
|
||||
@@ -32,6 +34,21 @@ struct TrendsView: View {
|
||||
@State private var loadFailed = false
|
||||
@State private var firstSnapshot: String?
|
||||
|
||||
/// 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
|
||||
@@ -85,7 +102,34 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Double> {
|
||||
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.
|
||||
@@ -95,6 +139,7 @@ struct TrendsView: View {
|
||||
onHistoryUnavailable?()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
isLoading = true
|
||||
loadFailed = false
|
||||
defer { isLoading = false }
|
||||
@@ -132,6 +177,38 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -195,11 +272,7 @@ struct TrendsView: View {
|
||||
} else if !hasEnoughData {
|
||||
emptyState // single point — nothing to draw yet
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
chart
|
||||
legend
|
||||
legendFooter
|
||||
}
|
||||
revenueCatCard
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
@@ -228,21 +301,103 @@ struct TrendsView: View {
|
||||
.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)
|
||||
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) { point in
|
||||
LineMark(
|
||||
x: .value("Date", point.date),
|
||||
y: .value("Price", point.pence),
|
||||
series: .value("Station", history.name)
|
||||
)
|
||||
.foregroundStyle(seriesColor(index(of: history.stationID)))
|
||||
areaMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
}
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
lineMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,31 +406,83 @@ struct TrendsView: View {
|
||||
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: 260)
|
||||
.frame(height: 190)
|
||||
}
|
||||
|
||||
/// 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(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in
|
||||
ForEach(legendSeries, id: \.stationID) { history in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(seriesColor(index))
|
||||
.fill(seriesColor(index(of: history.stationID)))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(history.name)
|
||||
.font(.caption)
|
||||
|
||||
Reference in New Issue
Block a user