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:
@@ -161,6 +161,13 @@ struct FavouritesView: View {
|
|||||||
priceDisplayStyle: priceDisplayStyle
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ struct TrendsView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
chart
|
chart
|
||||||
legend
|
legend
|
||||||
|
legendFooter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,10 +257,41 @@ struct TrendsView: View {
|
|||||||
Text(history.name)
|
Text(history.name)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
if let avg = FuelHistoryStore.averagePence(history.points) {
|
||||||
|
Text(legendFigure(avg))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,3 +156,5 @@
|
|||||||
"No price history yet" = "No price history yet";
|
"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.";
|
"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.";
|
"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)
|
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
|
// MARK: Mirror URLs
|
||||||
|
|
||||||
func testHistoryFileURLKeepsBaseLastSegment() {
|
func testHistoryFileURLKeepsBaseLastSegment() {
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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.
|
/// baseline delta pattern). Days where a station has no point are gaps.
|
||||||
|
|||||||
Reference in New Issue
Block a user