Server-side live peakGridImportKwh for today, consumed identically by the Dashboard, Day Detail, and History. Closes T-1420 (bug) and folds in T-1421 (Dashboard real-time peak).
eInput − offpeak residual.livePeakGridImport integrates max(pgrid,0) over the two windows bracketing off-peak, clamped to now, gated on the morning window only, and day-trimmed so /status, /day, and /history return an identical value.specs/bugfixes/.Ready to push
Backend and iOS build clean; go test ./... exit 0 and golangci-lint 0 issues; the macOS app builds and all peak/grid/status tests pass. The review's one must-fix (a cross-screen consistency test, the literal core guarantee of the change) has been added and passes. Reuse, quality, and efficiency reviews surfaced only acceptable trade-offs given a two-user app and the project's simplicity guideline. Two pre-existing, unrelated test failures (keychain migration under a locally-signed test host) are documented and untouched.
82f7712 T-1420: Live peak grid import for today across all three screens The app shows how much electricity you pulled from the grid, split into peak (paid) and off-peak (free) hours. For today, that split used to break: before the off-peak window opened at 11:00 the breakdown vanished entirely on the History and Day Detail screens, and after 11:00 the “peak” number was computed in a roundabout way that could be wrong.
Today is the screen people look at most. A missing or inflated peak figure is misleading — and the three screens (Dashboard, Day Detail, History) could each show a slightly different number for the same thing.
A new pure function api.livePeakGridImport(readings, now, offpeakStart, offpeakEnd) integrates max(pgrid,0) over the two windows bracketing off-peak — morning [00:00, min(now, opStart)) and evening [opEnd, min(now, dayEnd)) — reusing derivedstats.IntegrateOffpeakDeltas per window. It mirrors the existing liveOffpeakDeltas and is independent of reconcileEnergy, so the off-peak sampling artifact never lands on peak.
IntegratePeakGridImportKwh — which requires both windows — couldn't be reused.sort.Search, so /status (rolling 24h) and /day//history (today-only) compute the same value.Peak and off-peak no longer sum exactly to the displayed eInput (which is max(stored, computed)); each row is individually accurate. The iOS residual fallback stays for pre-30-day rows (Decision 4b).
The bug had two independent causes scoped to today. (1) Rendering coupling: HistoryDerivedState.gridEntry guarded on offpeakGridImportKwh and SummaryBlock.gridInRows on offpeakGridImport != nil; today's flux-offpeak row is created only at the 11:00 window start, so 00:00–11:00 dropped the breakdown. (2) Cross-source residual: today's eInput = reconcileEnergy(computed, stored) = max(readings, AlphaESS snapshot) while off-peak is a pure readings integration, so eInput − offpeak mixed sources and could inflate peak when the snapshot won the reconcile.
peakGridImportKwh is now emitted for today on /status (new StatusResponse field), /day, and /history; past days keep the stored #58 value. The iOS prefer/fallback path already existed from #58 for past days; this extends it to today and removes the off-peak rendering gate.
dayStart is derived from now.In(sydneyTZ); callers pass now strictly within the day, matching liveOffpeakDeltas.ok=false → field absent → iOS residual fallback (same as energy being absent then).sliceWindow keeps one bracketing reading each side for edge synthesis; evening left edge uses the last off-peak reading.internal_api_compute.go
Why it matters. The new correctness core. Integrates max(pgrid,0) over the morning and evening windows bracketing off-peak, clamped to now, gated on the morning window only so it works for the partial “today so far” case. Independent of reconcileEnergy, so the off-peak sampling loss never lands on peak.
What to look at. internal/api/compute.go — livePeakGridImport
internal_api_compute.go
Why it matters. Without it, /status (rolling 24h, has pre-midnight readings) would synthesise a pre-midnight left edge on the morning window while /day and /history (today-only) would not — so the same instant could read differently across screens, violating the Data Consistency rule.
What to look at. internal/api/compute.go — sort.Search trim near dayStart
internal_api_status.go
Why it matters. Surfaces the live value on all three today paths; past days keep the stored server value. New StatusResponse.PeakGridImportKwh (T-1421).
What to look at. status.go, day.go, history.go, response.go
Flux_Flux_History_HistoryDerivedState.swift
Why it matters. The user-visible fix. gridEntry and SummaryBlock.gridInRows now render the split whenever a peak value exists (off-peak shown as 0 before the window), instead of gating on off-peak presence — which is what hid the breakdown before 11:00.
What to look at. HistoryDerivedState.gridEntry; SummaryBlock.gridInRows + showsGridSplit
internal_api_peak_grid_import_test.go
Why it matters. Locks in Decision 9's core guarantee — /status == /day == /history for the same readings and now — which no test covered before. Seeds pre-midnight readings so it would fail without the internal day-trim.
What to look at. internal/api/peak_grid_import_test.go — TestTodayPeakGridImportConsistentAcrossEndpoints
eInput is max(stored, computed) while off-peak is a pure integration, so the residual mixes sources and can inflate peak. Integrating peak directly from pgrid keeps peak's own ~1.5% sampling artifact proportional to its own kWh.IntegratePeakGridImportKwh does) would return nothing for the partial day. Gating on the morning window and adding the evening only when usable yields a value at any time of day./status) or today-only readings (/day, /history), satisfying the Data Consistency rule without relying on each caller to pre-trim.?? residual path must stay; only Decision 4a (no real-time today path) is superseded.| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | tests / Data Consistency | No test asserted that /status, /day, and /history return the same today peak for identical inputs — the literal core guarantee of Decision 9 and the CLAUDE.md Data Consistency rule. | Added TestTodayPeakGridImportConsistentAcrossEndpoints, which drives all three handlers from one readings slice (seeded with pre-midnight samples to also prove the internal day-trim) and asserts the three peaks are equal and == 46.8 kWh. |
| minor | SummaryBlock.swift | The interplay between showsGridSplit (checks server-peak OR off-peak) and peakGridImport (server → residual → nil) is subtle; the degenerate “off-peak present but eInput nil” case passes showsGridSplit but unwraps peak to nil and falls through to the combined row. | Left as-is: the behaviour is correct (combined row is the right degradation), the inputs are a degenerate API state, and the comments explain the intent. Restructuring would not improve correctness. |
| minor | day.go / history.go | The today-vs-stored peak branch is near-duplicated across the two handlers. | Left as-is: a 6-line pattern in genuinely different contexts (single-date response vs multi-date loop, different readings sources); extracting would harm clarity and risks nothing as long as the algorithm is shared via livePeakGridImport. |
| minor | compute.go / handlers (efficiency) | livePeakGridImport allocates up to two new []derivedstats.Reading conversions per call (~120 KB churn), and /day//history already hold a converted today slice. | Left as-is: negligible for a two-user app polling every 10s; threading a pre-converted slice would complicate the signature against the project's simplicity guideline. Noted for revisit if request rate grows. |
| minor | tests (coverage) | Midnight/DST boundary, the during-off-peak /status handler path, and the insufficient-morning-samples handler path are not exercised at handler level. | Left as-is: all three are covered at the unit level by TestLivePeakGridImport and TestLivePeakGridImportGates; the handlers are thin pass-throughs. |
Click to expand.
diff --git a/internal/api/compute.go b/internal/api/compute.goindex a760487..e253d8b 100644--- a/internal/api/compute.go+++ b/internal/api/compute.go@@ -104,6 +104,87 @@ func liveOffpeakDeltas(readings []dynamo.ReadingItem, now time.Time, }, true } +// livePeakGridImport integrates max(pgrid, 0) over today's two peak windows —+// the spans bracketing the off-peak window — each clamped to now:+//+// morning = [00:00, min(now, opStart))+// evening = [opEnd, min(now, dayEnd)) (empty until now passes opEnd)+//+// It is the "peak so far today" complement of liveOffpeakDeltas, computed+// directly from readings (independent of reconcileEnergy) so the off-peak+// sampling artifact is never dumped onto the peak residual (T-1421).+// offpeakStart and offpeakEnd are the raw "HH:MM" config values.+//+// Returns (_, false) when the window is unparseable or the morning window has+// too few usable samples to integrate — the same <2-point usability gate+// liveOffpeakDeltas uses. The evening window is additive-when-usable: before+// now passes opEnd, or when it is too sparse to integrate on its own, it+// contributes zero rather than failing the whole result. This is why+// derivedstats.IntegratePeakGridImportKwh cannot be reused here — it requires+// BOTH windows to pass the gate, which is wrong for the partial "today so far"+// case (the evening window is empty before opEnd).+//+// Pure function: no state and no clock except the explicit now parameter.+func livePeakGridImport(readings []dynamo.ReadingItem, now time.Time,+ offpeakStart, offpeakEnd string,+) (float64, bool) {+ startMin, endMin, parsed := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)+ if !parsed {+ return 0, false+ }+ local := now.In(sydneyTZ)+ dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, sydneyTZ)+ opStart := dayStart.Add(time.Duration(startMin) * time.Minute)+ opEnd := dayStart.Add(time.Duration(endMin) * time.Minute)+ dayEnd := dayStart.AddDate(0, 0, 1)++ // Trim to today's readings so the result is identical regardless of whether+ // the caller's slice carries pre-midnight samples (/status reads a rolling+ // 24h window; /day and /history read today only). Without this the morning+ // window would synthesise a pre-midnight left edge for /status but not the+ // others, so the same instant could read differently across screens. This+ // matches computeTodayEnergy, which integrates from the first post-midnight+ // sample. Readings are sorted ascending (the DynamoDB sort-key guarantee).+ if i := sort.Search(len(readings), func(i int) bool {+ return readings[i].Timestamp >= dayStart.Unix()+ }); i > 0 {+ readings = readings[i:]+ }++ // Morning window [00:00, min(now, opStart)). The usability gate hangs off+ // this window: before there are two usable post-midnight samples there is+ // no peak to report.+ mornEnd := opStart+ if local.Before(opStart) {+ mornEnd = local+ }+ mornWin := sliceWindow(readings, dayStart.Unix(), mornEnd.Unix())+ morning, ok := derivedstats.IntegrateOffpeakDeltas(+ toDerivedReadings(mornWin), dayStart.Unix(), mornEnd.Unix(),+ )+ if !ok {+ return 0, false+ }+ peak := morning.GridImportKwh++ // Evening window [opEnd, min(now, dayEnd)). Only contributes once now is+ // past the off-peak window end; additive-when-usable so a brief sparse+ // patch right after opEnd does not discard the morning value.+ if local.After(opEnd) {+ evenEnd := dayEnd+ if local.Before(dayEnd) {+ evenEnd = local+ }+ evenWin := sliceWindow(readings, opEnd.Unix(), evenEnd.Unix())+ if evening, evOK := derivedstats.IntegrateOffpeakDeltas(+ toDerivedReadings(evenWin), opEnd.Unix(), evenEnd.Unix(),+ ); evOK {+ peak += evening.GridImportKwh+ }+ }+ return peak, true+}+ // computeCutoffTime estimates when the battery will reach the cutoff percentage // using linear extrapolation. Returns nil if the battery is not discharging or // SOC is already at/below cutoff.
diff --git a/internal/api/status.go b/internal/api/status.goindex f3ff85e..de72613 100644--- a/internal/api/status.go+++ b/internal/api/status.go@@ -200,6 +200,15 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe // complete (from the finalised row) or pending today (live-integrated // from the readings already in memory for live compute). resp.Offpeak = buildOffpeak(opItem, allReadings, now, h.offpeakStart, h.offpeakEnd)++ // Peak grid import so far today: integrated directly from readings over the+ // two windows bracketing off-peak, independent of reconcileEnergy so the+ // off-peak sampling artifact never lands on peak (T-1421). Absent until the+ // morning window has enough samples; iOS then uses its residual fallback.+ if peak, ok := livePeakGridImport(allReadings, now, h.offpeakStart, h.offpeakEnd); ok {+ resp.PeakGridImportKwh = floatPtr(derivedstats.RoundEnergy(peak))+ }+ resp.Note = noteText return jsonResponse(resp)
diff --git a/internal/api/response.go b/internal/api/response.goindex 6ce10c3..8385711 100644--- a/internal/api/response.go+++ b/internal/api/response.go@@ -10,7 +10,12 @@ type StatusResponse struct { Rolling15m *RollingAvg `json:"rolling15min"` Offpeak *OffpeakData `json:"offpeak"` TodayEnergy *TodayEnergy `json:"todayEnergy"`- Note *string `json:"note"`+ // PeakGridImportKwh is today's peak (non-off-peak) grid import so far,+ // integrated directly from Pgrid over the windows bracketing off-peak+ // (T-1421). Absent when the morning window is not yet integrable; the iOS+ // Dashboard then falls back to the eInput − offpeak residual.+ PeakGridImportKwh *float64 `json:"peakGridImportKwh,omitempty"`+ Note *string `json:"note"` } // LiveData contains the most recent power readings.
diff --git a/internal/api/day.go b/internal/api/day.goindex 1695839..2cedec8 100644--- a/internal/api/day.go+++ b/internal/api/day.go@@ -202,10 +202,16 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq summary.OffpeakGridExportKwh = floatPtr(exp) } }- // Peak grid import is the stored server-computed value only — no- // real-time compute path for today (Decision 4); today's row is absent- // and the iOS fallback applies.- if deItem != nil && deItem.PeakGridImportKwh != nil {+ // Peak grid import: today is integrated live from readings (T-1420,+ // superseding peak-from-readings Decision 4a), independent of the+ // off-peak split so it renders before the window opens. Past days use+ // the stored server-computed value. Absent on either path falls through+ // to the iOS residual fallback (e.g. pre-30-day rows, Decision 4b).+ if isToday {+ if peak, ok := livePeakGridImport(readings, now, h.offpeakStart, h.offpeakEnd); ok {+ summary.PeakGridImportKwh = floatPtr(derivedstats.RoundEnergy(peak))+ }+ } else if deItem != nil && deItem.PeakGridImportKwh != nil { summary.PeakGridImportKwh = floatPtr(*deItem.PeakGridImportKwh) } resp.Summary = summary
diff --git a/internal/api/history.go b/internal/api/history.goindex 93b4203..a98e5d4 100644--- a/internal/api/history.go+++ b/internal/api/history.go@@ -155,9 +155,16 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR day.OffpeakGridExportKwh = floatPtr(exp) } }- // Peak grid import is the stored server-computed value only (Decision 4):- // today's row has no stored peak, so the iOS fallback applies there.- if item.PeakGridImportKwh != nil {+ // Peak grid import: today is integrated live from readings (T-1420,+ // superseding peak-from-readings Decision 4a), independent of the+ // off-peak split so it renders before the window opens. Past rows use+ // the stored server-computed value; absent on either path falls through+ // to the iOS residual fallback (e.g. pre-30-day rows, Decision 4b).+ if isItemToday {+ if peak, ok := livePeakGridImport(todayReadings, now, h.offpeakStart, h.offpeakEnd); ok {+ day.PeakGridImportKwh = floatPtr(derivedstats.RoundEnergy(peak))+ }+ } else if item.PeakGridImportKwh != nil { day.PeakGridImportKwh = floatPtr(*item.PeakGridImportKwh) } if note, ok := notesByDate[item.Date]; ok {
diff --git a/internal/api/compute_test.go b/internal/api/compute_test.goindex 4f5e608..998e846 100644--- a/internal/api/compute_test.go+++ b/internal/api/compute_test.go@@ -163,6 +163,94 @@ func TestLiveOffpeakDeltasDeterminism(t *testing.T) { assert.Equal(t, first, second, "same inputs must produce identical outputs") } +// TestLivePeakGridImport covers the "peak so far today" integration: the two+// windows bracketing off-peak, each clamped to now, gated on the morning+// window only (T-1420 / T-1421). Window 11:00-14:00, so peak windows are+// [00:00, 11:00) and [14:00, 24:00).+func TestLivePeakGridImport(t *testing.T) {+ loc := sydneyTZ+ dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, loc)+ const windowStart = "11:00"+ const windowEnd = "14:00"+ opStart := dayStart.Add(11 * time.Hour)+ opEnd := dayStart.Add(14 * time.Hour)++ // Uniform 10-second cadence covering the whole day plus a 60s shoulder+ // before midnight (for left-edge synthesis). Constant pgrid = 3600 W so+ // each second contributes exactly 1 Wh: kWh == hours integrated.+ const pgridW = 3600.0+ readings := make([]dynamo.ReadingItem, 0)+ for ts := dayStart.Add(-60 * time.Second).Unix(); ts <= dayStart.Add(24*time.Hour).Unix(); ts += 10 {+ readings = append(readings, dynamo.ReadingItem{Timestamp: ts, Pgrid: pgridW})+ }++ tests := map[string]struct {+ now time.Time+ wantOK bool+ wantKwh float64 // expected peak grid import so far+ }{+ "before off-peak: whole day so far is peak": {+ // 05:00 -> [00:00, 05:00) = 5 h.+ now: dayStart.Add(5 * time.Hour), wantOK: true, wantKwh: 18.0,+ },+ "at off-peak start: full morning, no evening": {+ // [00:00, 11:00) = 11 h.+ now: opStart, wantOK: true, wantKwh: 39.6,+ },+ "during off-peak: morning only, evening still zero": {+ // 12:30 -> morning 11 h, evening not yet open.+ now: dayStart.Add(12*time.Hour + 30*time.Minute), wantOK: true, wantKwh: 39.6,+ },+ "at off-peak end: morning only (evening zero-length)": {+ now: opEnd, wantOK: true, wantKwh: 39.6,+ },+ "after off-peak end: morning plus partial evening": {+ // 14:30 -> morning 11 h + evening 0.5 h = 11.5 h.+ now: opEnd.Add(30 * time.Minute), wantOK: true, wantKwh: 41.4,+ },+ "late evening: morning plus most of evening": {+ // 23:00 -> morning 11 h + evening [14:00, 23:00) = 9 h = 20 h total.+ now: dayStart.Add(23 * time.Hour), wantOK: true, wantKwh: 72.0,+ },+ }++ for name, tc := range tests {+ t.Run(name, func(t *testing.T) {+ got, ok := livePeakGridImport(readings, tc.now, windowStart, windowEnd)+ assert.Equal(t, tc.wantOK, ok)+ if !ok {+ return+ }+ assert.InDelta(t, tc.wantKwh, got, 0.01)+ assert.GreaterOrEqual(t, got, 0.0)+ })+ }+}++// TestLivePeakGridImportGates covers the not-usable paths: an unparseable+// window and a morning window with too few samples to integrate.+func TestLivePeakGridImportGates(t *testing.T) {+ loc := sydneyTZ+ dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, loc)++ t.Run("unparseable window returns false", func(t *testing.T) {+ readings := []dynamo.ReadingItem{+ {Timestamp: dayStart.Add(time.Hour).Unix(), Pgrid: 1000},+ {Timestamp: dayStart.Add(time.Hour + 10*time.Second).Unix(), Pgrid: 1000},+ }+ _, ok := livePeakGridImport(readings, dayStart.Add(2*time.Hour), "nope", "14:00")+ assert.False(t, ok)+ })++ t.Run("single morning sample is not integrable", func(t *testing.T) {+ readings := []dynamo.ReadingItem{+ {Timestamp: dayStart.Add(time.Hour).Unix(), Pgrid: 1000},+ }+ _, ok := livePeakGridImport(readings, dayStart.Add(2*time.Hour), "11:00", "14:00")+ assert.False(t, ok)+ })+}+ // TestBuildOffpeakDispatch covers the live-vs-stored dispatch in buildOffpeak. // Pending rows live-integrate from the readings slice; complete rows // pass through op.GridUsageKwh etc. The op.StartE* fields are never read.
diff --git a/internal/api/peak_grid_import_test.go b/internal/api/peak_grid_import_test.goindex e7f7ffc..f9c272a 100644--- a/internal/api/peak_grid_import_test.go+++ b/internal/api/peak_grid_import_test.go@@ -6,11 +6,23 @@ import ( "testing" "time" + "github.com/ArjenSchwarz/flux/internal/derivedstats" "github.com/ArjenSchwarz/flux/internal/dynamo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// uniformTodayReadings builds a constant-pgrid reading stream from Sydney+// midnight to end (inclusive) at 30s cadence — used to assert live peak wiring+// with an easily integrable signal.+func uniformTodayReadings(dayStart, end time.Time, pgridW float64) []dynamo.ReadingItem {+ out := []dynamo.ReadingItem{}+ for ts := dayStart.Unix(); ts <= end.Unix(); ts += 30 {+ out = append(out, dynamo.ReadingItem{Timestamp: ts, Pgrid: pgridW, Soc: 50})+ }+ return out+}+ // TestHandleDayPeakGridImport covers the /day peakGridImportKwh field: present // with the stored value when the daily-energy row carries it, and absent from // the JSON when the stored value is nil (Decision 4 — no real-time path).@@ -102,3 +114,198 @@ func TestHandleHistoryPeakGridImport(t *testing.T) { assert.Equal(t, 1, strings.Count(resp.Body, "peakGridImportKwh"), "only the populated row should carry peakGridImportKwh") }++// TestHandleDayTodayLivePeakGridImport pins the T-1420 fix on /day: today's+// peak is integrated live from readings (not the stored value, not the off-peak+// residual) and is present even before the off-peak window opens — i.e. with no+// off-peak row at all (fixedNow is 10:00, before the 11:00 window).+func TestHandleDayTodayLivePeakGridImport(t *testing.T) {+ now := fixedNow() // 2026-04-15 10:00 AEST — before the 11:00 off-peak window.+ date := now.Format("2006-01-02")+ dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, sydneyTZ)+ readings := uniformTodayReadings(dayStart, now, 3600) // [00:00, 10:00].++ // Expected = the same live integration the handler performs, rounded.+ rawPeak, ok := livePeakGridImport(readings, now, "11:00", "14:00")+ require.True(t, ok)+ wantPeak := derivedstats.RoundEnergy(rawPeak)+ require.Greater(t, wantPeak, 0.0)++ const storedPeak = 99.0 // deliberately wrong — today must ignore stored.+ sp := storedPeak+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return readings, nil+ },+ getDailyEnergyFn: func(_ context.Context, _, _ string) (*dynamo.DailyEnergyItem, error) {+ return &dynamo.DailyEnergyItem{Date: date, EInput: 4.2, PeakGridImportKwh: &sp}, nil+ },+ // No off-peak row: proves peak renders independently of the off-peak split.+ getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+ return nil, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+ require.NoError(t, err)+ require.Equal(t, 200, resp.StatusCode)++ dr := parseDayResponse(t, resp)+ require.NotNil(t, dr.Summary)+ require.NotNil(t, dr.Summary.PeakGridImportKwh, "today peak must be present before the off-peak window opens")+ assert.InDelta(t, wantPeak, *dr.Summary.PeakGridImportKwh, 1e-9, "today uses live integration")+ assert.NotEqual(t, storedPeak, *dr.Summary.PeakGridImportKwh, "today must ignore the stored value")+ assert.Nil(t, dr.Summary.OffpeakGridImportKwh, "no off-peak split before the window — peak still renders")+}++// TestHandleHistoryTodayLivePeakGridImport pins the same on /history: the today+// row carries a live-integrated peak even though it has no stored peak and no+// off-peak row.+func TestHandleHistoryTodayLivePeakGridImport(t *testing.T) {+ now := fixedNow()+ today := now.Format("2006-01-02")+ dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, sydneyTZ)+ readings := uniformTodayReadings(dayStart, now, 3600)++ rawPeak, ok := livePeakGridImport(readings, now, "11:00", "14:00")+ require.True(t, ok)+ wantPeak := derivedstats.RoundEnergy(rawPeak)++ mr := &mockReader{+ queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+ return []dynamo.DailyEnergyItem{{Date: today, EInput: 4.2}}, nil // today, no stored peak+ },+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return readings, nil+ },+ // No off-peak rows for today.+ queryOffpeakFn: func(_ context.Context, _, _, _ string) ([]dynamo.OffpeakItem, error) {+ return nil, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }++ resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))+ require.NoError(t, err)+ require.Equal(t, 200, resp.StatusCode)++ hr := parseHistoryResponse(t, resp)+ require.Len(t, hr.Days, 1)+ require.NotNil(t, hr.Days[0].PeakGridImportKwh, "today row must carry a live peak even without a stored value")+ assert.InDelta(t, wantPeak, *hr.Days[0].PeakGridImportKwh, 1e-9)+ assert.Nil(t, hr.Days[0].OffpeakGridImportKwh, "no off-peak split — peak still present")+}++// TestTodayPeakGridImportConsistentAcrossEndpoints is the core Data Consistency+// guarantee of Decision 9: /status, /day, and /history must return the SAME+// today peak for the same readings and now. The readings deliberately start+// before Sydney midnight so the three callers feed livePeakGridImport slices of+// different lengths (/status reads a rolling 24h, /day and /history are today-+// only) — the internal day-trim is what makes the three agree, and this test+// would fail without it. now is 16:00 so both peak windows contribute.+func TestTodayPeakGridImportConsistentAcrossEndpoints(t *testing.T) {+ now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+ today := now.Format("2006-01-02")++ // 30s cadence from 22:00 yesterday (pre-midnight) to 16:00 today, constant+ // 3600 W. Peak so far = morning [00:00, 11:00) + evening [14:00, 16:00) =+ // 13 h = 46.8 kWh; the pre-midnight tail must NOT leak in.+ start := time.Date(2026, 4, 14, 22, 0, 0, 0, sydneyTZ)+ readings := []dynamo.ReadingItem{}+ for ts := start.Unix(); ts <= now.Unix(); ts += 30 {+ readings = append(readings, dynamo.ReadingItem{Timestamp: ts, Pgrid: 3600, Soc: 50})+ }++ newHandler := func() *Handler {+ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return readings, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) { return nil, nil },+ queryOffpeakFn: func(_ context.Context, _, _, _ string) ([]dynamo.OffpeakItem, error) {+ return nil, nil+ },+ getDailyEnergyFn: func(_ context.Context, _, date string) (*dynamo.DailyEnergyItem, error) {+ return &dynamo.DailyEnergyItem{Date: date, EInput: 3.0}, nil+ },+ queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+ return []dynamo.DailyEnergyItem{{Date: today, EInput: 3.0}}, nil+ },+ }+ h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+ h.nowFunc = func() time.Time { return now }+ return h+ }++ // /status+ statusResp, err := newHandler().Handle(context.Background(), statusRequest())+ require.NoError(t, err)+ sr := parseStatusResponse(t, statusResp)+ require.NotNil(t, sr.PeakGridImportKwh)++ // /day (today)+ dayResp, err := newHandler().Handle(context.Background(), dayRequest(map[string]string{"date": today}))+ require.NoError(t, err)+ dr := parseDayResponse(t, dayResp)+ require.NotNil(t, dr.Summary)+ require.NotNil(t, dr.Summary.PeakGridImportKwh)++ // /history (today row)+ histResp, err := newHandler().Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))+ require.NoError(t, err)+ hr := parseHistoryResponse(t, histResp)+ var todayPeak *float64+ for _, d := range hr.Days {+ if d.Date == today {+ todayPeak = d.PeakGridImportKwh+ }+ }+ require.NotNil(t, todayPeak)++ // All three identical, and equal the expected 46.8 kWh (pre-midnight excluded).+ assert.InDelta(t, 46.8, *sr.PeakGridImportKwh, 0.01)+ assert.InDelta(t, *sr.PeakGridImportKwh, *dr.Summary.PeakGridImportKwh, 1e-9, "/status and /day must agree")+ assert.InDelta(t, *sr.PeakGridImportKwh, *todayPeak, 1e-9, "/status and /history must agree")+}++// TestHandleStatusLivePeakGridImport pins the new /status field (T-1421).+func TestHandleStatusLivePeakGridImport(t *testing.T) {+ now := fixedNow()+ dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, sydneyTZ)+ readings := uniformTodayReadings(dayStart, now, 3600)++ rawPeak, ok := livePeakGridImport(readings, now, "11:00", "14:00")+ require.True(t, ok)+ wantPeak := derivedstats.RoundEnergy(rawPeak)++ mr := &mockReader{+ queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+ return readings, nil+ },+ getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+ return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+ },+ getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+ return nil, nil+ },+ getDailyEnergyFn: func(_ context.Context, _, date string) (*dynamo.DailyEnergyItem, error) {+ return &dynamo.DailyEnergyItem{Date: date, EInput: 3.0}, 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)+ require.Equal(t, 200, resp.StatusCode)++ sr := parseStatusResponse(t, resp)+ require.NotNil(t, sr.PeakGridImportKwh, "/status must expose today's live peak")+ assert.InDelta(t, wantPeak, *sr.PeakGridImportKwh, 1e-9)+}
diff --git a/Flux/Flux/Helpers/SummaryBlock.swift b/Flux/Flux/Helpers/SummaryBlock.swiftindex f5a679d..2146f2a 100644--- a/Flux/Flux/Helpers/SummaryBlock.swift+++ b/Flux/Flux/Helpers/SummaryBlock.swift@@ -69,35 +69,35 @@ struct SummaryBlock: View { @ViewBuilder private var gridInRows: some View {- if offpeakGridImport != nil {+ if showsGridSplit, let peak = peakGridImport {+ // Off-peak is shown as 0 (not "missing") when a peak value exists+ // but the off-peak window hasn't opened yet — today before 11:00+ // (T-1420). `showsGridSplit` keeps this branch off when neither a+ // server peak nor an off-peak value is available, so we never imply+ // a misleading "off-peak 0" on a day with genuinely unknown split.+ let offpeak = offpeakGridImport ?? 0 compareRow( label: "Grid in (peak)",- value: kwh(peakGridImport),+ value: kwh(peak), sub: "paid", accent: FluxTheme.Palette.grid, last: false,- current: peakGridImport,+ current: peak, comparison: snapshot?.peakGridImport ) compareRow( label: "Grid in (off-peak)",- value: kwh(offpeakGridImport),+ value: kwh(offpeak), sub: "free", accent: FluxTheme.Palette.offpeak, last: false,- current: offpeakGridImport,+ current: offpeak, comparison: snapshot?.offpeakGridImport ) } else {- // Without a peak/off-peak split (DaySummary currently doesn't- // carry one) showing it all under "peak" would be misleading.- // Render a single combined row instead.- //- // No `valueSub` / `accessibilityOverride` here — Decision 10- // (`specs/stat-comparisons/decision_log.md`) guarantees the- // split is always present in production data, so this branch- // never fires when Compare is on. If that guarantee ever- // changes, this row needs its own compare wiring.+ // No split information at all (no server peak, no off-peak value):+ // showing it all under "peak" would be misleading, so render a+ // single combined row instead. FluxStatRow( label: "Grid in", value: kwh(gridImport),@@ -106,6 +106,15 @@ struct SummaryBlock: View { } } + /// Whether to render the peak/off-peak split. True when the server gives an+ /// authoritative peak (today's live value or a stored past day) or when an+ /// off-peak value is present to derive the residual. False leaves only the+ /// combined "Grid in" row, so a day with a genuinely unknown split never+ /// implies an off-peak of 0.+ private var showsGridSplit: Bool {+ serverPeakGridImport != nil || offpeakGridImport != nil+ }+ private var gridOutRow: some View { compareRow( label: "Grid out",@@ -246,6 +255,7 @@ extension SummaryBlock { trailing: String? = nil, todayEnergy: TodayEnergy?, offpeakGridImport: Double?,+ serverPeakGridImport: Double? = nil, showsBatteryCycle: Bool = true, avgLoadWatts: Double? = nil ) {@@ -256,6 +266,7 @@ extension SummaryBlock { gridImport: todayEnergy?.eInput, gridExport: todayEnergy?.eOutput, offpeakGridImport: offpeakGridImport,+ serverPeakGridImport: serverPeakGridImport, batteryCharge: todayEnergy?.eCharge, batteryDischarge: todayEnergy?.eDischarge, showsBatteryCycle: showsBatteryCycle,
diff --git a/Flux/Flux/History/HistoryDerivedState.swift b/Flux/Flux/History/HistoryDerivedState.swiftindex 6a3c61a..ae7e2db 100644--- a/Flux/Flux/History/HistoryDerivedState.swift+++ b/Flux/Flux/History/HistoryDerivedState.swift@@ -226,13 +226,27 @@ extension HistoryViewModel { } private static func gridEntry(day: DayEnergy, parsedDate: Date, isToday: Bool) -> GridEntry? {- guard let offpeakImport = day.offpeakGridImportKwh else { return nil }- let peak = day.peakGridImportKwh ?? max(0, day.eInput - offpeakImport)+ // Prefer the server peak (today's live value or a stored past day) so+ // the split renders even before the off-peak window opens, where+ // off-peak is genuinely 0, not missing (T-1420). Else use the+ // eInput − off-peak residual; else omit (no split info — never imply+ // a misleading off-peak 0).+ let peak: Double+ let offpeak: Double+ if let serverPeak = day.peakGridImportKwh {+ peak = serverPeak+ offpeak = day.offpeakGridImportKwh ?? 0+ } else if let offpeakImport = day.offpeakGridImportKwh {+ peak = max(0, day.eInput - offpeakImport)+ offpeak = offpeakImport+ } else {+ return nil+ } return GridEntry( date: parsedDate, dayID: day.date, peakImportKwh: peak,- offpeakImportKwh: offpeakImport,+ offpeakImportKwh: offpeak, exportKwh: day.eOutput, isToday: isToday )
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex 13f0f3e..374571f 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -183,6 +183,7 @@ struct DashboardView: View { SummaryBlock( todayEnergy: viewModel.status?.todayEnergy, offpeakGridImport: viewModel.status?.offpeak?.gridUsageKwh,+ serverPeakGridImport: viewModel.status?.peakGridImportKwh, showsBatteryCycle: false, avgLoadWatts: viewModel.status?.rolling15min?.avgLoad )
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swiftindex e248aee..d612471 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift@@ -9,6 +9,11 @@ public struct StatusResponse: Codable, Sendable { public let rolling15min: RollingAvg? public let offpeak: OffpeakData? public let todayEnergy: TodayEnergy?+ /// Server-computed peak (non-off-peak) grid import so far today, integrated+ /// directly from Pgrid over the windows bracketing off-peak (T-1421). Nil+ /// when the morning window is not yet integrable; the Dashboard then falls+ /// back to the eInput − offpeak residual.+ public let peakGridImportKwh: Double? public let note: String? public init(@@ -17,6 +22,7 @@ public struct StatusResponse: Codable, Sendable { rolling15min: RollingAvg?, offpeak: OffpeakData?, todayEnergy: TodayEnergy?,+ peakGridImportKwh: Double? = nil, note: String? = nil ) { self.live = live@@ -24,6 +30,7 @@ public struct StatusResponse: Codable, Sendable { self.rolling15min = rolling15min self.offpeak = offpeak self.todayEnergy = todayEnergy+ self.peakGridImportKwh = peakGridImportKwh self.note = note } }
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swiftindex efda1ff..22fbe59 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift@@ -53,12 +53,14 @@ struct APIModelsTests { "eOutput": 5.94, "eCharge": 5.7, "eDischarge": 6.8- }+ },+ "peakGridImportKwh": 1.85 } """ let status = try decoder.decode(StatusResponse.self, from: Data(json.utf8)) + #expect(status.peakGridImportKwh == 1.85) #expect(status.live?.ppv == 2400) #expect(status.live?.soc == 62.4) #expect(status.live?.pgridSustained == false)@@ -92,6 +94,7 @@ struct APIModelsTests { #expect(status.rolling15min == nil) #expect(status.offpeak == nil) #expect(status.todayEnergy == nil)+ #expect(status.peakGridImportKwh == nil) } @Test
diff --git a/Flux/FluxTests/HistoryViewModelTests.swift b/Flux/FluxTests/HistoryViewModelTests.swiftindex 53bffd0..039b4a5 100644--- a/Flux/FluxTests/HistoryViewModelTests.swift+++ b/Flux/FluxTests/HistoryViewModelTests.swift@@ -104,6 +104,51 @@ struct HistoryViewModelTests { #expect(abs(gridEntry.offpeakImportKwh - 3.0) < 0.001) } + @Test+ func gridSeriesIncludesTodayWithServerPeakBeforeOffpeakWindow() async throws {+ let modelContext = try makeModelContext()+ let apiClient = MockHistoryAPIClient()+ // now = 00:30 AEST 2026-04-16 — before the 11:00 off-peak window, so+ // there is no off-peak split yet, but the server provides a live peak.+ // The grid entry must still render, with off-peak shown as 0 (T-1420);+ // pre-fix this row was dropped because off-peak was nil.+ apiClient.historyResult = .success(HistoryResponse(days: [+ DayEnergy(+ date: "2026-04-16", epv: 1.0, eInput: 2.4, eOutput: 0.0, eCharge: 0.5, eDischarge: 0.3,+ peakGridImportKwh: 2.4+ )+ ]))++ let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)+ let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })+ await viewModel.loadHistory(days: 7)++ #expect(viewModel.gridSeries.count == 1, "today renders even without an off-peak split")+ let gridEntry = try #require(viewModel.gridSeries.first)+ #expect(gridEntry.dayID == "2026-04-16")+ #expect(gridEntry.isToday)+ #expect(abs(gridEntry.peakImportKwh - 2.4) < 0.001, "peak uses the server value")+ #expect(abs(gridEntry.offpeakImportKwh - 0.0) < 0.001, "off-peak shown as 0 before the window opens")+ }++ @Test+ func gridSeriesOmitsDayWithNeitherPeakNorOffpeak() async throws {+ let modelContext = try makeModelContext()+ let apiClient = MockHistoryAPIClient()+ // A past row with no server peak and no off-peak (e.g. a pre-30-day row+ // under the readings TTL) must stay omitted — showing "off-peak 0"+ // there would be misleading, not merely a not-yet-open window.+ apiClient.historyResult = .success(HistoryResponse(days: [+ DayEnergy(date: "2026-04-10", epv: 5.0, eInput: 4.0, eOutput: 1.0, eCharge: 2.0, eDischarge: 1.5)+ ]))++ let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)+ let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })+ await viewModel.loadHistory(days: 7)++ #expect(viewModel.gridSeries.isEmpty, "no split info — entry omitted, not rendered with off-peak 0")+ }+ @Test func summaryExcludesTodayFromAveragesButCountsOffpeakAlways() async throws { let modelContext = try makeModelContext()
diff --git a/specs/peak-from-readings/decision_log.md b/specs/peak-from-readings/decision_log.mdindex 439b2dd..3533e44 100644--- a/specs/peak-from-readings/decision_log.md+++ b/specs/peak-from-readings/decision_log.md@@ -115,7 +115,7 @@ The original framing of this decision claimed the hourly pass would backfill the ## Decision 4: Today and pre-30-day rows use the iOS fallback **Date**: 2026-05-30-**Status**: accepted+**Status**: accepted; clause 4a (no real-time today peak path) superseded by Decision 9. Clause 4b (pre-30-day rows keep the iOS residual fallback) retained. ### Context @@ -263,7 +263,7 @@ One operator command for both readings-derived grid quantities over the same day ## Decision 8: Extend iOS consumption to Day Detail (past days); defer the Dashboard **Date**: 2026-05-30-**Status**: accepted+**Status**: accepted; the Dashboard deferral is superseded by Decision 9 (today's real-time peak now ships across all three screens). ### Context @@ -302,3 +302,49 @@ Day Detail is a one-field, client-only change reusing data already on the wire a FluxCore `DaySummary` (`APIModels.swift`), `DayCosts.swift`; app-side `SummaryBlock.swift`, `ComparisonSnapshot.swift`. No backend or `/status` change. `DayEnergy.costs` forwarder must pass the new field into the transient `DaySummary` it builds. ---++## Decision 9: Live peak grid import for today across all three screens (supersedes Decision 4a and the Decision 8 Dashboard deferral)++**Date**: 2026-05-30+**Status**: accepted (T-1420, T-1421)++### Context++Decision 4a deliberately left today's peak grid import as the iOS `eInput − offpeak` residual, and Decision 8 deferred the Dashboard for the same reason. Two follow-up tickets reopened this:++- **T-1420** found today's peak/off-peak breakdown was not just inaccurate but frequently *absent* on Day Detail and History. The iOS rendering was parasitic on off-peak presence (`gridEntry` guarded on `offpeakGridImportKwh`; `SummaryBlock.gridInRows` gated on `offpeakGridImport != nil`), and today's off-peak split only exists once the `flux-offpeak` row is created at the 11:00 window start. So from 00:00–11:00 the breakdown vanished entirely, and after 11:00 peak showed the cross-source residual.+- **T-1421** required the Dashboard's today peak to be accurate and, per the new CLAUDE.md "Data Consistency" rule, to read identically to Day Detail and History.++`derivedstats.IntegratePeakGridImportKwh` (Decision 1) cannot be reused for "today so far": it requires **both** windows bracketing off-peak to pass the usability gate, which is wrong for the partial day where the evening window is empty before 14:00.++### Decision++Add a single server-side live peak path, `api.livePeakGridImport`, that integrates `max(pgrid, 0)` directly over the two windows bracketing off-peak — morning `[00:00, min(now, opStart))` and evening `[opEnd, min(now, dayEnd))` — each clamped to `now`, gated on the **morning** window only (evening is additive-when-usable). It mirrors `liveOffpeakDeltas` and is independent of `reconcileEnergy`, so the off-peak sampling artifact never lands on peak. It trims to the Sydney day internally so the value is identical whether the caller passes a rolling-24h slice (`/status`) or today-only readings (`/day`, `/history`).++Expose `peakGridImportKwh` for **today** on `/status` (new `StatusResponse` field), `/day`, and `/history`; past days keep the stored value. On iOS, decode the `/status` field and have the History `gridEntry` and `SummaryBlock.gridInRows` render the peak/off-peak split whenever a peak value is present — server peak (today live, or a stored past day) or an off-peak value for the residual — showing off-peak as `0` (not "missing") before the window opens. Where neither exists (pre-30-day rows, Decision 4b), keep the combined "Grid in" row so a genuinely-unknown split is never shown as off-peak `0`.++### Rationale++Computing the live peak once server-side and consuming it on all three screens is exactly what the Data Consistency rule mandates: one source, identical numbers everywhere. Direct integration carries peak's own ~1.5% sampling artifact proportional to its own kWh rather than stacking the whole day's reconcile mismatch onto the residual. Gating on the morning window (not both) is what makes the partial-day case work, and the internal day-trim removes the only cross-caller divergence (a pre-midnight bracket present on `/status` but not `/day`/`/history`).++### Alternatives Considered++- **iOS rendering decouple only, keep the residual.** Rejected: fixes the "missing breakdown before 11:00" but leaves today's peak on the cross-source residual, so the three screens can still disagree — violating the Data Consistency rule.+- **Reuse `IntegratePeakGridImportKwh` as-is.** Rejected: its both-windows-must-pass gate returns `false` for today before 14:00, so it can never produce a partial-day value.+- **Persist a live peak hourly like past days.** Rejected: today's row is a moving target; live compute on demand is cheaper and avoids cache-invalidation churn (same reasoning as off-peak-from-readings Decision 2/3).++### Consequences++**Positive:**+- Today's peak/off-peak breakdown is present and accurate on Day Detail, History, and the Dashboard at any time of day, and reads identically across them.+- The shared `livePeakGridImport` is built once and consumed by all three endpoints.++**Negative:**+- Peak and off-peak no longer sum exactly to today's displayed `eInput` (which is `max(stored, computed)`); each row is individually accurate but they reconcile to the readings-integrated total, not the `max()`. Observer-visible only, and consistent with the same Day Detail trade-off in Decision 8.+- The iOS `?? residual` fallback paths must stay for pre-30-day rows (Decision 4b retained).++### Impact++Backend: `internal/api/compute.go` (`livePeakGridImport`), `status.go`, `day.go`, `history.go`, `response.go` (new `StatusResponse.PeakGridImportKwh`). iOS: FluxCore `StatusResponse` (`APIModels.swift`), app-side `SummaryBlock.swift`, `HistoryDerivedState.swift`, `DashboardView.swift`.++---
diff --git a/specs/bugfixes/today-peak-grid-import/report.md b/specs/bugfixes/today-peak-grid-import/report.mdnew file mode 100644index 0000000..64e830f--- /dev/null+++ b/specs/bugfixes/today-peak-grid-import/report.md@@ -0,0 +1,111 @@+# Bugfix Report: Today's peak grid import degraded/missing on Day Detail and History++**Date:** 2026-05-30+**Status:** Fixed++## Description of the Issue++Today's peak grid import was not shown accurately — and frequently not at all — on the Day Detail and History screens. The iOS rendering of the peak/off-peak grid-import split was parasitic on the off-peak split being present, and today's off-peak split only exists once the `flux-offpeak` row is created at the 11:00 window start.++**Reproduction steps:**+1. Open the History (or Day Detail) screen for today, before 11:00 local (Australia/Sydney).+2. Observe the grid import card: History omits today's grid entry entirely; Day Detail collapses to a single combined "Grid in" row — no peak/off-peak breakdown.+3. After 11:00, observe that the peak figure is the `eInput − offpeak` residual, which is a cross-source value (today's `eInput` is `max(stored AlphaESS counter, readings integration)` while off-peak is a pure readings integration), so it can be inflated.++**Impact:** User-visible on the two history screens (and the Dashboard, tracked as T-1421) for all of "today" — the most-viewed day. Severity: moderate; data was misleading or absent but not corrupt.++## Investigation Summary++Routed from `/transit` as a bug; investigated with two read-only exploration passes (backend Go, iOS Swift) and direct reads of the live-compute paths.++- **Symptoms examined:** missing grid breakdown before 11:00; residual (not integrated) peak after 11:00.+- **Code inspected:** `internal/api/{compute,status,day,history,response}.go`, `internal/derivedstats/integrate_offpeak.go`; iOS `SummaryBlock.swift`, `HistoryDerivedState.swift`, `APIModels.swift`, `DashboardView.swift`.+- **Hypotheses tested:** initially framed as two defects (accuracy + rendering). Mid-investigation the user pulled PR #58 ("Peak From Readings"). Verified #58 added a server-computed `peakGridImportKwh` for **past days only** (its commit and Decision 4a explicitly exclude a real-time path for today) and did **not** touch the off-peak rendering gate. Confirmed both the live-today accuracy gap and the rendering gate remained.++## Discovered Root Cause++Two independent defects, both scoped to "today":++1. **Rendering gate (the visible bug).** `HistoryDerivedState.gridEntry` guarded `guard let offpeakImport = day.offpeakGridImportKwh else { return nil }`, and `SummaryBlock.gridInRows` rendered the split only `if offpeakGridImport != nil`. Today's off-peak split does not exist before the 11:00 window opens, so the breakdown was dropped.+2. **Cross-source residual (the accuracy bug).** No server-side live peak existed for today; iOS derived peak as `max(0, eInput − offpeak)`. For today, `eInput = reconcileEnergy(computed, stored) = max(readings, AlphaESS snapshot)` while `offpeak` is a pure readings integration, so subtracting the two mixes sources and inflates peak when the snapshot wins the reconcile.++**Defect type:** Logic/coupling error (presentation coupled to an unrelated data dependency) + missing computation path.++**Why it occurred:** Off-peak-from-readings introduced the live off-peak split at the window start and the residual-for-today peak; the iOS card was wired to off-peak presence as the proxy for "do we have a split." Peak-from-readings (#58) then added an accurate server peak but deliberately deferred today (Decision 4a) and the Dashboard (Decision 8).++**Contributing factors:** `derivedstats.IntegratePeakGridImportKwh` requires **both** windows bracketing off-peak to pass the usability gate, which is wrong for the partial "today so far" case (the evening window is empty before 14:00), so it could not be reused as-is.++## Resolution for the Issue++Built a single server-side live-peak path consumed by all three "today" surfaces, and decoupled the iOS rendering from off-peak presence. Per the new CLAUDE.md "Data Consistency" rule (today's peak must read identically on Dashboard, Day Detail, History) the work folds in T-1421.++**Changes made:**+- `internal/api/compute.go` — new `livePeakGridImport(readings, now, offpeakStart, offpeakEnd) (float64, bool)`: integrates `max(pgrid,0)` over morning `[00:00, min(now, opStart))` + evening `[opEnd, min(now, dayEnd))`, gated on the morning window only (evening additive-when-usable), independent of `reconcileEnergy`. Trims to the Sydney day internally so `/status` (24h slice) and `/day`/`/history` (today-only) yield identical values.+- `internal/api/status.go` — set new `resp.PeakGridImportKwh` from `livePeakGridImport`.+- `internal/api/response.go` — new `StatusResponse.PeakGridImportKwh *float64 json:"peakGridImportKwh,omitempty"`.+- `internal/api/day.go` / `internal/api/history.go` — for today, set `peakGridImportKwh` from `livePeakGridImport`; past days keep the stored value.+- `Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift` — decode `StatusResponse.peakGridImportKwh`.+- `Flux/Flux/Helpers/SummaryBlock.swift` — render the split whenever a peak value is present (`serverPeakGridImport != nil || offpeakGridImport != nil`), off-peak shown as `0` before the window; combined row only when neither exists. New `todayEnergy:` init accepts `serverPeakGridImport`.+- `Flux/Flux/History/HistoryDerivedState.swift` — `gridEntry` prefers the server peak, falls back to the residual, omits only when neither peak nor off-peak is known.+- `Flux/Flux/Dashboard/DashboardView.swift` — pass `status.peakGridImportKwh` into `SummaryBlock`.++**Approach rationale:** Computing the live peak once server-side and consuming it everywhere is exactly what the Data Consistency rule mandates. Direct integration carries peak's own ~1.5% sampling artifact rather than stacking the day's reconcile mismatch onto the residual. The server-peak-presence signal cleanly separates "off-peak genuinely 0 (window not open)" from "off-peak unknown" so a missing split is never shown as a misleading off-peak `0`.++**Alternatives considered:**+- iOS rendering decouple only (keep the residual) — rejected: leaves today's peak cross-source so the three screens can still disagree.+- Reuse `IntegratePeakGridImportKwh` — rejected: its both-windows gate can't produce a partial-day value before 14:00.++## Regression Test++**Test files:** `internal/api/compute_test.go`, `internal/api/peak_grid_import_test.go`, `Flux/FluxTests/HistoryViewModelTests.swift`, `Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift`++**Key test names:**+- Go: `TestLivePeakGridImport`, `TestLivePeakGridImportGates`, `TestHandleDayTodayLivePeakGridImport`, `TestHandleHistoryTodayLivePeakGridImport`, `TestHandleStatusLivePeakGridImport`, `TestTodayPeakGridImportConsistentAcrossEndpoints` (asserts `/status` == `/day` == `/history` for today — the Data Consistency guarantee — with pre-midnight readings to prove the internal day-trim)+- Swift: `gridSeriesIncludesTodayWithServerPeakBeforeOffpeakWindow`, `gridSeriesOmitsDayWithNeitherPeakNorOffpeak`, `decodeFullStatusResponse` (extended)++**What they verify:** the morning/during/after-off-peak integration semantics and gates; that `/status`, `/day`, `/history` expose today's live peak independent of the off-peak split; that History renders today's grid entry (off-peak shown as 0) before 11:00 and still omits a genuinely-split-less past row.++**Run command:** `go test ./...` and `make macos-test`++## Affected Files++| File | Change |+|------|--------|+| `internal/api/compute.go` | New `livePeakGridImport` |+| `internal/api/status.go` | Populate `peakGridImportKwh` |+| `internal/api/response.go` | New `StatusResponse.PeakGridImportKwh` |+| `internal/api/day.go` | Today uses live peak; past uses stored |+| `internal/api/history.go` | Today row uses live peak; past uses stored |+| `internal/api/compute_test.go` | `livePeakGridImport` unit tests |+| `internal/api/peak_grid_import_test.go` | Today live-peak handler tests |+| `Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift` | Decode `peakGridImportKwh` on `StatusResponse` |+| `Flux/Flux/Helpers/SummaryBlock.swift` | Decouple split from off-peak presence; accept server peak |+| `Flux/Flux/History/HistoryDerivedState.swift` | `gridEntry` decouple |+| `Flux/Flux/Dashboard/DashboardView.swift` | Pass `/status` peak into `SummaryBlock` |+| `Flux/.../APIModelsTests.swift`, `Flux/FluxTests/HistoryViewModelTests.swift` | Regression tests |+| `specs/peak-from-readings/decision_log.md` | Decision 9 (supersedes 4a + Decision 8 deferral) |++## Verification++**Automated:**+- [x] Regression tests pass (new Go + Swift tests green)+- [x] Full Go suite passes (`go test ./...` exit 0); macOS build succeeds; Swift peak/grid/status/dashboard tests pass+- [x] Linters pass for changed code: `golangci-lint` 0 issues; SwiftLint reports no violations in changed files (the `HistoryDerivedState.swift` `file_length` flag under `--strict` is pre-existing — origin/main is already 401 lines, and origin/main has 73 such `--strict` violations, so the project does not enforce `--strict`)++**Known unrelated test failures (pre-existing, environmental):**+- `KeychainAccessibilityMigratorTests` (2 cases) fail deterministically in the local test host (keychain class-migration under "Sign to Run Locally"); keychain code is byte-identical to origin/main.+- `DashboardViewModel*` concurrency/tier tests are flaky under full-suite contention; all pass in isolation.++**Manual verification:** Not run on-device; behaviour is covered by the handler + view-model tests. The data path is deterministic given `now` and the readings slice.++## Prevention++- Avoid coupling presentation to an indirect data dependency (here, gating the peak row on off-peak presence). Render a value when *that* value is available.+- When adding a derived metric shown on multiple screens, compute it once (server-side where possible) per the CLAUDE.md Data Consistency rule, rather than re-deriving per screen.+- When a shared integrator has a usability gate spanning multiple windows, provide a partial-window variant for live "so far today" rather than overloading the all-windows one.++## Related++- Transit: T-1420 (this bug), T-1421 (Dashboard real-time peak, folded in)+- Spec: `specs/peak-from-readings/` — Decision 1, 4, 8, and new **Decision 9**+- PR #58 "Peak From Readings" (server peak for past days; this builds the today/live path it deferred)
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 21270e0..cdb4186 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Today's peak grid import now accurate and always shown on Day Detail, History, and the Dashboard** (T-1420, T-1421). Today's peak/off-peak grid-import split was parasitic on the off-peak split existing: iOS gated the rows on `offpeakGridImportKwh != nil`, and today's off-peak row is only created at the 11:00 window start, so before 11:00 History dropped today's grid entry entirely and Day Detail collapsed to a single combined "Grid in" row; after 11:00 peak showed the cross-source `eInput − offpeak` residual (today's `eInput` is `max(stored AlphaESS counter, readings integration)` while off-peak is a pure integration, so the off-peak sampling loss could land on peak). A new server-side `api.livePeakGridImport` integrates `max(pgrid,0)` directly over the two windows bracketing off-peak — morning `[00:00, min(now, offpeak-start))` and evening `[offpeak-end, min(now, day-end))` — clamped to `now` and gated on the morning window only (the evening window is empty before 14:00, so `derivedstats.IntegratePeakGridImportKwh`, which requires both windows to pass, cannot serve the partial day). It trims to the Sydney day internally so `/status` (rolling 24 h) and `/day` / `/history` (today-only) return an identical value, independent of `reconcileEnergy`. `peakGridImportKwh` is now exposed for today on `/status` (new `StatusResponse` field), `/day`, and `/history`; past days keep the stored value. On iOS, `StatusResponse` decodes the field and History's grid entry + `SummaryBlock` render the peak/off-peak split whenever a peak value is present (off-peak shown as 0 before the window opens), falling back to the residual and collapsing to a single combined row only when neither peak nor off-peak is known (pre-30-day rows). Supersedes peak-from-readings Decision 4a and the Decision 8 Dashboard deferral (new Decision 9). No chart axes, colours, labels, or row layouts change. - **"Won't empty before" hero line now shows the power-flow rate**. The Dashboard hero's off-peak indicator (the "Won't empty before HH:MM" state from T-1327) previously dropped the live charge/discharge rate that the normal "empty by" status line carries. It now leads with the same `Discharging · 2.40 kW` / `Charging · 1.20 kW` prefix — e.g. `Discharging · 400 W · won't empty before 11:00`. The whole line stays in secondary text (the off-peak time is deliberately not amber, per the feature's Decision 9 — it is an informational state, not a cutoff warning). When the battery is idle or live data is missing there is no meaningful rate, so it falls back to the bare `Won't empty before HH:MM` text. The VoiceOver label (`Battery won't empty before off-peak at HH:MM`, the requirement contract) is unchanged. - **Off-peak window deltas no longer count grid-charging tail as peak** (T-1341). The five off-peak energy deltas (grid usage, grid export, solar, battery charge, battery discharge) are now computed by trapezoidal integration of the 10-second `flux-readings` series over the window, replacing the previous `endE* − startE*` snapshot diff. AlphaESS's intraday `eInput` lags physical reality by 15–30 minutes, so the 14:00 snapshot routinely missed the tail of grid-charging (~4× overcount on the History peak imports tile; 2026-05-18 reference: meter reported 0.17 / 20.75 kWh peak/off-peak, Flux reported 1.74 / 18.95). All three computation sites move together: the poller's window-end finalisation (`internal/poller/offpeak.go`) now waits for a reading at-or-after `offpeak-end` (30 s budget) before integrating, so a missing post-window reading defers rather than commits a partial window; the live `/status` and `/day` paths (`internal/api/compute.go`, `status.go`, `history.go`) integrate the in-memory today readings up to `min(now, offpeak-end)` for pending rows and return the stored row for completed days; past-day reads serve the persisted integration. A new `derivedstats.IntegrateOffpeakDeltas` reuses the per-sample clamping, 60 s gap rule, and boundary interpolation already shipped for `integratePload` / `integratePpv`, so the off-peak path matches the existing live-energy integrator. Three provenance fields on `OffpeakItem` (`integrationSampleCount`, `integrationSkippedPairs`, `integratedAt`) record what each row was built from; the original snapshot fields are retained for drift logging. Conditional writes (`WriteOffpeakIfPendingOrAbsent` and `WriteOffpeakIfComplete`) make the poller-vs-backfill race safe. A new one-shot `cmd/backfill-offpeak` CLI (mirroring `cmd/backfill-solar`) recomputes any retained `flux-offpeak` row within the readings TTL window; the CLI never touches today's row and emits a five-column per-day summary (`prev→new |Δ|=abs` for all five deltas plus sample / skipped counts). Drift logging on every write surfaces snapshot-vs-integration divergence in CloudWatch.
max(stored, computed). Observer-visible only; the screens never show a separate “grid in total” row alongside the split.KeychainAccessibilityMigratorTests fail deterministically under the locally-signed macOS test host; the keychain code is byte-identical to origin/main. Dashboard concurrency tests are flaky under full-suite contention and pass in isolation. Neither relates to this change.