flux branch offpeak-projection-hero commits 1 + review fixes files 10 touched lines +226 / -148

Pre-push review: offpeak-projection-hero

Relocate the off-peak charge projection from a BatteryBlock row to the Dashboard hero's charging subline (T-1533, follow-up to #70).

At a glance

  • Display moved to the hero charging subline; figure rendered in amber, mirroring the discharge empty by line (Decision 10).
  • BatteryBlock reverted to pre-feature state — the projection no longer appears on two surfaces, so there is one source of truth.
  • Two stale-doc findings (CHANGELOG, implementation.md) fixed; one comment imprecision fixed; a suppression test gap (AC 4.3) closed by making projectedCharge internal and testing it.
  • One reuse finding (whole-percent formatting duplicated in HistoryStatsFormatters) was consciously skipped — see Decisions.

Verdict

Ready to push

The relocation is correct and complete: the projection now appends to the hero's .charging subline (Charging · 4.50 kW · ~99% by 14:00), BatteryBlock is cleanly reverted to its pre-feature state, and the spec, decision log, CHANGELOG and implementation notes are all updated. Lint and build pass; all 9 DashboardHeroPanelTests (5 new) pass. The 4 failures in the full suite are pre-existing flaky/environment tests (Keychain accessibility migrator, async-timing view-model tests) documented in #70 — none touch the projection surface.

Review findings

7 raised · 4 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The Dashboard's big battery percentage has a line of text under it. When the battery is charging during the cheap off-peak window, that line now also shows where the battery will end up by the time the window closes — for example Charging · 4.50 kW · ~99% by 14:00. Previously this estimate sat lower down, as a small row in the battery stats panel.

Why it matters

The estimate is a live, glanceable figure, so it belongs next to the live percentage it extends — not buried among the day's totals. It also now appears in only one place, so there is no risk of two screens showing slightly different numbers.

Key concepts

  • Hero: the large battery-percentage panel at the top of the Dashboard.
  • Off-peak window: the cheap-power window (11:00–14:00) when the battery charges from the grid.
  • Projection: a best-case estimate of the charge level at the window's end, computed on the server.

Architecture

Pure display relocation — no backend change. The server already returns offpeak.projectedEndSoc on /status; this change moves where the app renders it.

  • DashboardHeroPanel gains projectedOffpeakEndSoc and offpeakWindowEnd inputs. Its .charging case appends the projection as a suffix, in the amber accent used by the discharge empty by time.
  • DashboardView now threads the projection + window-end into the hero panel instead of the battery panel.
  • BatteryBlock is reverted: the projectedOffpeakEndSoc/offpeakWindowEnd inputs and the offpeakRow selection are removed; rendersOffpeakDelta/offpeakDeltaText return to private.

Patterns

The visible/accessible strings are static pure helpers (projectedChargeLabel, projectedChargeAccessibilityLabel) so they unit-test without hosting a SwiftUI view. The present/absent selection lives in one projectedCharge computed property (internal for the same testability reason).

Trade-offs

The hero shows the projection only while charging; if the battery is idle or (under simulation) discharging during the window, it is not surfaced. This is a deliberate narrowing recorded in Decision 10 and AC 4.1.

Deep dive

Display gating is two-layered: the server returns a projection whenever within-window on fresh live data (Pbat-independent, per requirement 1.9 / Decision 4), while the hero only renders it inside the .charging case (live.pbat < -50). So a projection can exist in the payload yet not be shown when idle/discharging — captured honestly in Decision 10's negative consequences and AC 4.1's added "AND charging" clause.

Formatting

projectedChargeLabel rounds to a whole percent (~%.0f%%) and prefixes ~ to signal an idealised figure — intentionally tighter than the server's 1-dp value, which suits the dense subline. The accessibility variant spells out "about N percent" so VoiceOver does not read ~ as "tilde".

Edge cases & testing

projectedCharge returning nil drives AC 4.3 (no suffix) and is now unit-tested directly. Rounding-to-100 (99.6 → ~100%) and the window-end-nil fallback are pinned. The charging-placement gate lives in the view body and is exercised by the SwiftUI preview rather than a unit test — noted in code and implementation.md.

Important changes — detailed

DashboardHeroPanel: projection appended to the charging subline

DashboardHeroPanel.swift

Why it matters. Core behaviour change — the projection's new home. Mirrors the discharge empty-by line, figure in amber via cutoffAccent.

What to look at. DashboardHeroPanel.swift — .charging case + projectedCharge / projectedChargeLabel

Takeaway. Static pure formatters + an internal computed property keep SwiftUI display logic unit-testable without view hosting — the same idiom the removed offpeakRow used.
Rationale. The projection is a live, transient figure, so it belongs with the live hero numbers and gets a treatment symmetric with the discharge cutoff time.

BatteryBlock reverted to pre-feature state

BatteryBlock.swift

Why it matters. Removes the second rendering surface so the projection has a single source of truth on the Dashboard.

What to look at. BatteryBlock.swift — offpeakRow/projectedLabel removed; rendersOffpeakDelta/offpeakDeltaText back to private

Takeaway. When relocating a feature, fully revert the old site rather than leaving a dormant code path — avoids two surfaces drifting.
Rationale. Showing the same value in the hero and a battery-panel row is redundant and risks the two drifting on formatting (Decision 10).

AC 4.3 suppression now unit-tested

DashboardHeroPanelTests.swift

Why it matters. The no-projection guard is the contract behind "show nothing when absent"; previously only the static label helpers were covered.

What to look at. DashboardHeroPanelTests.swift — projectedChargeIsNilWhenNoProjection + present-case test

Takeaway. Expose the present/absent selector at internal access so the gating contract is tested, even when its placement in the view body is not.
Rationale. Raised by the spec-review agent as a coverage gap; closed by widening projectedCharge to internal (mirrors the prior offpeakRow pattern).

Spec, CHANGELOG and implementation notes realigned

decision_log.md

Why it matters. Keeps the spec authoritative: Decision 9 superseded, Decision 10 added; stale BatteryBlock-row descriptions corrected across docs.

What to look at. decision_log.md / requirements.md / design.md / CHANGELOG.md / implementation.md

Takeaway. A post-ship UI move is a real decision — record it as a superseding ADR and sweep the now-stale prose, don't just change code.
Rationale. Project convention requires spec docs to track implementation; the review found the CHANGELOG and implementation.md still described the reverted approach.

Key decisions

Show the projection in the hero charging subline, not a BatteryBlock row (Decision 10).

The projection is a live figure and belongs beside the SoC numeral it extends, with a treatment symmetric to the discharge empty by line. Supersedes Decision 9. Recorded in decision_log.md and AC 4.1.

Round to a whole percent with a "~" prefix in the hero.

The server computes 1-dp, but the dense subline reads better as an idealised ~99%. The projection is shown in exactly one place, so there is no cross-screen consistency concern with the tighter rounding.

Gate display on the .charging case only.

Consequence: a server-provided projection is not shown when the battery is idle or (under simulation) discharging during the window. Documented as a negative consequence in Decision 10 and the "AND charging" clause of AC 4.1.

Skip reusing HistoryStatsFormatters for whole-percent formatting.

The reuse agent noted projectedChargeLabel/projectedChargeAccessibilityLabel duplicate the whole-percent rounding in HistoryStatsFormatters.socPercent/accessibleSocPercent. Skipped: that helper is History-namespaced (an awkward dependency for the Dashboard), the two implementations are trivial "round to integer percent" with no realistic drift risk, and the clean single-source home would be a SOCFormatting (FluxCore) promotion — out of scope for a display move. Reusing SOCFormatting.format directly is not appropriate (it renders 1-dp, contradicting the idealised-estimate intent).

Review findings

SeverityAreaFindingResolution
majorCHANGELOG.md [Unreleased]The user-facing Added entry still described the projection as a "Projected at HH:MM" row on the battery panel that replaces the off-peak delta row.Rewrote the Added bullet to the hero charging-subline behaviour; corrected the Internal spec bullet to note the Decision 10 relocation.
majorspecs/.../implementation.mdDescribed the reverted BatteryBlock offpeakRow approach throughout (beginner/intermediate/expert) and cited deleted tests in the completeness table.Updated the app-side sections to the hero approach, added a historical note pointing to Decision 10, and remapped the AC 4.1/4.2/4.3 completeness rows to the new tests.
minorDashboardHeroPanel.swift commentDoc comment cited AC 4.3 for the present-value case; AC 4.1 governs rendering-when-present, AC 4.3 the absent case.Corrected the comment to cite AC 4.1 (present) and AC 4.3 (absent).
minorTest coverage (AC 4.3)No test asserted that a nil projection produces no suffix; only the static label helpers were covered.Made projectedCharge internal and added projectedChargeIsNilWhenNoProjection plus a present-case test.
minorReuse — whole-percent formattingprojectedChargeLabel/projectedChargeAccessibilityLabel duplicate the whole-percent rounding already in HistoryStatsFormatters.Skipped — feature-namespaced helper makes it an awkward cross-feature dependency; trivial logic with no real drift risk; proper single-source is a separate FluxCore promotion. SOCFormatting.format reuse is inappropriate (1-dp).
minorQuality — charging-case branchesThe if/else in the .charging case repeats the font + simulated-accessibility modifier chain.Skipped — the structure deliberately mirrors the sibling .discharging case; collapsing it would reduce that parallelism. Defensible as-is.
minorTest coverage (AC 4.1 charging gate)The charging-only placement gate lives in the view body and has no unit test.Acknowledged — SwiftUI bodies are not unit-testable here; the path is exercised by the new preview, noted in code and implementation.md. No change beyond the note.

Per-file diffs

Click to expand.

Flux/Flux/Dashboard/DashboardHeroPanel.swift Modified +77 / -3
diff --git a/Flux/Flux/Dashboard/DashboardHeroPanel.swift b/Flux/Flux/Dashboard/DashboardHeroPanel.swiftindex 7da2530..dceb156 100644--- a/Flux/Flux/Dashboard/DashboardHeroPanel.swift+++ b/Flux/Flux/Dashboard/DashboardHeroPanel.swift@@ -9,6 +9,14 @@ struct DashboardHeroPanel<Accessory: View>: View {     let rolling15min: RollingAvg?     let battery: BatteryInfo?     let offpeakWindowStart: String?+    /// Server-computed projected SoC (percent) at the off-peak window end, from+    /// the same `/status` response. The server only returns it during the+    /// window on fresh live data; the panel renders it on the charging subline+    /// when present (AC 4.1) and shows nothing when nil (AC 4.3).+    let projectedOffpeakEndSoc: Double?+    /// Off-peak window end label (e.g. "14:00") used to time-stamp the+    /// projection, so the labelled time matches the projected time (AC 4.2).+    let offpeakWindowEnd: String?     /// When true the discharge rate and "empty by" reflect a simulated added     /// load, so they render in the simulation accent and are announced as     /// simulated (Req 5.3/5.4).@@ -24,6 +32,8 @@ struct DashboardHeroPanel<Accessory: View>: View {         rolling15min: RollingAvg?,         battery: BatteryInfo? = nil,         offpeakWindowStart: String? = nil,+        projectedOffpeakEndSoc: Double? = nil,+        offpeakWindowEnd: String? = nil,         isSimulating: Bool = false,         @ViewBuilder accessory: @escaping () -> Accessory = { EmptyView() }     ) {@@ -31,6 +41,8 @@ struct DashboardHeroPanel<Accessory: View>: View {         self.rolling15min = rolling15min         self.battery = battery         self.offpeakWindowStart = offpeakWindowStart+        self.projectedOffpeakEndSoc = projectedOffpeakEndSoc+        self.offpeakWindowEnd = offpeakWindowEnd         self.isSimulating = isSimulating         self.accessory = accessory     }@@ -150,6 +162,37 @@ struct DashboardHeroPanel<Accessory: View>: View {         "Charging · \(PowerFormatting.format(watts))"     } +    /// The projected-SoC suffix for the charging subline, or nil when the+    /// server returned no projection (outside the window / no live data). The+    /// panel never re-derives the value — it only formats what `/status`+    /// provides (AC 4.3). Internal so the present/absent selection (AC 4.1/4.3)+    /// is testable without hosting the view body.+    var projectedCharge: (text: String, accessibility: String)? {+        guard let soc = projectedOffpeakEndSoc else { return nil }+        return (+            Self.projectedChargeLabel(soc: soc, windowEnd: offpeakWindowEnd),+            Self.projectedChargeAccessibilityLabel(soc: soc, windowEnd: offpeakWindowEnd)+        )+    }++    /// Visible projection suffix, e.g. "~99% by 14:00". The percent is rounded+    /// and prefixed with "~" because it is an idealised best-case figure (4.5+    /// kW to 95%, then trickle), not a measured value. Falls back to the bare+    /// percent when the window end is unknown.+    static func projectedChargeLabel(soc: Double, windowEnd: String?) -> String {+        let pct = String(format: "~%.0f%%", soc)+        guard let windowEnd else { return pct }+        return "\(pct) by \(windowEnd)"+    }++    /// VoiceOver reading for the projection suffix — spells out "about N+    /// percent" so the "~" is not announced as "tilde".+    static func projectedChargeAccessibilityLabel(soc: Double, windowEnd: String?) -> String {+        let pct = "about \(Int(soc.rounded())) percent"+        guard let windowEnd else { return pct }+        return "\(pct) by \(windowEnd)"+    }+     /// VoiceOver announcement for the indicator. The exact string is the     /// requirement contract under test (see requirements.md §3.5).     static func cantEmptyBeforeOffpeakAccessibilityLabel(offpeakWindowStart: String) -> String {@@ -189,10 +232,26 @@ struct DashboardHeroPanel<Accessory: View>: View {             .appFont(FluxTheme.Typography.heroSubline)             .modifier(SimulatedSublineAccessibility(isSimulating: isSimulating))         case .charging(let watts):-            Text(Self.chargingLabel(watts))+            if let projectedCharge {+                // Mirror the discharge "empty by" line: the rate in the+                // standard accent, then the idealised projected SoC at the+                // window end in amber (Decision 10).+                HStack(spacing: 4) {+                    Text("\(Self.chargingLabel(watts)) · ")+                        .foregroundStyle(sublineAccent)+                    Text(projectedCharge.text)+                        .foregroundStyle(cutoffAccent)+                        .monospacedDigit()+                        .accessibilityLabel(projectedCharge.accessibility)+                }                 .appFont(FluxTheme.Typography.heroSubline)-                .foregroundStyle(sublineAccent)                 .modifier(SimulatedSublineAccessibility(isSimulating: isSimulating))+            } else {+                Text(Self.chargingLabel(watts))+                    .appFont(FluxTheme.Typography.heroSubline)+                    .foregroundStyle(sublineAccent)+                    .modifier(SimulatedSublineAccessibility(isSimulating: isSimulating))+            }         case .idle:             Text("Idle · battery holding")                 .appFont(FluxTheme.Typography.heroSubline)@@ -256,6 +315,21 @@ private struct SimulatedSublineAccessibility: ViewModifier {                 battery: MockFluxAPIClient.statusResponseCantEmpty.battery,                 offpeakWindowStart: MockFluxAPIClient.statusResponseCantEmpty.offpeak?.windowStart             )+            // Off-peak charging with the projected end-of-window SoC appended.+            DashboardHeroPanel(+                live: LiveData(+                    ppv: 0,+                    pload: 600,+                    pbat: -4500,+                    pgrid: 5100,+                    pgridSustained: true,+                    soc: 84.3,+                    timestamp: "2026-06-14T12:34:00Z"+                ),+                rolling15min: nil,+                projectedOffpeakEndSoc: 99.1,+                offpeakWindowEnd: "14:00"+            )         }         .padding()     }
Flux/Flux/Dashboard/DashboardView.swift Modified +3 / -3
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex f753bdf..23a4dd7 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -219,6 +219,8 @@ struct DashboardView: View {             rolling15min: viewModel.status?.rolling15min,             battery: viewModel.status?.battery,             offpeakWindowStart: viewModel.status?.offpeak?.windowStart,+            projectedOffpeakEndSoc: viewModel.status?.offpeak?.projectedEndSoc,+            offpeakWindowEnd: viewModel.status?.offpeak?.windowEnd,             isSimulating: viewModel.isSimulating         ) {             simulateMenu@@ -255,9 +257,7 @@ struct DashboardView: View {                 .flatMap(DateFormatting.parseTimestamp),             offpeakBatteryDeltaPercent: viewModel.status?.offpeak?.batteryDeltaPercent,             showsOffpeakDelta: true,-            energyLeftKwh: energyLeftKwh,-            projectedOffpeakEndSoc: viewModel.status?.offpeak?.projectedEndSoc,-            offpeakWindowEnd: viewModel.status?.offpeak?.windowEnd+            energyLeftKwh: energyLeftKwh         )     } 
Flux/Flux/Helpers/BatteryBlock.swift Modified +5 / -34
diff --git a/Flux/Flux/Helpers/BatteryBlock.swift b/Flux/Flux/Helpers/BatteryBlock.swiftindex 1ee2b24..a53d500 100644--- a/Flux/Flux/Helpers/BatteryBlock.swift+++ b/Flux/Flux/Helpers/BatteryBlock.swift@@ -18,15 +18,6 @@ struct BatteryBlock: View {     var showsOffpeakDelta: Bool = false     /// When non-nil, renders an "Energy left" row above "Battery cycle".     var energyLeftKwh: Double?-    /// Server-computed projected SoC (percent) at the off-peak window end.-    /// When present, the projection row replaces the "Charged during off-peak"-    /// delta row (Decision 9 — they are mutually exclusive). Live-only, so it-    /// is nil on Day Detail / History.-    var projectedOffpeakEndSoc: Double?-    /// Off-peak window end label (e.g. "14:00") from the same `/status`-    /// response, used to label the projection row so the labelled time matches-    /// the time the projection targets (AC 4.2).-    var offpeakWindowEnd: String?      var body: some View {         FluxPanel {@@ -42,12 +33,12 @@ struct BatteryBlock: View {                     label: "Lowest",                     value: lowestValue,                     sub: lowestSubtitle,-                    last: offpeakRow == nil+                    last: !rendersOffpeakDelta                 )-                if let offpeakRow {+                if rendersOffpeakDelta {                     FluxStatRow(-                        label: offpeakRow.label,-                        value: offpeakRow.value,+                        label: "Charged during off-peak",+                        value: offpeakDeltaText,                         last: true                     )                 }@@ -55,29 +46,11 @@ struct BatteryBlock: View {         }     } -    /// The single off-peak row to render, or nil when none applies. The-    /// projection row takes precedence over the delta row (Decision 9); the-    /// two are mutually exclusive. Internal so the selection contract is-    /// testable without view-rendering infrastructure.-    var offpeakRow: (label: String, value: String)? {-        if let projected = projectedOffpeakEndSoc {-            return (projectedLabel, SOCFormatting.format(projected))-        }-        if rendersOffpeakDelta {-            return ("Charged during off-peak", offpeakDeltaText)-        }-        return nil-    }--    var projectedLabel: String {-        "Projected at \(offpeakWindowEnd ?? "off-peak end")"-    }--    var rendersOffpeakDelta: Bool {+    private var rendersOffpeakDelta: Bool {         showsOffpeakDelta || offpeakBatteryDeltaPercent != nil     } -    var offpeakDeltaText: String {+    private var offpeakDeltaText: String {         guard let value = offpeakBatteryDeltaPercent else { return "—" }         return String(format: "%+.0f%%", value)     }
Flux/FluxTests/DashboardHeroPanelTests.swift Modified +60 / -0
diff --git a/Flux/FluxTests/DashboardHeroPanelTests.swift b/Flux/FluxTests/DashboardHeroPanelTests.swiftindex 1d1e19d..a34faf9 100644--- a/Flux/FluxTests/DashboardHeroPanelTests.swift+++ b/Flux/FluxTests/DashboardHeroPanelTests.swift@@ -82,4 +82,66 @@ struct DashboardHeroPanelTests {         #expect(DashboardHeroPanel<EmptyView>.dischargingLabel(2400) == "Discharging · 2.40 kW")         #expect(DashboardHeroPanel<EmptyView>.chargingLabel(1200) == "Charging · 1.20 kW")     }++    // The off-peak projection appears as a suffix on the charging subline+    // (Decision 10). Pin the visible format: the percent is rounded and "~"+    // marks it as an idealised figure, time-stamped with the window end.+    @Test+    func projectedChargeLabelRoundsPercentAndAppendsWindowEnd() {+        #expect(+            DashboardHeroPanel<EmptyView>.projectedChargeLabel(soc: 99.1, windowEnd: "14:00")+                == "~99% by 14:00"+        )+        #expect(+            DashboardHeroPanel<EmptyView>.projectedChargeLabel(soc: 99.6, windowEnd: "14:00")+                == "~100% by 14:00"+        )+    }++    @Test+    func projectedChargeLabelFallsBackToBarePercentWithoutWindowEnd() {+        #expect(+            DashboardHeroPanel<EmptyView>.projectedChargeLabel(soc: 80.4, windowEnd: nil) == "~80%"+        )+    }++    // VoiceOver spells out "about N percent" so the "~" is not read as "tilde".+    @Test+    func projectedChargeAccessibilityLabelSpellsOutPercent() {+        #expect(+            DashboardHeroPanel<EmptyView>.projectedChargeAccessibilityLabel(soc: 99.1, windowEnd: "14:00")+                == "about 99 percent by 14:00"+        )+        #expect(+            DashboardHeroPanel<EmptyView>.projectedChargeAccessibilityLabel(soc: 80.4, windowEnd: nil)+                == "about 80 percent"+        )+    }++    // AC 4.3: with no projection in /status, the charging subline shows no+    // suffix. `projectedCharge` is the guard that drives that branch — pin the+    // present/absent selection directly (the placement inside the `.charging`+    // case is verified by the preview, not unit-testable here).+    @Test+    func projectedChargeIsNilWhenNoProjection() {+        let panel = DashboardHeroPanel(+            live: nil,+            rolling15min: nil,+            projectedOffpeakEndSoc: nil,+            offpeakWindowEnd: "14:00"+        )+        #expect(panel.projectedCharge == nil)+    }++    @Test+    func projectedChargeProducesVisibleAndAccessibleTextWhenPresent() {+        let panel = DashboardHeroPanel(+            live: nil,+            rolling15min: nil,+            projectedOffpeakEndSoc: 99.1,+            offpeakWindowEnd: "14:00"+        )+        #expect(panel.projectedCharge?.text == "~99% by 14:00")+        #expect(panel.projectedCharge?.accessibility == "about 99 percent by 14:00")+    } }
Flux/FluxTests/BatteryBlockProjectionTests.swift Deleted -81
diff --git a/Flux/FluxTests/BatteryBlockProjectionTests.swift b/Flux/FluxTests/BatteryBlockProjectionTests.swiftdeleted file mode 100644index 56aff92..0000000--- a/Flux/FluxTests/BatteryBlockProjectionTests.swift+++ /dev/null@@ -1,81 +0,0 @@-import FluxCore-import Testing-@testable import Flux--// Verifies BatteryBlock's off-peak row selection (Decision 9): the projection-// row takes precedence over the "Charged during off-peak" delta row, and the-// two are mutually exclusive. The contract is exposed through internal-// computed properties so it is testable without view-rendering infrastructure-// (and so it runs on macOS, where UIKit hosting is unavailable).-@MainActor-@Suite-struct BatteryBlockProjectionTests {-    private func block(-        projected: Double?,-        windowEnd: String?,-        delta: Double? = nil,-        showsDelta: Bool = true-    ) -> BatteryBlock {-        BatteryBlock(-            batteryCharge: 6.2,-            batteryDischarge: 5.4,-            lowestSOC: 38,-            lowestSOCTimestamp: nil,-            offpeakBatteryDeltaPercent: delta,-            showsOffpeakDelta: showsDelta,-            projectedOffpeakEndSoc: projected,-            offpeakWindowEnd: windowEnd-        )-    }--    @Test-    func projectionRowLabelUsesWindowEnd() {-        let battery = block(projected: 97.5, windowEnd: "14:00")-        let row = battery.offpeakRow-        #expect(row?.label == "Projected at 14:00")-        #expect(row?.value == SOCFormatting.format(97.5))-    }--    @Test-    func projectionRowFallsBackToOffPeakEndWhenWindowEndNil() {-        let battery = block(projected: 80, windowEnd: nil)-        #expect(battery.offpeakRow?.label == "Projected at off-peak end")-    }--    @Test-    func projectionSuppressesDeltaRow() {-        // Projection present + a realised delta also present: only the-        // projection row renders (mutually exclusive, projection wins).-        let battery = block(projected: 97.5, windowEnd: "14:00", delta: 42)-        let row = battery.offpeakRow-        #expect(row?.label == "Projected at 14:00")-        #expect(row?.value == SOCFormatting.format(97.5))-        #expect(row?.label != "Charged during off-peak")-    }--    @Test-    func deltaRowRendersWhenProjectionNil() {-        // No projection: behaviour unchanged — the delta row renders per the-        // existing showsOffpeakDelta logic.-        let battery = block(projected: nil, windowEnd: "14:00", delta: 42)-        let row = battery.offpeakRow-        #expect(row?.label == "Charged during off-peak")-        #expect(row?.value == "+42%")-    }--    @Test-    func deltaRowShowsDashWhenProjectionNilAndDeltaNil() {-        // No projection, no delta yet, but showsOffpeakDelta keeps the row.-        let battery = block(projected: nil, windowEnd: "14:00", delta: nil)-        let row = battery.offpeakRow-        #expect(row?.label == "Charged during off-peak")-        #expect(row?.value == "—")-    }--    @Test-    func noOffpeakRowWhenProjectionNilAndDeltaHidden() {-        // Neither projection nor delta context (Day Detail / History style).-        let battery = block(projected: nil, windowEnd: nil, delta: nil, showsDelta: false)-        #expect(battery.offpeakRow == nil)-    }-}
specs/offpeak-charge-projection/requirements.md Modified +1 / -1
diff --git a/specs/offpeak-charge-projection/requirements.md b/specs/offpeak-charge-projection/requirements.mdindex 4af36f8..5b85251 100644--- a/specs/offpeak-charge-projection/requirements.md+++ b/specs/offpeak-charge-projection/requirements.md@@ -59,7 +59,7 @@ During the off-peak charging window the battery is charged from the grid, but th  **Acceptance Criteria:** -1. <a name="4.1"></a>WHEN the `/status` response includes a projected SoC, the Dashboard SHALL display it as a percentage within the off-peak section.  +1. <a name="4.1"></a>WHEN the `/status` response includes a projected SoC AND the battery is charging, the Dashboard SHALL display it as a percentage appended to the hero panel's charging subline (e.g. `Charging · 4.50 kW · ~99% by 14:00`). See [Decision 10](decision_log.md).   2. <a name="4.2"></a>The Dashboard SHALL label the projection using the off-peak window end time from the same `/status` response, not a client-side constant, so the labelled time matches the time the projection targets.   3. <a name="4.3"></a>WHEN the `/status` response does not include a projected SoC, the Dashboard SHALL NOT display the projection or any placeholder for it.   4. <a name="4.4"></a>The projection display SHALL render on both iOS and macOS via the shared FluxCore views.  
specs/offpeak-charge-projection/design.md Modified +3 / -1
diff --git a/specs/offpeak-charge-projection/design.md b/specs/offpeak-charge-projection/design.mdindex f8fd249..2bebea9 100644--- a/specs/offpeak-charge-projection/design.md+++ b/specs/offpeak-charge-projection/design.md@@ -2,7 +2,9 @@  ## Overview -Add a server-computed projected SoC at the off-peak window end to `/status`, and render it as a single contextual row on the Dashboard's battery panel. The projection uses an idealised two-rate charge curve and is gated identically to the existing cutoff estimate.+Add a server-computed projected SoC at the off-peak window end to `/status`, and append it to the Dashboard hero panel's charging subline (e.g. `Charging · 4.50 kW · ~99% by 14:00`). The projection uses an idealised two-rate charge curve and is gated identically to the existing cutoff estimate.++> **Note (2026-06-14, [Decision 10](decision_log.md)):** the display moved from a `BatteryBlock` row to the hero charging subline after the feature shipped. The "Swift — display" and "Dashboard wiring" sections below describe the original `BatteryBlock` row approach; the live implementation now lives in `DashboardHeroPanel.swift` (`projectedChargeLabel` / charging case), with `BatteryBlock` reverted to its pre-feature state. The server-side design (compute, gating, field placement) is unchanged.  ## Architecture 
specs/offpeak-charge-projection/decision_log.md Modified +37 / -1
diff --git a/specs/offpeak-charge-projection/decision_log.md b/specs/offpeak-charge-projection/decision_log.mdindex 9a5fbcd..8016398 100644--- a/specs/offpeak-charge-projection/decision_log.md+++ b/specs/offpeak-charge-projection/decision_log.md@@ -255,7 +255,7 @@ The value is meaningful only during the window and the Dashboard labels it with ## Decision 9: Projection row takes precedence over the off-peak delta row in BatteryBlock  **Date**: 2026-06-13-**Status**: accepted+**Status**: superseded by Decision 10  ### Context @@ -283,3 +283,39 @@ During the window the projection is the meaningful figure and the delta is genui - The "Charged during off-peak" row is hidden during the window; the realised delta only appears once the window closes.  ---++## Decision 10: Show the projection in the hero subline, not a BatteryBlock row++**Date**: 2026-06-14+**Status**: accepted++### Context++Decision 9 placed the projection as a row in the Dashboard's `BatteryBlock` ("Projected at 14:00 / 97.5%"). In use the figure read as a minor stat buried among the daily battery totals, not as the live, glanceable number it is meant to be. The hero panel already owns the live battery story: the big SoC numeral plus a subline that, when discharging, shows `Discharging · 2.40 kW · empty by 18:30` (the cutoff time in amber). The off-peak projection is the charging-side counterpart of that "empty by" figure and belongs in the same place.++### Decision++Render the projection in the hero panel's charging subline rather than in `BatteryBlock`. While the battery is charging and `/status` carries a projection, the subline reads `Charging · 4.50 kW · ~99% by 14:00`, with the projected figure in the same amber accent the cutoff time uses. The `BatteryBlock` projection row is removed and its delta-row behaviour reverts to the pre-feature state.++### Rationale++The projection is a live, transient figure (off-peak window only), so it belongs with the other live hero figures, not among the day's accumulated battery stats. Mirroring the discharge "empty by" line gives charge and discharge a symmetric, already-familiar treatment, and keeps the projection in exactly one place — there is no second screen or panel to keep consistent. Rounding to a whole percent prefixed with "~" signals an idealised best-case estimate, which suits the densely packed subline better than a 1-dp figure.++### Alternatives Considered++- **Keep the BatteryBlock row (Decision 9)**: A labelled row in the battery panel - Rejected; reads as a static daily stat rather than a live figure, and is visually distant from the SoC numeral it projects forward.+- **A dedicated second subline under the rate**: `Charging · 4.50 kW` on one line, `Off-peak target ~99% by 14:00` below - Rejected; adds vertical weight to the hero and breaks the symmetry with the single-line discharge treatment.+- **Show it in both the hero and the BatteryBlock row**: Rejected; the same value in two places on one screen is redundant and risks the two drifting on formatting.++### Consequences++**Positive:**+- The projection sits beside the SoC numeral it extrapolates, with the same amber accent as the cutoff time — charge and discharge are symmetric.+- Exactly one place renders the projection; no cross-panel consistency to maintain.+- `BatteryBlock` returns to a single off-peak responsibility (the realised delta row), simplifying it.++**Negative:**+- The projection is shown only while charging; if the battery is idle or (under simulation) discharging during the window, it is not surfaced.+- Whole-percent rounding in the hero drops the 1-dp precision the server computes (acceptable for an idealised figure shown in one spot).++---
specs/offpeak-charge-projection/implementation.md Modified +28 / -19
diff --git a/specs/offpeak-charge-projection/implementation.md b/specs/offpeak-charge-projection/implementation.mdindex cc87595..815dc0c 100644--- a/specs/offpeak-charge-projection/implementation.md+++ b/specs/offpeak-charge-projection/implementation.md@@ -10,9 +10,10 @@ used to validate the implementation against the spec. During the cheap "off-peak" charging window, the battery is being filled from the grid. Before this change, the app showed you the current charge level but gave no hint of how full the battery would be by the time the cheap window closed. This-feature adds a small line on the Dashboard's battery panel that reads, for example,-**"Projected at 14:00 — 97.5%"**: an estimate of where the battery will end up if-charging keeps going at full speed until the window ends.+feature adds the estimate to the Dashboard's main battery readout: while the battery+is charging, the line under the big percentage reads, for example,+**"Charging · 4.50 kW · ~99% by 14:00"** — where the battery will end up if charging+keeps going at full speed until the window ends.  The estimate is worked out on the server (the part of Flux that talks to the battery), not in the app, so every device that shows it shows the same number.@@ -55,11 +56,17 @@ Backend (Go, `internal/api/`): App (Swift): - `FluxCore/Models/APIModels.swift` — `OffpeakData` gains `projectedEndSoc: Double?`   with a trailing defaulted init parameter.-- `Flux/Helpers/BatteryBlock.swift` — two new view inputs (`projectedOffpeakEndSoc`,-  `offpeakWindowEnd`) and an `offpeakRow` computed property that selects a single-  off-peak row.+- `Dashboard/DashboardHeroPanel.swift` — two new view inputs (`projectedOffpeakEndSoc`,+  `offpeakWindowEnd`), static `projectedChargeLabel` / `projectedChargeAccessibilityLabel`+  formatters, and a `projectedCharge` computed property that the `.charging` case of the+  hero subline renders as a suffix (see Decision 10). - `Dashboard/DashboardView.swift` — passes the projection and window-end label from-  `viewModel.status?.offpeak?`.+  `viewModel.status?.offpeak?` into the hero panel.++> The projection was originally rendered as a `BatteryBlock` row (Decision 9); it was+> relocated to the hero charging subline after the feature shipped (Decision 10), and+> `BatteryBlock` reverted to its pre-feature state. This document describes the+> shipped (hero) implementation.  ### Implementation Approach @@ -81,11 +88,13 @@ capacity is non-positive. The caller adds the **fresh-live** gate — the same `liveFresh` flag that guards `EstimatedCutoff` — so a stale reading never produces a projection. -On the app side, `BatteryBlock` consolidates the off-peak row into one `offpeakRow`-computed property: a projection row takes precedence over the "Charged during-off-peak" delta row (they're mutually exclusive), and `nil` means no row at all. The-projection's value is rendered with `SOCFormatting.format`, which matches the server's-1-dp rounding.+On the app side, `DashboardHeroPanel`'s `.charging` case appends the projection to the+subline, mirroring the discharge "empty by" treatment: the rate in the standard accent,+then the projected SoC at the window end in amber. The suffix comes from+`projectedCharge` (nil → no suffix, satisfying AC 4.3) and is formatted by+`projectedChargeLabel` as `~99% by 14:00` — rounded to a whole percent and prefixed+with `~` to signal an idealised estimate (tighter than the server's 1-dp value, which+suits the dense subline).  ### Trade-offs @@ -127,11 +136,13 @@ projection's value is rendered with `SOCFormatting.format`, which matches the se - **Serialization (AC 3.2)**: `*float64` with no `omitempty` → absence serialises as an   explicit `null`, mirroring `EstimatedCutoff`, so clients distinguish "no projection"   from a value. Swift `Double?` decodes present / `null` / absent identically to `nil`.-- **SwiftUI selection**: `offpeakRow` is the single source of truth for the off-peak-  row; the "Lowest" row's `last:` reads `offpeakRow == nil` (post-review), so the-  two-row precedence rule lives in exactly one place. `rendersOffpeakDelta` /-  `offpeakDeltaText` were widened from `private` to internal solely so the logic is-  testable without view hosting — an accepted trade-off documented in the code.+- **SwiftUI selection**: `projectedCharge` is the single source of truth for the+  charging suffix — it returns the `(text, accessibility)` pair when a projection is+  present and `nil` otherwise, so the present/absent decision (AC 4.1/4.3) lives in one+  place. It is internal (not `private`) solely so the selection is testable without+  hosting the view body; its placement inside the `.charging` case (the AC 4.1 "AND+  charging" gate) is exercised by the SwiftUI preview. The visible/accessible strings+  are `static` pure helpers, unit-tested directly.  ### Architecture Impact @@ -175,20 +186,21 @@ Every spec requirement maps to code that can be explained and is covered by a te | 2.3 | unparseable window / capacity ≤ 0 → nil | `negative capacity`, `zero capacity` fixtures | | 3.1 / 3.2 | server-side; `*float64` no `omitempty` | `absent projection serialises as JSON null`; Swift decode present/null/absent | | 3.3 | Dashboard reads `offpeak?.projectedEndSoc` directly | n/a (no re-derivation to test) |-| 4.1 / 4.2 | `offpeakRow` projection row labelled with `offpeakWindowEnd` | `projectionRowLabelUsesWindowEnd`, fallback test |-| 4.3 | `offpeakRow == nil` → no row | `noOffpeakRowWhenProjectionNilAndDeltaHidden` |-| 4.4 | `BatteryBlock` shared across iOS + macOS via target membership | macOS build + test |+| 4.1 / 4.2 | `projectedChargeLabel` suffix on the `.charging` subline, labelled with `offpeakWindowEnd` | `projectedChargeLabelRoundsPercentAndAppendsWindowEnd`, fallback test; `projectedChargeProducesVisibleAndAccessibleTextWhenPresent` |+| 4.3 | `projectedCharge == nil` → no suffix | `projectedChargeIsNilWhenNoProjection` |+| 4.4 | `DashboardHeroPanel` shared across iOS + macOS via target membership | macOS build + test | -Decision 9 (projection row takes precedence over the delta row) is covered by-`projectionSuppressesDeltaRow` and `deltaRowRendersWhenProjectionNil`.+Decision 10 (projection shown in the hero charging subline, not a `BatteryBlock` row)+is covered by the `projectedChargeLabel` / `projectedCharge` tests above; Decision 9+(the original `BatteryBlock` row) is superseded and its tests were removed.  **Partially implemented:** none.  **Missing:** none.  **Notes / non-defects surfaced while explaining:**-- AC 4.4 says "shared FluxCore views" but `BatteryBlock` lives in the app target-  (`Flux/Flux/Helpers/`), shared via target membership, not FluxCore. The design+- AC 4.4 says "shared FluxCore views" but `DashboardHeroPanel` lives in the app target+  (`Flux/Flux/Dashboard/`), shared via target membership, not FluxCore. The design   explicitly flags this wording as imprecise; only the `OffpeakData` model is in   FluxCore. Not a divergence. - AC 3.3 has no automated test (it asserts the *absence* of client-side derivation);
CHANGELOG.md Modified +2 / -2
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 58302ee..65eeb46 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,12 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added -- **Off-peak charge projection** (T-1533). During the off-peak charging window the Dashboard battery panel shows a "Projected at HH:MM" row with the state-of-charge the battery is expected to reach by the window end if charging continues at full capability. The figure is computed server-side on `GET /status` from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W to 100%), clamped to `[current SoC, 100%]`, and is a best-case estimate independent of the live battery power and of load simulation. It reuses the cutoff estimate's battery capacity so the two figures never disagree, appears only inside the window with fresh live data (otherwise an explicit `null`), and replaces the "Charged during off-peak" delta row while the window is open (Day Detail and History are unaffected). iOS + macOS.+- **Off-peak charge projection** (T-1533). While the battery is charging during the off-peak window, the Dashboard hero shows the state-of-charge the battery is expected to reach by the window end on the charging line — e.g. `Charging · 4.50 kW · ~99% by 14:00`. The figure is computed server-side on `GET /status` from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W to 100%), clamped to `[current SoC, 100%]`, and is a best-case estimate independent of the live battery power and of load simulation. It reuses the cutoff estimate's battery capacity so the two figures never disagree, and appears only inside the window with fresh live data while charging (otherwise nothing is shown). iOS + macOS. - **History period navigation** (T-1497). The History screen's Wk and Mo ranges gain a period header — previous/next chevrons, a tappable label opening a graphical date picker (which follows the system's first-day-of-week), and a Current button sitting between the chevrons — to browse past weeks and months, with ←/→ key navigation on macOS. Past periods are fetched via a new past-only `start`/`end` date-range form on `GET /history` that serves stored values only (no live compute), while the current period keeps the unchanged `days=N` form; a cross-handler parity test pins `/day` and `/history` to identical numbers for dates past the readings TTL. App-side: a `HistoryQuery` enum through `FluxAPIClient` and the chart-expansion scope, a Sydney-calendar `HistoryPeriod` model (DST-safe, property-tested), view-model navigation intents with request coalescing keyed on the requested period and a resolved snapshot driving the header and chart domain, an offline cache bounded to the viewed period, a distinct no-data notice for empty past periods (cards stay rendered), and an "N of M days" subtitle when a past period is partially covered. iOS + macOS.  ### Internal -- **Off-peak charge projection spec** (T-1533). Full spec for showing the projected battery state-of-charge at the off-peak window end on the Dashboard: requirements, design, 9-entry decision log, and an 8-task TDD plan across two streams (Go backend, Swift app) in `specs/offpeak-charge-projection/`. During the off-peak window, `GET /status` returns a server-computed projection from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W to 100%), independent of live battery power and load simulation, clamped to `[currentSoC, 100]`; it is rendered as a single contextual row on the Dashboard battery panel that takes precedence over the off-peak delta row, and reuses the cutoff estimate's capacity so the two figures stay consistent. `specs/OVERVIEW.md` updated.+- **Off-peak charge projection spec** (T-1533). Full spec for showing the projected battery state-of-charge at the off-peak window end on the Dashboard: requirements, design, 9-entry decision log, and an 8-task TDD plan across two streams (Go backend, Swift app) in `specs/offpeak-charge-projection/`. During the off-peak window, `GET /status` returns a server-computed projection from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W to 100%), independent of live battery power and load simulation, clamped to `[currentSoC, 100]`; it is appended to the Dashboard hero's charging subline (relocated from a battery-panel row per Decision 10), and reuses the cutoff estimate's capacity so the two figures stay consistent. `specs/OVERVIEW.md` updated. - **History period navigation spec** (T-1497). Full spec for navigating to past weeks/months on the History screen: requirements, design, 17-entry decision log, and a 12-task TDD implementation plan in `specs/history-period-navigation/`. Covers prev/next period chevrons, a Sydney-zoned date-picker jump, a return-to-current action, and a new past-only `start`/`end` date-range form on `GET /history` (existing `days=N` form kept permanently per Decision 16; follow-up ticket T-1540 tracks the unification question). `specs/OVERVIEW.md` updated. No code changes yet.  ## [1.6] - 2026-06-10

Things to double-check

Flaky full-suite failures

The full macOS suite reported 4 failures — KeychainAccessibilityMigratorTests ×2 (environment-dependent keychain), refreshSkipsWhenAlreadyLoading and updateCompareEnabledFetchesAndSetsReadyOnSuccess (async timing). The first three are documented as flaky/environment in #70; the fourth passed in an earlier full run this session. All are unrelated to the projection. A targeted run of DashboardHeroPanelTests passed ** TEST SUCCEEDED ** (9/9).

Review fixes are uncommitted

The fixes above live in the working tree on top of commit b8e2611. They should be folded into that commit (amend) or added as a follow-up commit before pushing.