Trends: chart key shows average price per station + descriptor footer

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.
This commit is contained in:
FuelBoard Contributor
2026-08-16 10:39:49 +01:00
parent 9aea478317
commit 430006be94
5 changed files with 71 additions and 0 deletions
+7
View File
@@ -161,6 +161,13 @@ struct FavouritesView: View {
priceDisplayStyle: priceDisplayStyle
)
}
.onAppear {
// QA hook: launch with `-showTrends` to open the sheet
// without a tap (same pattern as -showKeySheet).
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
showTrends = true
}
}
}
}
}
+32
View File
@@ -175,6 +175,7 @@ struct TrendsView: View {
VStack(spacing: 12) {
chart
legend
legendFooter
}
}
}
@@ -256,10 +257,41 @@ struct TrendsView: View {
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)
}
}
+2
View File
@@ -156,3 +156,5 @@
"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.";
"Average price over the days shown" = "Average price over the days shown";
"Average pence above the day's cheapest favourite" = "Average pence above the day's cheapest favourite";
@@ -164,6 +164,29 @@ final class FuelHistoryTests: XCTestCase {
XCTAssertEqual(pruned.count, days.count)
}
// MARK: Chart-key average
func testAveragePenceEmptyIsNil() {
XCTAssertNil(FuelHistoryStore.averagePence([]))
}
func testAveragePenceSinglePoint() {
let d = FuelHistoryStore.date(fromDay: "2026-08-15")!
XCTAssertEqual(FuelHistoryStore.averagePence([PricePoint(date: d, pence: 156.7)]), 156.7)
}
func testAveragePenceMultiplePoints() {
let d1 = FuelHistoryStore.date(fromDay: "2026-08-15")!
let d2 = FuelHistoryStore.date(fromDay: "2026-08-16")!
// (153.9 + 156.7) / 2 = 155.3 exact in binary? 153.9+156.7=310.6, /2=155.3
let avg = FuelHistoryStore.averagePence([
PricePoint(date: d1, pence: 153.9),
PricePoint(date: d2, pence: 156.7),
])
XCTAssertNotNil(avg)
XCTAssertEqual(avg!, 155.3, accuracy: 0.0001)
}
// MARK: Mirror URLs
func testHistoryFileURLKeepsBaseLastSegment() {
+7
View File
@@ -148,6 +148,13 @@ enum FuelHistoryStore {
}
}
/// Average pence over a series' points; nil when there are no points
/// (a station with no data shows no bracket figure in the chart key).
static func averagePence(_ points: [PricePoint]) -> Double? {
guard !points.isEmpty else { return nil }
return points.reduce(0) { $0 + $1.pence } / Double(points.count)
}
/// Rebase every station's series so each day's CHEAPEST favourite sits at
/// 0 and the others show signed pence above it (mirrors the list's
/// baseline delta pattern). Days where a station has no point are gaps.