T-1327 — server-computed cantEmptyBeforeOffpeak flag on /status and a new Dashboard hero indicator. Diff measured against the merge-base 9958320 because origin/main has since added unrelated T-1150 iPad work; this branch must rebase onto origin/main before push.
computeCantEmptyBeforeOffpeak + active-window predicate in internal/api/compute.go; new maxDischargeKW = 5.0 constant in internal/api/status.go; new BatteryInfo.CantEmptyBeforeOffpeak *bool in internal/api/response.go; wired inside the existing liveFresh branch of handleStatus.BatteryInfo.cantEmptyBeforeOffpeak with a defaulted memberwise init (five existing constructors compile unchanged); new DashboardHeroPanel subview composed alongside (not inside) the existing Mode enum; new MockFluxAPIClient.statusResponseCantEmpty fixture; #Preview renders both states.golangci-lint clean. FluxCore swift package: 193/193 green.KeychainAccessibilityMigratorTests — errSecInteractionNotAllowed) are baseline flakes from headless test runs; unrelated to this branch.Ready to push (rebase first)
All four review agents (code-reuse, code-quality, efficiency, spec/docs/tests) returned clean against the spec. No blockers. A handful of nits surfaced (HasBoundary + NextOpStart could be a single *time.Time; the AX label is asserted via a static helper rather than a rendered view; FP-edge boundary test is platform-stable but not deterministic-by-construction) — none worth touching pre-push. Before pushing, rebase onto origin/main to pick up the merged T-1150 iPad work.
fbb060b T-1327: Phase changelog, design table fix, mark spec Done 15636a1 T-1327: Mark validation task complete, clean up rune slug accumulation 380bbe6 [merge]: T-1327 stream 2 (Swift) b768ddf [merge]: T-1327 stream 1 (Backend Go) bd06d82 T-1327: Swift Dashboard hero indicator + cantEmptyBeforeOffpeak model 445629d T-1327: Backend `cantEmptyBeforeOffpeak` indicator on /status 175e83c T-1327: Spec battery-can't-empty-before-offpeak Dashboard indicator The Flux Dashboard already shows a line like “empty by 18:45” based on how fast the battery is draining right now. The trouble is: that estimate uses the current (often slow) drain rate. If the battery is very full and off-peak power is only minutes away, no amount of drain can actually get it down to the 5% floor in time. The promise is misleading.
This change adds a new line that says “Won’t empty before HH:MM” for exactly that case. The two lines never show at the same time. When the warning shows, the regular cutoff estimate hides.
The user sees the truth (physics ceiling reached) instead of a cutoff time that can’t happen. They can act — manually export, run a high load, or just stop worrying.
internal/api/compute.go: new withinOffpeakWindow(now, start, end) bool (delegates parsing to derivedstats.ParseOffpeakWindow) and computeCantEmptyBeforeOffpeak(in cantEmptyInput) *bool with four early-return guards.internal/api/status.go: new maxDischargeKW = 5.0 constant; helper invoked inside the existing liveFresh branch, immediately after EstimatedCutoff is set.internal/api/response.go: new CantEmptyBeforeOffpeak *bool on BatteryInfo, JSON-tagged cantEmptyBeforeOffpeak, pointer + no omitempty — emits null when false-equivalent, mirroring EstimatedCutoff’s encoding.BatteryInfo.cantEmptyBeforeOffpeak: Bool? with memberwise-init default nil (preserves five existing constructors per design.md §Pattern extension audit).DashboardHeroPanel gains battery and offpeakWindowStart inputs; new cantEmptyBeforeOffpeakIndicator subview composed at the subline @ViewBuilder level — existing Mode enum untouched.MockFluxAPIClient gains statusResponseCantEmpty fixture; #Preview renders both states.remainingKwh = (Soc - cutoffPercent) / 100 * CapacityKwh
requiredHours = remainingKwh / maxDischargeKW
flag = Now.Add(requiredHours * time.Hour).After(NextOpStart)Strict After — equality returns nil. Tri-state wire shape (true / null / not-emitted) handles forward-compatibility with older Swift clients automatically.
rolling15min mirror (Decision 6) — would always equal the hero flag.func computeCantEmptyBeforeOffpeak(in cantEmptyInput) *bool {
if !in.HasBoundary || in.WithinOffpeakWindow ||
in.Soc <= cutoffPercent || in.CapacityKwh <= 0 {
return nil
}
remainingKwh := (in.Soc - cutoffPercent) / 100 * in.CapacityKwh
requiredHours := remainingKwh / maxDischargeKW
if in.Now.Add(time.Duration(requiredHours * float64(time.Hour))).After(in.NextOpStart) {
t := true
return &t
}
return nil
}nowFunc to the gap morning and asserts nextOffpeakStart returns the 11:00 AEDT boundary (+11 offset).fallbackCapacityKwh = 13.34. Case f exercises this.Soc = 5 + 500/13.34 so requiredHours = 1h. Strict After returns false, helper returns nil. FP-stable on x86_64 / arm64.if liveFresh. Stale → field nil → JSON null, identical to EstimatedCutoff.Adds one boolean to /status and one private subview to DashboardHeroPanel. No poller changes, no new tables, no new SSM, no new Lambda routes, no widget changes. The flag rides inside the existing liveFresh gate, so freshness semantics are unchanged.
The Swift defaulted-init pattern protects five existing call sites from churn — second time it pays off. Future optional additions to BatteryInfo should follow the same shape.
HasBoundary + NextOpStart carry the same information; *time.Time would let the compiler enforce the invariant. Out-of-scope refactor (would touch nextOffpeakStart signature and its other caller).statusLine when cantEmptyBeforeOffpeak == true but offpeakWindowStart == nil. Design-documented; would be worth an assertionFailure in DEBUG.nextOffpeakStart, once in withinOffpeakWindow). Sub-µs on a 10-second-cadence endpoint dominated by DynamoDB I/O. Caching would add state-plumbing for no measurable gain.internal/api/compute.go
Why it matters. Where the core can-the-battery-actually-drain-in-time check is implemented and where the wire field gets populated.
What to look at. internal/api/compute.go:80-130 + internal/api/status.go:24,136-143
internal/api/response.go
Why it matters. This is the API contract that Swift (and any other consumer) reads against.
What to look at. internal/api/response.go:38
Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift
Why it matters. Determines whether five existing BatteryInfo call sites have to be touched.
What to look at. APIModels.swift — BatteryInfo init: cantEmptyBeforeOffpeak: Bool? = nil as last parameter
Flux/Flux/Dashboard/DashboardHeroPanel.swift
Why it matters. User-visible piece. Where a future maintainer will land first.
What to look at. DashboardHeroPanel.swift:55-83 — subline @ViewBuilder swaps the indicator for the existing statusLine; Mode enum untouched.
internal/api/status_test.go
Why it matters. DST gaps are the latent-bug class for Sydney-local arithmetic.
What to look at. internal/api/status_test.go — case (e), pins nowFunc to 2026-10-04 01:30 Sydney (spring-forward) and asserts the +11 AEDT offset.
Flux is a single-deployment personal tool with one inverter. A parameter would be dead weight; the value is read from a package-level const maxDischargeKW = 5.0 alongside cutoffPercent.
The rolling slice inherits live values; mirroring would duplicate without adding information. Hero is the only surface that consumes the field.
Avoids a parallel HH:MM parser and inherits the midnight-spanning-window rejection (Decision 8). Cost is sub-µs.
Co-located with the view body so the contract string lives in one place. The unit test calls the helper (no ViewInspector / snapshot harness in place). Trade-off: a small piece of test-facing surface on the view.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | internal/api/compute.go:80-130 | HasBoundary and NextOpStart carry the same information; *time.Time would let the compiler enforce the invariant. | Out-of-scope refactor — would touch nextOffpeakStart signature and its other caller in handleStatus. Skipped per scope. |
| minor | Flux/Flux/Dashboard/DashboardHeroPanel.swift:55-62 | Falls back to statusLine silently when cantEmptyBeforeOffpeak == true but offpeakWindowStart == nil. Hides a server bug. | Design-documented defensive behaviour; assertionFailure in DEBUG would be nicer. Skipped — not blocking. |
| minor | Flux/Flux/Services/MockFluxAPIClient.swift:69-82 | statusResponseCantEmpty rebuilds BatteryInfo field-by-field instead of overlaying. Risks silent drift from the base fixture. | BatteryInfo is a let-only struct with no copy-with-mutation API; matches the existing fixture pattern in the file. Skipped. |
| minor | internal/api/compute_test.go:706-814 | Boundary-equality test relies on FP arithmetic on 0.1-derived values to land on the strict-After threshold. | Stable on x86_64 / arm64. Integer-clean inputs would be deterministic-by-construction. Skipped — currently passes; cleanup deferred. |
| minor | Flux/FluxTests/DashboardHeroPanelTests.swift:11-47 | Test instantiates a panel, discards it, then asserts a static string-interpolation helper. The static helper exists only so this test compiles. | Asserts the contract string per AC 3.5. Skipped — refactoring would require swapping in a snapshot/ViewInspector harness. |
Click to expand.
diff --git a/internal/api/compute.go b/internal/api/compute.goindex eb3d950..342a161 100644--- a/internal/api/compute.go+++ b/internal/api/compute.go@@ -80,6 +80,55 @@ func computeCutoffTime(soc, pbat, capacityKwh, cutoffPercent float64, now time.T return &t } +// cantEmptyInput bundles the inputs to computeCantEmptyBeforeOffpeak.+//+// Soc is the latest battery SOC (percent). CapacityKwh is the configured+// battery capacity. Now and NextOpStart are absolute Sydney-local times.+// HasBoundary mirrors nextOffpeakStart's ok return — false when the off-peak+// window is unparseable. WithinOffpeakWindow is true when Now falls inside+// the configured window (any future cutoff during a charging window is+// meaningless).+type cantEmptyInput struct {+ Soc, CapacityKwh float64+ Now, NextOpStart time.Time+ HasBoundary bool+ WithinOffpeakWindow bool+}++// computeCantEmptyBeforeOffpeak returns &true when, at the constant+// maxDischargeKW ceiling, the battery cannot reach cutoffPercent before+// NextOpStart. Returns nil when the question is meaningless (no off-peak+// boundary, window currently active, SOC already at/below cutoff, or+// non-positive capacity) or when the battery can in fact empty in time.+// The comparison is strict (After), so Now+requiredHours == NextOpStart+// returns nil — see requirements AC 2.4 (boundary equality).+func computeCantEmptyBeforeOffpeak(in cantEmptyInput) *bool {+ if !in.HasBoundary || in.WithinOffpeakWindow || in.Soc <= cutoffPercent || in.CapacityKwh <= 0 {+ return nil+ }+ remainingKwh := (in.Soc - cutoffPercent) / 100 * in.CapacityKwh+ requiredHours := remainingKwh / maxDischargeKW+ if in.Now.Add(time.Duration(requiredHours * float64(time.Hour))).After(in.NextOpStart) {+ t := true+ return &t+ }+ return nil+}++// withinOffpeakWindow reports whether now (in Sydney local time per the+// handler's invariant) falls inside the off-peak window [start, end).+// Parsing is delegated to derivedstats.ParseOffpeakWindow so this stays a+// single source of truth — unparseable inputs return false rather than+// raising an error (consistent with how cutoff-time suppression degrades).+func withinOffpeakWindow(now time.Time, offpeakStart, offpeakEnd string) bool {+ startMin, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)+ if !ok {+ return false+ }+ minuteOfDay := now.Hour()*60 + now.Minute()+ return minuteOfDay >= startMin && minuteOfDay < endMin+}+ // computeRollingAverages returns the mean pload and pbat over the given readings. // Returns (0, 0) for an empty slice. func computeRollingAverages(readings []dynamo.ReadingItem) (avgLoad, avgPbat float64) {
diff --git a/internal/api/compute_test.go b/internal/api/compute_test.goindex 2e9db6c..e76711f 100644--- a/internal/api/compute_test.go+++ b/internal/api/compute_test.go@@ -7,6 +7,7 @@ import ( "github.com/ArjenSchwarz/flux/internal/dynamo" "github.com/stretchr/testify/assert"+ "github.com/stretchr/testify/require" ) func TestOffpeakDeltas(t *testing.T) {@@ -657,6 +658,166 @@ func TestComputeTodayEnergy(t *testing.T) { } } +func TestWithinOffpeakWindow(t *testing.T) {+ syd := func(h, m int) time.Time {+ return time.Date(2026, 4, 15, h, m, 0, 0, sydneyTZ)+ }++ tests := map[string]struct {+ now time.Time+ offpeakStart string+ offpeakEnd string+ want bool+ }{+ "before window": {+ now: syd(10, 59), offpeakStart: "11:00", offpeakEnd: "14:00",+ want: false,+ },+ "at start": {+ now: syd(11, 0), offpeakStart: "11:00", offpeakEnd: "14:00",+ want: true,+ },+ "mid-window": {+ now: syd(12, 30), offpeakStart: "11:00", offpeakEnd: "14:00",+ want: true,+ },+ "at end (exclusive)": {+ now: syd(14, 0), offpeakStart: "11:00", offpeakEnd: "14:00",+ want: false,+ },+ "after window": {+ now: syd(14, 30), offpeakStart: "11:00", offpeakEnd: "14:00",+ want: false,+ },+ "unparseable strings": {+ now: syd(12, 0), offpeakStart: "x", offpeakEnd: "y",+ want: false,+ },+ }++ for name, tc := range tests {+ t.Run(name, func(t *testing.T) {+ got := withinOffpeakWindow(tc.now, tc.offpeakStart, tc.offpeakEnd)+ assert.Equal(t, tc.want, got)+ })+ }+}++func TestComputeCantEmptyBeforeOffpeak(t *testing.T) {+ now := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)++ // Boundary equality: requiredHours = (Soc - cutoffPercent)/100 * CapacityKwh / maxDischargeKW+ // Pick Soc such that requiredHours == 1h exactly with capacity 13.34 and max 5.0:+ // requiredHours = 1 → (Soc - 5)/100 * 13.34 / 5 = 1 → Soc - 5 = 500/13.34 → Soc = 5 + 500/13.34+ boundarySoc := 5.0 + 500.0/13.34++ tests := map[string]struct {+ in cantEmptyInput+ want *bool+ }{+ "soc just above cutoff, short window": {+ // Soc 6, cap 13.34, cutoff 5 → remaining 0.1334 kWh.+ // At 5 kW the battery drains in ~1.6 min; a 1-minute window+ // therefore opens before drain completes → flag &true.+ in: cantEmptyInput{+ Soc: 6, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(1 * time.Minute),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: boolPtr(true),+ },+ "soc just above cutoff, long window": {+ in: cantEmptyInput{+ Soc: 6, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(24 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ "soc well above cutoff, short window": {+ in: cantEmptyInput{+ Soc: 90, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(30 * time.Minute),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: boolPtr(true),+ },+ "soc well above cutoff, long window": {+ in: cantEmptyInput{+ Soc: 90, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(48 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ "soc exactly at cutoff": {+ in: cantEmptyInput{+ Soc: 5, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(1 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ "soc below cutoff": {+ in: cantEmptyInput{+ Soc: 3, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(1 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ "window currently active": {+ in: cantEmptyInput{+ Soc: 80, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(1 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: true,+ },+ want: nil,+ },+ "no boundary": {+ in: cantEmptyInput{+ Soc: 80, CapacityKwh: 13.34,+ Now: now, NextOpStart: time.Time{},+ HasBoundary: false, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ "zero capacity": {+ in: cantEmptyInput{+ Soc: 80, CapacityKwh: 0,+ Now: now, NextOpStart: now.Add(1 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ "boundary equality": {+ in: cantEmptyInput{+ Soc: boundarySoc, CapacityKwh: 13.34,+ Now: now, NextOpStart: now.Add(1 * time.Hour),+ HasBoundary: true, WithinOffpeakWindow: false,+ },+ want: nil,+ },+ }++ for name, tc := range tests {+ t.Run(name, func(t *testing.T) {+ got := computeCantEmptyBeforeOffpeak(tc.in)+ if tc.want == nil {+ assert.Nil(t, got)+ return+ }+ require.NotNil(t, got)+ assert.Equal(t, *tc.want, *got)+ })+ }+}++// boolPtr returns a pointer to the given bool.+func boolPtr(b bool) *bool {+ return &b+}+ func TestReconcileEnergy(t *testing.T) { tests := map[string]struct { computed *TodayEnergy
diff --git a/internal/api/status.go b/internal/api/status.goindex a25acf6..6166b31 100644--- a/internal/api/status.go+++ b/internal/api/status.go@@ -17,6 +17,11 @@ const ( // cutoffPercent is the fixed battery cutoff threshold. // Mirrored in iOS FluxCore/BatteryEnergy.swift — update both on hardware changes. cutoffPercent = 5+ // maxDischargeKW is the inverter's nameplate sustained discharge ceiling+ // used by the pbat-independent "can't empty before off-peak" check+ // (T-1327, Decision 1). Constant, not a parameter — there is one ceiling,+ // set in code.+ maxDischargeKW = 5.0 // liveDataStalenessThreshold bounds how old the most recent reading can // be before /status stops surfacing it as live. The poller writes every // 10 s, so nine consecutive missed writes (90 s) is unambiguously broken@@ -125,6 +130,17 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe battery.EstimatedCutoff = &s } }+ // T-1327: pbat-independent "can't empty before off-peak" indicator.+ // Computed only on the live branch — a stale SoC would produce a+ // misleading flag (Decision 7, mirrors EstimatedCutoff's gating).+ battery.CantEmptyBeforeOffpeak = computeCantEmptyBeforeOffpeak(cantEmptyInput{+ Soc: latest.Soc,+ CapacityKwh: capacity,+ Now: now,+ NextOpStart: nextOpWindowStart,+ HasBoundary: hasOffpeakBoundary,+ WithinOffpeakWindow: withinOffpeakWindow(now, h.offpeakStart, h.offpeakEnd),+ }) } // Lowest SOC since 00:00 Sydney local on now's date — see Decision 4 in
diff --git a/internal/api/status_test.go b/internal/api/status_test.goindex 936f70b..fce7b6a 100644--- a/internal/api/status_test.go+++ b/internal/api/status_test.go@@ -854,6 +854,198 @@ func TestHandleStatusBundlesNote(t *testing.T) { }) } +// TestHandleStatusCantEmptyBeforeOffpeak covers the wire-level cases for the+// T-1327 "battery can't empty before off-peak" indicator: liveFresh on/off,+// flag on/off, off-peak config presence, DST transition, and fallback+// capacity. Each case asserts the marshalled JSON shape ("cantEmptyBeforeOffpeak":+// true | null — false is never emitted) per AC 2.2.+func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) {+ t.Run("a) liveFresh and condition true emits true", func(t *testing.T) {+ // now = 10:50 Sydney, off-peak 11:00-14:00 → 10 min to window.+ // Soc 60 with 13.34 kWh cap at 5 kW max → requires ~88 min to reach 5%+ // → cannot empty in 10 min → flag &true.+ now := time.Date(2026, 4, 15, 10, 50, 0, 0, sydneyTZ)+ nowUnix := now.Unix()+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return []dynamo.ReadingItem{+ {Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 100, Pgrid: 50, Soc: 60},+ }, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ assert.Equal(t, 200, resp.StatusCode)++ sr := parseStatusResponse(t, resp)+ require.NotNil(t, sr.Battery)+ require.NotNil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must be set when condition holds")+ assert.True(t, *sr.Battery.CantEmptyBeforeOffpeak)++ // Verify JSON shape: field present and explicitly true.+ var raw map[string]json.RawMessage+ require.NoError(t, json.Unmarshal([]byte(resp.Body), &raw))+ var battery map[string]json.RawMessage+ require.NoError(t, json.Unmarshal(raw["battery"], &battery))+ require.Contains(t, battery, "cantEmptyBeforeOffpeak")+ assert.Equal(t, "true", string(battery["cantEmptyBeforeOffpeak"]))+ })++ t.Run("b) liveFresh and condition false emits null", func(t *testing.T) {+ // now = 10:00, off-peak 11:00-14:00 → 1 hour to window.+ // Soc 6 with 13.34 kWh → requires ~1.6 min, can empty in 1h → flag nil.+ now := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)+ nowUnix := now.Unix()+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return []dynamo.ReadingItem{+ {Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 100, Pgrid: 50, Soc: 6},+ }, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ sr := parseStatusResponse(t, resp)+ require.NotNil(t, sr.Battery)+ assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must be nil when battery can empty in time")++ var raw map[string]json.RawMessage+ require.NoError(t, json.Unmarshal([]byte(resp.Body), &raw))+ var battery map[string]json.RawMessage+ require.NoError(t, json.Unmarshal(raw["battery"], &battery))+ require.Contains(t, battery, "cantEmptyBeforeOffpeak", "field must always be serialised (no omitempty)")+ assert.Equal(t, "null", string(battery["cantEmptyBeforeOffpeak"]))+ })++ t.Run("c) not liveFresh emits null regardless", func(t *testing.T) {+ // Stale reading (>90s old) → liveFresh false → flag must not be computed.+ now := time.Date(2026, 4, 15, 10, 50, 0, 0, sydneyTZ)+ nowUnix := now.Unix()+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return []dynamo.ReadingItem{+ {Timestamp: nowUnix - 3600, Ppv: 0, Pload: 200, Pbat: 100, Pgrid: 50, Soc: 60},+ }, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ sr := parseStatusResponse(t, resp)+ assert.Nil(t, sr.Live, "precondition: live must be omitted when stale")+ require.NotNil(t, sr.Battery)+ assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must be nil when !liveFresh")+ })++ t.Run("d) empty off-peak config emits null", func(t *testing.T) {+ now := time.Date(2026, 4, 15, 10, 50, 0, 0, sydneyTZ)+ nowUnix := now.Unix()+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return []dynamo.ReadingItem{+ {Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 100, Pgrid: 50, Soc: 60},+ }, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ }+ // Empty off-peak config → ParseOffpeakWindow returns ok=false → no boundary.+ h := NewHandler(mr, nil, testSerial, testToken, "", "")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ sr := parseStatusResponse(t, resp)+ require.NotNil(t, sr.Battery)+ assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must be nil when off-peak config is missing")+ })++ t.Run("e) Sydney DST transition day", func(t *testing.T) {+ // 2026-10-04 is DST start in Sydney — clocks jump 02:00 AEST → 03:00 AEDT.+ // Pin now to 01:30 AEST (= 15:30 UTC on 2026-10-03). Off-peak start+ // 11:00 AEDT (= 00:00 UTC on 2026-10-04) → real elapsed ≈ 8.5 h.+ // Soc 60 with 13.34 kWh at 5 kW max → required ≈ 1.47 h < 8.5 h → flag nil.+ // The point is to verify nextOffpeakStart's DST math feeds the helper+ // without producing a spurious flag flip on the gap day.+ loc, err := time.LoadLocation("Australia/Sydney")+ require.NoError(t, err)+ now := time.Date(2026, 10, 4, 1, 30, 0, 0, loc)+ nowUnix := now.Unix()+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return []dynamo.ReadingItem{+ {Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 100, Pgrid: 50, Soc: 60},+ }, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ assert.Equal(t, 200, resp.StatusCode)+ sr := parseStatusResponse(t, resp)+ require.NotNil(t, sr.Battery)+ assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak,+ "DST day: required hours (~1.47h) far less than time-to-window (~8.5h) → flag nil")++ // Cross-check the boundary the integration uses: nextOpStart on+ // the DST day must be 11:00 AEDT (UTC+11), not 11:00 AEST.+ nextOp, ok := nextOffpeakStart(now.In(sydneyTZ), "11:00", "14:00")+ require.True(t, ok)+ _, offsetSec := nextOp.Zone()+ assert.Equal(t, 11*3600, offsetSec, "off-peak start sits in AEDT after the DST gap")+ })++ t.Run("f) fallback capacity when system record missing", func(t *testing.T) {+ // System record nil → handler uses fallbackCapacityKwh = 13.34.+ // Same conditions as case (a): flag should fire.+ now := time.Date(2026, 4, 15, 10, 50, 0, 0, sydneyTZ)+ nowUnix := now.Unix()+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return []dynamo.ReadingItem{+ {Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: 100, Pgrid: 50, Soc: 60},+ }, nil+ },+ getSystemFn: func(_ context.Context, _ string) (*dynamo.SystemItem, error) {+ return nil, nil // not found → fallback capacity used+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ sr := parseStatusResponse(t, resp)+ require.NotNil(t, sr.Battery)+ assert.Equal(t, 13.34, sr.Battery.CapacityKwh, "precondition: fallback capacity in use")+ require.NotNil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must fire even on fallback capacity")+ assert.True(t, *sr.Battery.CantEmptyBeforeOffpeak)+ })+}+ func TestHandleStatusSingleNowCapture(t *testing.T) { // Verify that the handler captures "now" once and uses it consistently. // The mock clock should be called exactly once via nowFunc.
diff --git a/internal/api/response.go b/internal/api/response.goindex 2fa3a1c..f680714 100644--- a/internal/api/response.go+++ b/internal/api/response.go@@ -26,11 +26,17 @@ type LiveData struct { // BatteryInfo contains battery capacity, cutoff estimates, and the lowest // SOC since 00:00 Sydney local on the current day.+//+// CantEmptyBeforeOffpeak is the T-1327 "won't drain in time" indicator. It is+// a pointer with no omitempty so the wire emits `null` when the condition+// does not hold and `true` when it does — `false` is never serialised+// (mirrors EstimatedCutoff). See AC 2.2. type BatteryInfo struct {- CapacityKwh float64 `json:"capacityKwh"`- CutoffPercent int `json:"cutoffPercent"`- EstimatedCutoff *string `json:"estimatedCutoffTime"`- Low24h *Low24h `json:"low24h"`+ CapacityKwh float64 `json:"capacityKwh"`+ CutoffPercent int `json:"cutoffPercent"`+ EstimatedCutoff *string `json:"estimatedCutoffTime"`+ CantEmptyBeforeOffpeak *bool `json:"cantEmptyBeforeOffpeak"`+ Low24h *Low24h `json:"low24h"` } // Low24h contains the lowest SOC reading since 00:00 Sydney local on the
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swiftindex ab34426..d4d4912 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift@@ -61,17 +61,24 @@ public struct BatteryInfo: Codable, Sendable { public let cutoffPercent: Int public let estimatedCutoffTime: String? public let low24h: Low24h?+ /// Server-computed flag: true when even at sustained max discharge the+ /// battery cannot reach `cutoffPercent` before the next off-peak window+ /// begins. The server emits `true` or `null`; `false` is never on the+ /// wire (mirrors `estimatedCutoffTime`).+ public let cantEmptyBeforeOffpeak: Bool? public init( capacityKwh: Double, cutoffPercent: Int, estimatedCutoffTime: String?,- low24h: Low24h?+ low24h: Low24h?,+ cantEmptyBeforeOffpeak: Bool? = nil ) { self.capacityKwh = capacityKwh self.cutoffPercent = cutoffPercent self.estimatedCutoffTime = estimatedCutoffTime self.low24h = low24h+ self.cantEmptyBeforeOffpeak = cantEmptyBeforeOffpeak } }
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swiftindex 76f2679..3ce62ee 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift@@ -126,6 +126,57 @@ struct APIModelsTests { #expect(battery.low24h == nil) } + @Test+ func decodeBatteryInfoWithCantEmptyBeforeOffpeakTrue() throws {+ let json = """+ {+ "capacityKwh": 13.34,+ "cutoffPercent": 5,+ "estimatedCutoffTime": null,+ "cantEmptyBeforeOffpeak": true,+ "low24h": null+ }+ """++ let battery = try decoder.decode(BatteryInfo.self, from: Data(json.utf8))++ #expect(battery.cantEmptyBeforeOffpeak == true)+ }++ @Test+ func decodeBatteryInfoWithCantEmptyBeforeOffpeakNull() throws {+ let json = """+ {+ "capacityKwh": 13.34,+ "cutoffPercent": 5,+ "estimatedCutoffTime": null,+ "cantEmptyBeforeOffpeak": null,+ "low24h": null+ }+ """++ let battery = try decoder.decode(BatteryInfo.self, from: Data(json.utf8))++ #expect(battery.cantEmptyBeforeOffpeak == nil)+ }++ @Test+ func decodeBatteryInfoWithCantEmptyBeforeOffpeakKeyMissing() throws {+ // Forward compatibility: older server payloads omit the key entirely.+ let json = """+ {+ "capacityKwh": 13.34,+ "cutoffPercent": 5,+ "estimatedCutoffTime": null,+ "low24h": null+ }+ """++ let battery = try decoder.decode(BatteryInfo.self, from: Data(json.utf8))++ #expect(battery.cantEmptyBeforeOffpeak == nil)+ }+ @Test func decodeRollingAvgWithNullCutoff() throws { let json = """
diff --git a/Flux/Flux/Dashboard/DashboardHeroPanel.swift b/Flux/Flux/Dashboard/DashboardHeroPanel.swiftindex 6fc7b43..7a2339f 100644--- a/Flux/Flux/Dashboard/DashboardHeroPanel.swift+++ b/Flux/Flux/Dashboard/DashboardHeroPanel.swift@@ -7,6 +7,20 @@ import SwiftUI struct DashboardHeroPanel: View { let live: LiveData? let rolling15min: RollingAvg?+ let battery: BatteryInfo?+ let offpeakWindowStart: String?++ init(+ live: LiveData?,+ rolling15min: RollingAvg?,+ battery: BatteryInfo? = nil,+ offpeakWindowStart: String? = nil+ ) {+ self.live = live+ self.rolling15min = rolling15min+ self.battery = battery+ self.offpeakWindowStart = offpeakWindowStart+ } var body: some View { FluxPanel(padding: FluxTheme.Metrics.panelHeroPadding) {@@ -29,12 +43,45 @@ struct DashboardHeroPanel: View { .padding(.top, -10) .padding(.bottom, -10) - statusLine+ subline .padding(.vertical, FluxTheme.Metrics.statRowVerticalPadding) } } } + /// When the server flags the battery as unable to reach the cutoff before+ /// the next off-peak window starts, we hide the standard status line and+ /// show the indicator instead — only one is visible at a time.+ @ViewBuilder+ private var subline: some View {+ if battery?.cantEmptyBeforeOffpeak == true, let offpeakWindowStart {+ cantEmptyBeforeOffpeakIndicator(offpeakWindowStart: offpeakWindowStart)+ } else {+ statusLine+ }+ }++ private func cantEmptyBeforeOffpeakIndicator(offpeakWindowStart: String) -> some View {+ Text(Self.cantEmptyBeforeOffpeakVisibleText(offpeakWindowStart: offpeakWindowStart))+ .appFont(FluxTheme.Typography.heroSubline)+ .foregroundStyle(FluxTheme.Palette.secondaryText)+ .accessibilityLabel(+ Self.cantEmptyBeforeOffpeakAccessibilityLabel(offpeakWindowStart: offpeakWindowStart)+ )+ }++ /// Visible indicator text. `offpeakWindowStart` is already an `HH:MM`+ /// string from the wire — substituted verbatim.+ static func cantEmptyBeforeOffpeakVisibleText(offpeakWindowStart: String) -> String {+ "Won't empty before \(offpeakWindowStart)"+ }++ /// 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 {+ "Battery won't empty before off-peak at \(offpeakWindowStart)"+ }+ private var heroNumber: String { guard let soc = live?.soc else { return "—" } if soc >= 99.95 { return "100" }@@ -102,13 +149,23 @@ struct DashboardHeroPanel: View { } #if DEBUG-#Preview {+#Preview("Default + can't-empty indicator") { ZStack { FluxTheme.Palette.background.ignoresSafeArea()- DashboardHeroPanel(- live: MockFluxAPIClient.statusResponse.live,- rolling15min: MockFluxAPIClient.statusResponse.rolling15min- )+ VStack(spacing: 16) {+ DashboardHeroPanel(+ live: MockFluxAPIClient.statusResponse.live,+ rolling15min: MockFluxAPIClient.statusResponse.rolling15min,+ battery: MockFluxAPIClient.statusResponse.battery,+ offpeakWindowStart: MockFluxAPIClient.statusResponse.offpeak?.windowStart+ )+ DashboardHeroPanel(+ live: MockFluxAPIClient.statusResponseCantEmpty.live,+ rolling15min: MockFluxAPIClient.statusResponseCantEmpty.rolling15min,+ battery: MockFluxAPIClient.statusResponseCantEmpty.battery,+ offpeakWindowStart: MockFluxAPIClient.statusResponseCantEmpty.offpeak?.windowStart+ )+ } .padding() } }
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex b8b89b7..8f180d3 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -112,7 +112,9 @@ struct DashboardView: View { DashboardHeroPanel( live: viewModel.status?.live,- rolling15min: viewModel.status?.rolling15min+ rolling15min: viewModel.status?.rolling15min,+ battery: viewModel.status?.battery,+ offpeakWindowStart: viewModel.status?.offpeak?.windowStart ) LiveTrioPanel(live: viewModel.status?.live)
diff --git a/Flux/Flux/Services/MockFluxAPIClient.swift b/Flux/Flux/Services/MockFluxAPIClient.swiftindex c16438c..5465d86 100644--- a/Flux/Flux/Services/MockFluxAPIClient.swift+++ b/Flux/Flux/Services/MockFluxAPIClient.swift@@ -2,6 +2,7 @@ import FluxCore import Foundation +// swiftlint:disable:next type_body_length final actor MockFluxAPIClient: FluxAPIClient { static let preview = MockFluxAPIClient() static let previewDate = "2026-04-15"@@ -62,6 +63,24 @@ final actor MockFluxAPIClient: FluxAPIClient { note: previewNote ) + /// Variant of `statusResponse` with the server-computed+ /// `cantEmptyBeforeOffpeak` flag set to `true`. Used by the Dashboard+ /// hero preview and tests to exercise the indicator subview.+ static let statusResponseCantEmpty = StatusResponse(+ live: statusResponse.live,+ battery: BatteryInfo(+ capacityKwh: 13.3,+ cutoffPercent: 10,+ estimatedCutoffTime: "\(previewDate)T18:30:00Z",+ low24h: Low24h(soc: 38.2, timestamp: "\(previewDate)T08:45:00Z"),+ cantEmptyBeforeOffpeak: true+ ),+ rolling15min: statusResponse.rolling15min,+ offpeak: statusResponse.offpeak,+ todayEnergy: statusResponse.todayEnergy,+ note: previewNote+ )+ static let historyDays: [DayEnergy] = { guard let baseDate = DateFormatting.parseDayDate(previewDate) else { return []
diff --git a/Flux/FluxTests/DashboardHeroPanelTests.swift b/Flux/FluxTests/DashboardHeroPanelTests.swiftnew file mode 100644index 0000000..6feeeae--- /dev/null+++ b/Flux/FluxTests/DashboardHeroPanelTests.swift@@ -0,0 +1,48 @@+import FluxCore+import SwiftUI+import Testing+@testable import Flux++// The hero panel composes the "can't empty before off-peak" indicator+// alongside the existing status line. The contract worth pinning is the+// VoiceOver announcement — it is the user-facing signal the requirement+// commits to (see requirements.md §3.5). The string is sourced from+// `DashboardHeroPanel.cantEmptyBeforeOffpeakAccessibilityLabel` so both+// the view body and this test consume the same expression.+@MainActor+@Suite+struct DashboardHeroPanelTests {+ @Test+ func cantEmptyIndicatorAccessibilityLabelMatchesSpec() {+ let live = LiveData(+ ppv: 2400,+ pload: 750,+ pbat: 400,+ pgrid: -100,+ pgridSustained: false,+ soc: 62.4,+ timestamp: "2026-04-15T10:00:00Z"+ )+ let battery = BatteryInfo(+ capacityKwh: 13.3,+ cutoffPercent: 10,+ estimatedCutoffTime: nil,+ low24h: nil,+ cantEmptyBeforeOffpeak: true+ )+ // Construct the panel with the new inputs to keep the binding+ // surface under test even though the assertion is on the static+ // helper that drives the rendered accessibility label.+ _ = DashboardHeroPanel(+ live: live,+ rolling15min: nil,+ battery: battery,+ offpeakWindowStart: "23:00"+ )++ #expect(+ DashboardHeroPanel.cantEmptyBeforeOffpeakAccessibilityLabel(offpeakWindowStart: "23:00")+ == "Battery won't empty before off-peak at 23:00"+ )+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 1f05952..c419ade 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added++- **Battery Can't Empty Before Off-Peak indicator** (T-1327). The Dashboard hero now shows "Won't empty before HH:MM" when, even at a 5 kW sustained-discharge ceiling, the battery cannot reach the 5 % cutoff before the next off-peak window opens — a `pbat`-independent answer to "is there any way?" rather than "given current rate, when?". The check is server-computed: `/status` carries a new nullable `battery.cantEmptyBeforeOffpeak` (true or null, encoded like `estimatedCutoffTime`), evaluated inside the existing `liveFresh` branch and reusing `derivedstats.ParseOffpeakWindow` (so the no-midnight-spanning-window limit carries over). Swift `BatteryInfo` gains a defaulted optional so the five existing constructors compile unchanged; `DashboardHeroPanel` composes a new `secondaryText` subview alongside the existing status line (no `Mode` enum changes) with a literal VoiceOver label `"Battery won't empty before off-peak at <HH:MM>"`. iOS and macOS share the panel via FluxCore.+ ## [1.3] - 2026-05-21 ### Added
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex a3450a5..3a1c31b 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -21,6 +21,7 @@ | [Solar by Block](#solar-by-block) | 2026-05-09 | Done | Adds solar production (kWh) per daylight block (morning peak, off-peak, afternoon peak) on the Day Detail five-block panel. Backend integrates `ppv` per block in `derivedstats.Blocks()`, persists via the existing poller summarisation, and ships a one-shot backfill CLI (`cmd/backfill-solar`) that patches `solarKwh` in place without altering historic `totalKwh`. iOS renders the per-block kWh value in amber next to the existing usage figure on daylight rows. T-1162. | | [Stat Comparisons](#stat-comparisons) | 2026-05-10 | Done | Opt-in Compare toggle on Day Detail with a period chip (Yesterday / 7 days ago) that renders an absolute-kWh delta as a sub-line under each SummaryBlock row and DayInFiveBlocksPanel value, in the same lighter/smaller treatment as the five-block time-range captions. Client-only — comparison day fetched via the existing `/day` endpoint; backend untouched. T-1161. | | [Zoom in on Graphs](#zoom-in-on-graphs) | 2026-05-12 | Done | Tap-to-enlarge affordance on the five active chart cards (History solar / grid usage / daily usage; Day Detail power and battery combined). iOS uses a `fullScreenCover` with a UIKit bridge requesting landscape (orientation lock restored on every dismissal path, portrait fallback if denied). macOS opens a separate `WindowGroup(for: ChartKind)` per chart, identity by view type only — re-expand brings the existing window forward; `.defaultLaunchBehavior(.suppressed)` prevents launch-time restoration; `.windowManagerRole(.associated)` keeps the Window menu honest. Selection interactivity preserved: History reuses `historySelectionOverlay` with a real `HistoryDragGate`; Day Detail uses `XSelectionQuiescenceGate` (400 ms quiet window) since `chartXSelection` has no lifecycle. Day Detail `selectedDate` lifted from the two chart views into `DayDetailView` so inline and enlarged share state. macOS data flows via a per-scene `ChartSceneObserver` (60 s polling) keyed by `ChartScopeRegistry`, not by lifting per-screen view models. iOS sheet state survives backgrounding via `@SceneStorage`. T-1215. |+| [Battery Can't Empty Before Off-Peak](#battery-cant-empty-before-off-peak) | 2026-05-23 | Done | Dashboard hero shows a "won't empty before HH:MM" indicator when, even at the 5 kW max sustained discharge rate, the battery cannot reach the 5 % cutoff before the next off-peak window opens. New `pbat`-independent boolean `cantEmptyBeforeOffpeak` on `battery` in `/status`, computed inside the existing `liveFresh` branch and reusing `derivedstats.ParseOffpeakWindow`. Swift `BatteryInfo` gains an optional `Bool` with a defaulted memberwise init so existing constructors keep compiling; `DashboardHeroPanel` swaps in a new `secondaryText` subview (no `Mode` enum changes) with a literal VoiceOver label `"Battery won't empty before off-peak at <HH:MM>"`. iOS+macOS shared. Inherits the no-midnight-spanning-window limit from `ParseOffpeakWindow`. T-1327. | | [SoC Alerts](#soc-alerts) | 2026-05-19 | Done | User-defined battery state-of-charge alerts. Per-device rules pair a percentage threshold with a daily time window (HH:MM device-local, cross-midnight supported); the Go poller evaluates rules each 10s cycle and dispatches APNs pushes via `sideshow/apns2` (entitlements already provisioned). Three new DynamoDB tables (`flux-devices`, `flux-soc-rules`, `flux-soc-fire-state`). Fires once per (rule, local-window-start day) — collapse-id `base64url(SHA256(deviceId|ruleId|date))[:22]` keeps APNs under its 64-byte cap. In-process previous-SoC comparator keyed by `(deviceRule, windowStartDate)` and tagged with rule `UpdatedAt` so Lambda rule edits propagate without IPC. Pushes go through a 64-deep / 4-worker bounded queue so APNs RTT cannot stall the live-poll cadence. Lambda routing migrates to `http.ServeMux` (Go 1.22+ path params). Stable device identifier is a UUID in container `UserDefaults` (not Keychain, not app-group — so reinstall resets). iOS/macOS Settings → Alerts list/add/edit/delete sheet driven by a new `FluxCore/Notifications/` module; `FluxiOSAppDelegate` moves to its own file alongside new APNs callbacks. Orphan device rows (no successful registration in 30 days) are cascade-deleted by the existing midnight pass with conditional delete to dodge the re-registration race. T-1288. | ---@@ -215,3 +216,12 @@ User-defined battery state-of-charge alerts. Each device manages up to 10 rules; - [prerequisites.md](soc-alerts/prerequisites.md) - [requirements.md](soc-alerts/requirements.md) - [tasks.md](soc-alerts/tasks.md)++## Battery Can't Empty Before Off-Peak++Dashboard hero shows a "won't empty before HH:MM" indicator when, even at the 5 kW max sustained discharge rate, the battery cannot reach the 5 % cutoff before the next off-peak window opens. New `pbat`-independent boolean `cantEmptyBeforeOffpeak` on `battery` in `/status`, computed inside the existing `liveFresh` branch and reusing `derivedstats.ParseOffpeakWindow`. Swift `BatteryInfo` gains an optional `Bool` with a defaulted memberwise init so existing constructors keep compiling; `DashboardHeroPanel` swaps in a new `secondaryText` subview (no `Mode` enum changes) with a literal VoiceOver label `"Battery won't empty before off-peak at <HH:MM>"`. iOS+macOS shared via FluxCore. Inherits the no-midnight-spanning-window limit from `ParseOffpeakWindow`. T-1327.++- [decision_log.md](battery-cant-empty-before-offpeak/decision_log.md)+- [design.md](battery-cant-empty-before-offpeak/design.md)+- [requirements.md](battery-cant-empty-before-offpeak/requirements.md)+- [tasks.md](battery-cant-empty-before-offpeak/tasks.md)
diff --git a/specs/battery-cant-empty-before-offpeak/requirements.md b/specs/battery-cant-empty-before-offpeak/requirements.mdnew file mode 100644index 0000000..93146eb--- /dev/null+++ b/specs/battery-cant-empty-before-offpeak/requirements.md@@ -0,0 +1,66 @@+# Requirements: Battery Can't Empty Before Off-Peak++**Transit ticket:** T-1327++## Introduction++When the battery has too much stored energy to physically discharge to the cutoff floor before the next off-peak window starts, the existing `estimatedCutoffTime` is misleading — it extrapolates from live `pbat`, which can be well below the inverter's true ceiling. This feature surfaces an indicator on the Dashboard hero whenever, even at the maximum sustained discharge rate of 5 kW, the battery cannot reach the 5 % cutoff before the next off-peak window opens. The check is `pbat`-independent: it answers "is there any way?" rather than "given current rate, when?".++## Glossary++- **Cutoff SoC**: 5 % (existing `cutoffPercent` constant in `internal/api/status.go`).+- **Max discharge rate**: 5 kW sustained (new Go constant introduced by this feature).+- **Off-peak start (Sydney local)**: the next `offpeak.windowStart` boundary as computed today by `nextOffpeakStart()` in `internal/api/compute.go` (handles same-day or next-day rollover).+- **Live freshness gate**: the existing `liveFresh` check in `handleStatus` (latest reading within `liveDataStalenessThreshold = 90 s`).+- **Capacity (kWh)**: `battery.capacityKwh` as already exposed by `/status`, which falls back to `fallbackCapacityKwh = 13.34` when system metadata is missing.++## Non-Goals++- Configurable discharge ceiling — 5 kW is a baked-in Go constant for this feature.+- Modelling load or solar effects on discharge — the check is a battery-only, ceiling-based physics check.+- Modelling inverter derating at high SoC or high temperature.+- History or Day Detail surfaces — the indicator is Dashboard-only.+- SoC alerts integration / push notifications.+- Showing the indicator while an off-peak window is currently active.+- Supporting off-peak windows that cross midnight — the existing `parseOffpeakWindow` rejects `start ≥ end` and this feature inherits that limit.+- Mirroring the flag on `rolling15min` — it would always equal the hero flag.++## Requirements++### 1. Best-Case Discharge Check++**User Story:** As a Flux user, I want to know when my battery is so full that it cannot reach the cutoff before off-peak starts, so that I can take action (e.g. manual export, higher load) instead of relying on a misleading live cutoff estimate.++**Acceptance Criteria:**++1. <a name="1.1"></a>The system SHALL evaluate, at the moment `/status` is served, whether the battery can reach the cutoff SoC before the next off-peak window opens, assuming a constant max discharge rate from `now`. +2. <a name="1.2"></a>The check SHALL be `pbat`-independent: it MUST NOT require the battery to be currently discharging. +3. <a name="1.3"></a>The check SHALL use the same `battery.capacityKwh` value that the `/status` response carries (i.e. system value when present, else the documented fallback). +4. <a name="1.4"></a>The check SHALL use the off-peak start computed by the existing `nextOffpeakStart()` helper (Sydney local, today or next day as appropriate). +5. <a name="1.5"></a>The flag SHALL be false WHEN current SoC is at or below the cutoff SoC. +6. <a name="1.6"></a>The flag SHALL be false WHEN `now` falls inside the configured off-peak window (i.e. the window is currently active). +7. <a name="1.7"></a>The flag SHALL be false WHEN the live freshness gate fails (`!liveFresh`), since no current SoC is trustworthy. +8. <a name="1.8"></a>The flag SHALL be false WHEN the off-peak window configuration is missing or invalid (i.e. `nextOffpeakStart` reports no boundary). ++### 2. `/status` API Surface++**User Story:** As a Dashboard view, I want a single boolean from the API saying "battery can't empty before off-peak", so that I can render the indicator without re-implementing the math in Swift.++**Acceptance Criteria:**++1. <a name="2.1"></a>The `/status` response SHALL include a new boolean field on the `battery` object representing the "can't empty before off-peak" condition. +2. <a name="2.2"></a>The field SHALL be encoded as a nullable Go pointer (no `omitempty`) so that JSON emits `null` when the flag is false and `true` when it holds — matching the existing `estimatedCutoffTime` encoding. `false` SHALL never be emitted. +3. <a name="2.3"></a>The field name SHALL be stable, snake-free, camelCase, and SHALL NOT collide with `estimatedCutoffTime`, `low24h`, `capacityKwh`, or `cutoffPercent`. +4. <a name="2.4"></a>Unit tests SHALL cover at least: SoC just above cutoff with a short window-to-open (flag true), SoC well above cutoff with a long window-to-open (flag false), SoC at cutoff (false), SoC below cutoff (false), off-peak active (false), `!liveFresh` (false), missing off-peak config (false), `now == nextOffpeakStart` (false — boundary equality), system-record-missing capacity (uses fallback), and a Sydney DST transition day. ++### 3. Dashboard Hero Indicator++**User Story:** As a Flux user looking at the Dashboard, I want the hero panel to clearly tell me the battery won't drain before off-peak, so that I notice the situation immediately.++**Acceptance Criteria:**++1. <a name="3.1"></a>The Dashboard hero status line SHALL show, at any given time, exactly one of: the existing cutoff-time messaging ("empty by HH:MM") OR the new "won't empty before HH:MM" messaging — never both. +2. <a name="3.2"></a>The indicator SHALL communicate that the battery cannot reach the cutoff before off-peak begins, and SHALL surface the off-peak start time (HH:MM, Sydney local) using the existing `offpeak.windowStart` value already in `/status`. +3. <a name="3.3"></a>WHEN the API flag is true, the hero status line SHALL render the new messaging. WHEN the flag is false or absent, the hero SHALL render the existing status line exactly as today. +4. <a name="3.4"></a>The indicator SHALL render identically on iOS and macOS (the hero panel is shared by both targets). +5. <a name="3.5"></a>The indicator SHALL be accessible via VoiceOver, exposing the same wording (including the off-peak start time) it shows visually.
diff --git a/specs/battery-cant-empty-before-offpeak/design.md b/specs/battery-cant-empty-before-offpeak/design.mdnew file mode 100644index 0000000..46e6aab--- /dev/null+++ b/specs/battery-cant-empty-before-offpeak/design.md@@ -0,0 +1,203 @@+# Design: Battery Can't Empty Before Off-Peak++**Transit ticket:** T-1327+**Related docs:** [requirements.md](./requirements.md), [decision_log.md](./decision_log.md)++## Overview++Add a `pbat`-independent server-computed boolean to `/status` that says "even at 5 kW sustained discharge from `now`, the battery cannot reach the 5 % cutoff before the next off-peak window starts." On the Dashboard hero, an indicator subview composes alongside the existing status line whenever the flag is true; the existing line is hidden when the indicator shows (only one is visible at a time).++## Architecture++### Backend integration points (Go)++| File | Change |+|---|---|+| `internal/api/status.go` | Add `maxDischargeKW = 5.0` to the constants block (alongside `cutoffPercent`, `fallbackCapacityKwh`). Inside the existing `liveFresh` branch — right after `EstimatedCutoff` is set — call the new helper and assign `battery.CantEmptyBeforeOffpeak`. |+| `internal/api/compute.go` | Add the helper described in §Helper contract. Add a small `withinOffpeakWindow` predicate that delegates parsing to the existing exported `derivedstats.ParseOffpeakWindow` (no new HH:MM parser). |+| `internal/api/response.go` | Add `CantEmptyBeforeOffpeak *bool \`json:"cantEmptyBeforeOffpeak"\`` to `BatteryInfo`. Pointer, no `omitempty` — JSON emits `null` when nil, matching the existing `estimatedCutoffTime` encoding (`response.go:32`). |++### Helper contract++To keep the call site readable and the test cases obvious, the helper takes an input struct:++```go+type cantEmptyInput struct {+ Soc, CapacityKwh float64+ Now, NextOpStart time.Time+ HasBoundary bool+ WithinOffpeakWindow bool+}++// computeCantEmptyBeforeOffpeak returns &true when the battery cannot reach+// cutoffPercent before NextOpStart at maxDischargeKW sustained. Returns nil+// when the condition does not hold or inputs make the question meaningless.+// Reads cutoffPercent and maxDischargeKW as package-level constants.+func computeCantEmptyBeforeOffpeak(in cantEmptyInput) *bool+```++Branches (all return `nil`): `!in.HasBoundary` · `in.WithinOffpeakWindow` · `in.Soc <= cutoffPercent` · `in.CapacityKwh <= 0`.++Math (only reached after the above guards):++- `remainingKwh = (Soc - cutoffPercent) / 100 * CapacityKwh`+- `requiredHours = remainingKwh / maxDischargeKW`+- Return `&true` iff `Now.Add(requiredHours * time.Hour).After(NextOpStart)`. `After` is strict, so equality returns `nil` — matches AC [2.4] "now == nextOffpeakStart".++`maxDischargeKW` is a package constant, not a parameter — there is one max, set in code (Decision 1).++### Active-window check++`internal/derivedstats/offpeak.go:9` already exports `ParseOffpeakWindow`. The `isOffpeak` helper in that file is unexported but doing the active-window check inside `internal/api/compute.go` against `now` (already in Sydney local in `handleStatus`) avoids both re-parsing and a cross-package export:++```go+func withinOffpeakWindow(now time.Time, offpeakStart, offpeakEnd string) bool {+ startMin, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)+ if !ok {+ return false+ }+ minuteOfDay := now.Hour()*60 + now.Minute()+ return minuteOfDay >= startMin && minuteOfDay < endMin+}+```++Same single-source-of-truth invariant as Decision 8 (midnight-spanning windows still rejected via `ParseOffpeakWindow`).++### Wire path (existing → new)++```+handleStatus+ ├─ liveFresh ──┐+ │ ├─ existing: computeCutoffTime → battery.EstimatedCutoff+ │ └─ NEW: computeCantEmptyBeforeOffpeak → battery.CantEmptyBeforeOffpeak+ └─ !liveFresh: neither field is set (both stay nil — null in JSON)+```++Decision 6 stands — flag does not appear on `rolling15min`.++### Swift model integration points++| File | Change |+|---|---|+| `Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift` | Add `public let cantEmptyBeforeOffpeak: Bool?` to `BatteryInfo`. Memberwise initialiser gains `cantEmptyBeforeOffpeak: Bool? = nil` **with a default** so the five existing constructors (see §Pattern extension audit) keep compiling without edits. |+| `Flux/Flux/Services/MockFluxAPIClient.swift` | Add a second status fixture (e.g. `statusResponseCantEmpty`) that sets the flag to `true`, for preview/test coverage of the indicator state. The default fixture keeps the field `nil` to verify forward compatibility. |+| `Flux/FluxWidgets/WidgetFixtures.swift` | No edit needed thanks to the default; if a fixture explicitly wants the flag set, update locally. |++### Dashboard hero rendering++`Flux/Flux/Dashboard/DashboardHeroPanel.swift` today composes a single `statusLine` view branched by a `Mode` enum that reads `rolling15min.estimatedCutoffTime`. Changes:++1. `DashboardHeroPanel` gains two new inputs: `let battery: BatteryInfo?` and `let offpeakWindowStart: String?` (passed from `DashboardView.swift:113-116`).+2. Add a private subview `private var cantEmptyBeforeOffpeakIndicator: some View` that renders `"Won't empty before HH:MM"` using `FluxTheme.Typography.heroSubline` and `FluxTheme.Palette.secondaryText` (default subline styling — no `Palette.amber` reuse). HH:MM is read directly from the already-`HH:MM`-formatted `offpeakWindowStart` (no re-parsing needed).+3. `body` swaps the existing `statusLine` for the indicator when `battery?.cantEmptyBeforeOffpeak == true && offpeakWindowStart != nil`. The existing `Mode` enum and its branches are **unchanged**; the `.idle` "battery holding" copy is preserved.+4. `.accessibilityElement(children: .combine)` on the panel; the indicator subview sets `.accessibilityLabel("Battery won't empty before off-peak at HH:MM")` with HH:MM expanded (e.g. "23:00" → "23:00"). VoiceOver string is asserted by a Swift test (see §Testing).+5. When the flag is true but `offpeakWindowStart` is nil (defensive — server should always emit one when the flag is set), fall back to rendering the existing `statusLine`.++### Pattern extension audit++`BatteryInfo` is constructed via its public memberwise init in these places. Because the new property has `= nil` default, none of them require source changes:++| Call site | Constructor? | Action |+|---|---|---|+| `Flux/Packages/FluxCore/Tests/FluxCoreTests/WidgetAccessibilityTests.swift:19` | yes | none (default) |+| `Flux/Packages/FluxCore/Tests/FluxCoreTests/StatusSnapshotEnvelopeTests.swift:18` | yes | none (default) |+| `Flux/Packages/FluxCore/Tests/FluxCoreTests/StatusTimelineLogicTests.swift:25` | yes | none (default) |+| `Flux/Packages/FluxCore/Tests/FluxCoreTests/RelevanceScoringTests.swift:21` | yes | none (default) |+| `Flux/Packages/FluxCore/Sources/FluxCore/Widget/StatusTimelineLogic.swift:167` | yes | none (default) |+| `Flux/Flux/Services/MockFluxAPIClient.swift` | yes | add second fixture with flag `true` for previews |+| `Flux/FluxWidgets/WidgetFixtures.swift` | yes | none (default) |+| `Flux/FluxWidgets/Views/Shared/StatusEntry+WidgetColors.swift:48` | no (reads `rolling15min`) | none — widget is out of scope |+| `internal/api/status.go` | n/a (writer) | new write site (see Backend section) |++The default-value approach preserves source compatibility across the whole codebase. Without it, all five Swift constructors would need parallel edits.++## Data Models++Only one schema change: `BatteryInfo` gains a nullable boolean.++```jsonc+{+ "battery": {+ "capacityKwh": 13.34,+ "cutoffPercent": 5,+ "estimatedCutoffTime": null, // existing, may be null+ "cantEmptyBeforeOffpeak": true, // new, true | null+ "low24h": { /* ... */ }+ }+}+```++Wire contract (clarifies requirements AC [2.2]): the field is `null` when the condition is false, `true` when it holds. `false` is never emitted. Mirrors `estimatedCutoffTime`.++## Error Handling++No new failure modes. Every "false-equivalent" case (no boundary, window active, `!liveFresh`, soc at/below cutoff, capacity ≤ 0) yields `nil` and surfaces as JSON `null`. Swift decoding handles `null` → `nil` via `Bool?`.++## Testing Strategy++### Go: `internal/api/compute_test.go`++Map-based table tests for `computeCantEmptyBeforeOffpeak` and `withinOffpeakWindow`.++`computeCantEmptyBeforeOffpeak` cases:++| Name | soc | capacity | now → opStart | hasBoundary | windowActive | want |+|---|---|---|---|---|---|---|+| `soc just above cutoff, short window` | 6 | 13.34 | +1min | true | false | `&true` |+| `soc just above cutoff, long window` | 6 | 13.34 | +24h | true | false | `nil` |+| `soc well above cutoff, short window` | 90 | 13.34 | +30min | true | false | `&true` |+| `soc well above cutoff, long window` | 90 | 13.34 | +48h | true | false | `nil` |+| `soc exactly at cutoff` | 5 | 13.34 | +1h | true | false | `nil` |+| `soc below cutoff` | 3 | 13.34 | +1h | true | false | `nil` |+| `window currently active` | 80 | 13.34 | (any) | true | true | `nil` |+| `no boundary` | 80 | 13.34 | (any) | false | false | `nil` |+| `zero capacity` | 80 | 0 | +1h | true | false | `nil` |+| `boundary equality` | derived* | 13.34 | exact | true | false | `nil` |++\* For boundary equality: pick `Soc = cutoffPercent + (maxDischargeKW * 1h / 13.34) * 100` so `requiredHours == 1h` exactly, set `opStart = now + 1h`, expect `nil` because `After` is strict.++`withinOffpeakWindow` cases:++| Name | now (Sydney) | window | want |+|---|---|---|---|+| `before window` | 10:59 | 11:00-14:00 | false |+| `at start` | 11:00 | 11:00-14:00 | true |+| `mid-window` | 12:30 | 11:00-14:00 | true |+| `at end (exclusive)` | 14:00 | 11:00-14:00 | false |+| `after window` | 14:30 | 11:00-14:00 | false |+| `unparseable strings` | 12:00 | "x" / "y" | false |++### Go: `internal/api/status_test.go` (handler integration)++Add cases asserting the marshalled response includes/excludes the new field correctly:++1. `liveFresh && condition true` → `"cantEmptyBeforeOffpeak": true` in JSON.+2. `liveFresh && condition false` → `"cantEmptyBeforeOffpeak": null` in JSON.+3. `!liveFresh` → `"cantEmptyBeforeOffpeak": null` regardless of inputs (mirrors `estimatedCutoffTime` behaviour).+4. Off-peak config empty (`OFFPEAK_START=""`, `OFFPEAK_END=""`) → `null`.+5. **Sydney DST start (concrete date):** `nowFunc` returns 2026-10-04 01:30 Sydney local (the morning the clocks jump 02:00 → 03:00). Off-peak 11:00-14:00, Soc 60, capacity 13.34. Assert flag set/unset consistently with `nextOffpeakStart` advancing through the DST gap. (The existing `nextOffpeakStart` does the local-time math; this test pins the integration.)+6. Fallback capacity path: system record missing → flag is computed against `fallbackCapacityKwh = 13.34`.++### Swift: `Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift`++Decoding tests:++1. JSON with `"cantEmptyBeforeOffpeak": true` → decoded `Bool? == true`.+2. JSON with `"cantEmptyBeforeOffpeak": null` → decoded `nil`.+3. JSON without the key (older server payload) → decoded `nil` (forward compatibility).++### Swift: `DashboardHeroPanel` accessibility + preview tests++1. Add an XCTest (or Swift Testing) case in `FluxTests/` that constructs the panel with `battery.cantEmptyBeforeOffpeak == true`, `offpeakWindowStart == "23:00"`, and asserts the accessibility label of the indicator is exactly `"Battery won't empty before off-peak at 23:00"`. Covers [3.5].+2. Add the `#Preview` block in `DashboardHeroPanel.swift` to render both states (flag nil vs flag true) side by side, so the shared iOS+macOS Dashboard hero ([3.4]) can be visually verified in both schemes during code review. No additional macOS-specific runtime divergence exists because the panel uses platform-agnostic SwiftUI primitives.++### Property-based testing++Not applicable. The math is a single closed-form expression with fully enumerated branches.++## Out-of-scope follow-ups (noted, not implemented)++- Widget surfaces (medium/large) — Non-Goal.+- SoC push alerts integrating the flag — Non-Goal.+- Inverter derating curve — Non-Goal (Decision 1 consequence).+- Midnight-spanning off-peak windows — Non-Goal (Decision 8, inherited from `ParseOffpeakWindow`).
diff --git a/specs/battery-cant-empty-before-offpeak/decision_log.md b/specs/battery-cant-empty-before-offpeak/decision_log.mdnew file mode 100644index 0000000..b7c2a36--- /dev/null+++ b/specs/battery-cant-empty-before-offpeak/decision_log.md@@ -0,0 +1,276 @@+# Decision Log: Battery Can't Empty Before Off-Peak++## Decision 1: Use a fixed 5 kW discharge ceiling as a Go constant++**Date**: 2026-05-23+**Status**: accepted++### Context++The new indicator answers "can the battery physically reach the 5 % cutoff before off-peak starts?". This requires assuming a maximum sustained discharge rate. AlphaESS inverters are nominally capped at 5 kW per the system specification, and there is no current need to vary this per-deployment.++### Decision++Bake the 5 kW max discharge rate into the Go backend as a single named constant (`maxDischargeKW = 5.0`).++### Rationale++The value is a hardware constant tied to the AlphaESS inverter. Treating it as a constant matches how `cutoffPercent = 5` is handled today and avoids the operational overhead of another SSM parameter for a value that will not change without a hardware swap.++### Alternatives Considered++- **Configurable SSM parameter (`/flux/max-discharge-kw`)**: Allows tuning without redeploy — Rejected because there is no realistic scenario in which the value needs to change for the current deployment.+- **Read from AlphaESS system metadata**: Pull the cap from the device API if available — Rejected because the field is not currently consumed anywhere and adds dependencies for no behavioural gain.++### Consequences++**Positive:**++- Simple, testable, no infra changes.+- Mirrors the existing `cutoffPercent` pattern.++**Negative:**++- Changing the value later requires a code change and redeploy.++---++## Decision 2: `pbat`-independent ceiling check (battery output only)++**Date**: 2026-05-23+**Status**: accepted++### Context++"Can't empty" can mean different things. With load and solar, the effective discharge rate is variable. The user wants a stable signal that does not move with live `pbat`: even at the inverter ceiling, the battery still cannot reach cutoff in time.++### Decision++Compute the check using battery output only at the constant 5 kW ceiling. Ignore live `pbat`, household load, and solar. (Note: household load actually empties the battery *faster* — so this is not strictly a "best case" rate, but it is the `pbat`-independent ceiling the user explicitly asked for.)++### Rationale++The user explicitly framed the question as "even if it suddenly discharges at 5 kW, the battery percentage can't go below 5 %". Coupling to load would reintroduce the live-rate volatility the existing `estimatedCutoffTime` already provides; the point of this signal is stability.++### Alternatives Considered++- **Battery + live load**: Adds household load to the discharge rate — Rejected because it answers a different question (closer to existing cutoff prediction) and reintroduces volatility.++### Consequences++**Positive:**++- Stable, easy to reason about, easy to test.+- Independent of live `pbat`; flag is meaningful even when the battery is currently charging or idle.++**Negative:**++- Ignores load that would empty the battery faster — accepted because the goal is a `pbat`-independent ceiling, not a realistic forecast.+- Ignores inverter derating at high SoC/temperature — accepted because the spec is "can it physically?" at the inverter nameplate.++---++## Decision 3: Compute server-side as a new boolean on `battery`++**Date**: 2026-05-23+**Status**: accepted++### Context++The math is straightforward but requires knowing the off-peak start time and SoC/capacity together. The off-peak window is currently a server-side construct (env-passed SSM values) and the existing cutoff estimate is also computed server-side.++### Decision++Compute the flag in the Lambda `/status` handler and expose it as a new optional boolean on the `battery` object.++### Rationale++Keeps the off-peak time-zone math in one place (Go), matches the existing `estimatedCutoffTime` pattern, and keeps the Swift Dashboard view a thin renderer of API truth.++### Alternatives Considered++- **Client-side compute from existing fields**: Avoid API changes by deriving the flag in Swift — Rejected because off-peak windowStart/windowEnd are not currently in the `/status` payload, so the client would need new fields anyway, and the time-zone parsing duplicates Go logic.++### Consequences++**Positive:**++- Single source of truth.+- Easy to unit-test in Go.++**Negative:**++- Tiny payload increase.++---++## Decision 4: Show on Dashboard hero, not BatteryBlock or SoC alerts++**Date**: 2026-05-23+**Status**: accepted++### Context++Three surfaces were considered: a row in `BatteryBlock`, the Dashboard hero panel near the existing cutoff time, or a SoC push alert.++### Decision++Render the indicator on the Dashboard hero, near the existing `estimatedCutoffTime` row.++### Rationale++The signal contradicts the existing cutoff-time prediction in a way the user needs to notice immediately, so placement next to that prediction is the most coherent UX. SoC alerts and `BatteryBlock` are deferred.++### Alternatives Considered++- **BatteryBlock row**: Less prominent and visually separated from the cutoff prediction it contradicts — Rejected as too easy to miss.+- **SoC alert (push notification)**: Adds infra and decision noise — Rejected as out of scope for the first cut.++### Consequences++**Positive:**++- Surface is shared between iOS and macOS via FluxCore.+- Sits next to the prediction it qualifies, so the user reads them together.++**Negative:**++- Hero space is tight; the indicator must be compact.++---++## Decision 5: Replace the cutoff-time row instead of showing both++**Date**: 2026-05-23+**Status**: accepted++### Context++When the new flag is true, the existing `estimatedCutoffTime` extrapolation is misleading — it claims a cutoff time that physics says cannot be reached before off-peak. Both rows would contradict each other.++### Decision++When the flag is true, the Dashboard hero replaces the `estimatedCutoffTime` row with the new indicator. The cutoff time row is not shown alongside.++### Rationale++The indicator carries the same intent ("when will the battery hit cutoff?") with a more truthful answer ("it won't, before off-peak"). Showing both would invite the user to compare two readings that disagree.++### Alternatives Considered++- **Show both rows**: Indicator under or beside the cutoff time — Rejected because the two readings are mutually contradictory.+- **Hide the cutoff row with no replacement**: Drops information silently — Rejected because the user loses the off-peak context the indicator provides.++### Consequences++**Positive:**++- Single, internally consistent hero state.+- The off-peak start time travels with the indicator so the user still has temporal context.++**Negative:**++- The replacement row must be designed compactly to fit the hero without layout shifts.++---++## Decision 6: Flag is hero-only; do not duplicate on `rolling15min`++**Date**: 2026-05-23+**Status**: accepted++### Context++`/status` exposes both `battery.estimatedCutoffTime` (live `pbat`) and `rolling15min.estimatedCutoffTime` (15-min average `pbat`). Both could carry the new flag.++### Decision++The flag lives only on the `battery` object and only affects the hero row. `rolling15min` is unchanged.++### Rationale++The physics check is independent of `pbat`, so the result would be identical on both blocks — the duplication adds API surface for no UI consumer today.++### Alternatives Considered++- **Mirror on `rolling15min`**: API symmetry between the two cutoff blocks — Rejected because nothing renders `rolling15min` warnings and the value would always equal the hero flag.++### Consequences++**Positive:**++- Smaller API footprint.+- One place to change if the math evolves.++**Negative:**++- Slight asymmetry between the two cutoff blocks in the JSON.++---++## Decision 7: Suppress the flag when the live freshness gate fails++**Date**: 2026-05-23+**Status**: accepted++### Context++`/status` already gates `estimatedCutoffTime` on `liveFresh` (latest reading within 90 s). Without a recent SoC, any flag would be derived from a stale or absent value.++### Decision++The flag is computed and exposed only when `liveFresh` is true. Otherwise it is omitted (false).++### Rationale++Consistency with `estimatedCutoffTime`, and the underlying SoC value is the only honest input — a 10-minute-old SoC could make the flag flip when nothing has actually changed.++### Alternatives Considered++- **Compute against latest available SoC regardless of freshness**: Risks misleading UI during AlphaESS outages — Rejected.++### Consequences++**Positive:**++- One coherent freshness contract across hero fields.+- Avoids surprise flag flips during outages.++**Negative:**++- During outages, the user sees neither cutoff time nor warning — but the hero already degrades when `!liveFresh`, so this matches existing UX.++---++## Decision 8: Inherit the off-peak window limitations++**Date**: 2026-05-23+**Status**: accepted++### Context++`parseOffpeakWindow` (`internal/derivedstats/offpeak.go`) rejects windows where `start ≥ end`, so midnight-spanning off-peak windows are not supported anywhere in Flux today. `nextOffpeakStart` is also the only off-peak rollover helper.++### Decision++This feature reuses `nextOffpeakStart` directly and inherits its limitations: no support for midnight-spanning windows, no parallel rollover logic.++### Rationale++Reusing existing logic keeps a single source of truth for off-peak time math; expanding support is out of scope and would touch multiple unrelated features.++### Alternatives Considered++- **Add midnight-spanning support inside this feature**: Would split off-peak logic across two helpers — Rejected as a separate concern.++### Consequences++**Positive:**++- No drift between this feature and existing cutoff logic.++**Negative:**++- If a deployment ever needs a midnight-spanning window, this feature will silently not flag — explicitly documented as a Non-Goal.++---
diff --git a/specs/battery-cant-empty-before-offpeak/tasks.md b/specs/battery-cant-empty-before-offpeak/tasks.mdnew file mode 100644index 0000000..642766e--- /dev/null+++ b/specs/battery-cant-empty-before-offpeak/tasks.md@@ -0,0 +1,95 @@+---+references:+ - specs/battery-cant-empty-before-offpeak/requirements.md+ - specs/battery-cant-empty-before-offpeak/design.md+ - specs/battery-cant-empty-before-offpeak/decision_log.md+metadata:+ transit: T-1327+---+# Tasks: Battery Can't Empty Before Off-Peak++## Backend++- [x] 1. Backend (Go) — write helper tests (red) <!-- id:p1ve881 -->+ - Add `internal/api/compute_test.go` table tests (map-based, `t.Run`) per design.md §Testing Strategy.+ - `withinOffpeakWindow`: 5 cases — before window, at start, mid-window, at end (exclusive), after window, plus unparseable strings.+ - `computeCantEmptyBeforeOffpeak`: 10 cases — short window × low soc, long window × low soc, short window × high soc, long window × high soc, soc at cutoff, soc below cutoff, window currently active, no boundary, zero capacity, boundary equality (now + requiredHours == nextOpStart).+ - Tests fail initially because both functions are undefined.+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.8](requirements.md#1.8)++- [x] 2. Backend (Go) — implement helpers (green) <!-- id:p1ve882 -->+ - `internal/api/status.go`: add `maxDischargeKW = 5.0` to the constants block next to `cutoffPercent` and `fallbackCapacityKwh`.+ - `internal/api/compute.go`: add `withinOffpeakWindow(now time.Time, offpeakStart, offpeakEnd string) bool` that delegates HH:MM parsing to `derivedstats.ParseOffpeakWindow` (no new parser).+ - `internal/api/compute.go`: add `cantEmptyInput` struct and `computeCantEmptyBeforeOffpeak(in cantEmptyInput) *bool`. Returns nil when `!HasBoundary`, `WithinOffpeakWindow`, `Soc <= cutoffPercent`, or `CapacityKwh <= 0`.+ - Otherwise computes `requiredHours = (Soc - cutoffPercent)/100 * CapacityKwh / maxDischargeKW` and returns `&true` iff `Now.Add(requiredHours * time.Hour).After(NextOpStart)` (strict comparison — equality returns nil).+ - Blocked-by: p1ve881 (Backend (Go) — write helper tests (red))+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.8](requirements.md#1.8)++- [x] 3. Backend (Go) — handler integration tests (red) <!-- id:p1ve883 -->+ - Extend `internal/api/status_test.go` with cases asserting JSON output of `/status`.+ - (a) `liveFresh` + condition true → `cantEmptyBeforeOffpeak: true`.+ - (b) `liveFresh` + condition false → JSON `null`.+ - (c) `!liveFresh` → `null` regardless.+ - (d) `OFFPEAK_START`/`OFFPEAK_END` empty → `null`.+ - (e) `nowFunc` pinned to `2026-10-04 01:30 Australia/Sydney` (DST start), window 11:00-14:00, Soc 60, capacity 13.34, assert flag tracks `nextOffpeakStart` advancing through the DST gap.+ - (f) System record missing → flag uses `fallbackCapacityKwh = 13.34`.+ - Tests fail because the field is not yet on `BatteryInfo`.+ - Blocked-by: p1ve882 (Backend (Go) — implement helpers (green))+ - Stream: 1+ - Requirements: [1.3](requirements.md#1.3), [1.7](requirements.md#1.7), [2.4](requirements.md#2.4)++- [x] 4. Backend (Go) — add field + wire handler (green) <!-- id:p1ve884 -->+ - `internal/api/response.go`: add `CantEmptyBeforeOffpeak *bool` JSON-tagged `cantEmptyBeforeOffpeak` to `BatteryInfo` (pointer, no `omitempty`, mirrors `EstimatedCutoff` encoding).+ - `handleStatus` in `internal/api/status.go`: inside the existing `liveFresh` branch, immediately after `battery.EstimatedCutoff` is set, call `withinOffpeakWindow(now, h.offpeakStart, h.offpeakEnd)`, build `cantEmptyInput`, call `computeCantEmptyBeforeOffpeak`, and assign the result to `battery.CantEmptyBeforeOffpeak`.+ - No changes to the `!liveFresh` branch — the field stays nil.+ - Blocked-by: p1ve882 (Backend (Go) — implement helpers (green)), p1ve883 (Backend (Go) — handler integration tests (red))+ - Stream: 1+ - Requirements: [1.7](requirements.md#1.7), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3)++## Swift++- [x] 5. Swift — APIModels decoding tests (red) <!-- id:p1ve885 -->+ - Extend `Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift` with three decoding cases.+ - JSON containing `"cantEmptyBeforeOffpeak": true` decodes to `.cantEmptyBeforeOffpeak == true`.+ - JSON containing `"cantEmptyBeforeOffpeak": null` decodes to `nil`.+ - JSON omitting the key entirely decodes to `nil` (forward compatibility with older server payloads).+ - Tests fail because the field does not exist on `BatteryInfo` yet.+ - Stream: 2+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)++- [x] 6. Swift — add field to BatteryInfo + fixture variants (green) <!-- id:p1ve886 -->+ - `Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift`: add `public let cantEmptyBeforeOffpeak: Bool?` to `BatteryInfo`.+ - Update the memberwise initialiser with `cantEmptyBeforeOffpeak: Bool? = nil` as the last parameter — the default keeps the five existing call sites in design.md §Pattern extension audit compiling unchanged.+ - `Flux/Flux/Services/MockFluxAPIClient.swift`: add a second fixture `statusResponseCantEmpty` with `battery.cantEmptyBeforeOffpeak == true`. The default `statusResponse` keeps it `nil`.+ - Blocked-by: p1ve885 (Swift — APIModels decoding tests (red))+ - Stream: 2+ - Requirements: [2.1](requirements.md#2.1)++- [x] 7. Swift — Dashboard hero accessibility test (red) <!-- id:p1ve887 -->+ - Add a Swift Testing case under `Flux/FluxTests/` that constructs `DashboardHeroPanel(live:, rolling15min:, battery:, offpeakWindowStart:)` with `battery.cantEmptyBeforeOffpeak == true` and `offpeakWindowStart == "23:00"`.+ - Assert the rendered indicator's `accessibilityLabel` is exactly `"Battery won't empty before off-peak at 23:00"`.+ - Test fails because the panel does not yet accept the new inputs.+ - Blocked-by: p1ve886 (Swift — add field to BatteryInfo + fixture variants (green))+ - Stream: 2+ - Requirements: [3.5](requirements.md#3.5)++- [x] 8. Swift — implement hero indicator + wire DashboardView (green) <!-- id:p1ve888 -->+ - `Flux/Flux/Dashboard/DashboardHeroPanel.swift`: add inputs `let battery: BatteryInfo?` and `let offpeakWindowStart: String?`.+ - Add a private `cantEmptyBeforeOffpeakIndicator` subview using `FluxTheme.Typography.heroSubline` and `FluxTheme.Palette.secondaryText`. Do NOT reuse `Palette.amber`; do NOT change the `Mode` enum.+ - In `body`, render the indicator when `battery?.cantEmptyBeforeOffpeak == true && offpeakWindowStart != nil`; otherwise render the existing `statusLine` exactly as today.+ - Set the indicator's `accessibilityLabel` to `"Battery won't empty before off-peak at <HH:MM>"` where `<HH:MM>` is the value of `offpeakWindowStart`.+ - `Flux/Flux/Dashboard/DashboardView.swift:113-116`: pass `battery: viewModel.status?.battery` and `offpeakWindowStart: viewModel.status?.offpeak?.windowStart`.+ - Update the `#Preview` block to render both states using the new `MockFluxAPIClient.statusResponseCantEmpty` fixture alongside the default.+ - Blocked-by: p1ve886 (Swift — add field to BatteryInfo + fixture variants (green)), p1ve887 (Swift — Dashboard hero accessibility test (red))+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5)++## Validation++- [x] 9. Validate — run lint and full test suites <!-- id:p1ve889 -->+ - Run `make lint`, `go test ./...`, and the Swift test schemes (`make macos-test` and/or `make ios-test` depending on what's defined in the Makefile).+ - Fix any lint/test failures introduced by this branch. No new feature code — only test fixes, lint fixes, and follow-on adjustments.+ - Blocked-by: p1ve884 (Backend (Go) — add field + wire handler (green)), p1ve888 (Swift — implement hero indicator + wire DashboardView (green))+ - Stream: 1
origin/main has advanced beyond this branch’s base (9958320 Release v1.3) with 30b39f7 T-1150 iPad adaptive layout (#53). This branch must rebase onto origin/main before push to avoid spurious deletions of the T-1150 iPad files in the PR diff.
KeychainAccessibilityMigratorTests (2 tests) fail with errSecInteractionNotAllowed (-25308) when run headlessly. Verified by stashing this branch’s changes and re-running — identical failures on baseline. Unrelated to this branch.