flux branch T-1533/offpeak-charge-projection commits 7 unpushed files 16 touched (10 code, 6 docs/spec) lines +1125 / -8

Pre-push review: off-peak charge projection (T-1533)

Server-computed projected battery SoC at the off-peak window end, surfaced as one contextual row on the Dashboard. Reviewed against specs/offpeak-charge-projection/.

At a glance

  • New pure Go function projectOffpeakEndSoc — a closed-form two-rate charge curve (4.5 kW <95%, then 0.5 kW), clamped to [soc, 100], rounded to 1 dp.
  • Reuses the same capacity variable as EstimatedCutoff (AC 1.4) — the two figures cannot disagree about capacity by construction.
  • Independent of Pbat and simulateLoadWatts by signature, so "unchanged under simulation" (AC 2.4) holds structurally, not via a suppression branch.
  • ProjectedEndSoc *float64 with no omitempty → explicit JSON null when absent (AC 3.2); Swift Double? decodes present / null / absent.
  • Dashboard shows one offpeakRow — the projection row takes precedence over the off-peak delta row (Decision 9); Day Detail / History are unaffected (params default nil).
  • Two minor review nits fixed: window-end now uses the shared startOfDaySydney helper; the "Lowest" row's last: reads offpeakRow == nil.

Verdict

Ready to push

The implementation is faithful to the spec and all 9 decisions; every acceptance criterion maps to code and a test. Go build/test/lint and macOS build/lint/tests are green. Two minor cleanups raised by the review (a duplicated Sydney-midnight construction and a duplicated precedence condition) were fixed and re-verified with no behaviour change. Nothing blocks the push.

Review findings

6 raised · 2 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What this does

During the cheap off-peak charging window, the Dashboard now shows a line like "Projected at 14:00 — 97.5%": an estimate of how full the battery will be when cheap charging ends, assuming it keeps charging at full speed.

Why it matters

It answers "will my battery be full enough before cheap power ends?" without watching the charge climb. It only appears while it's useful (inside the window with live data) and is a deliberately optimistic best-case figure.

Key concepts

  • SoC — state of charge, how full the battery is (0–100%).
  • Off-peak window — the daily cheap-power window (11:00–14:00) when the battery charges from the grid.
  • Two-rate charging — fast (4.5 kW) up to 95%, then slow (500 W) to 100%, like easing off when a glass is nearly full.

Changes overview

Backend (internal/api/): new projectOffpeakEndSoc + charge constants in compute.go; ProjectedEndSoc *float64 on OffpeakData in response.go; wiring in status.go after buildOffpeak on the fresh-live branch. App: projectedEndSoc: Double? on FluxCore OffpeakData; an offpeakRow selector in BatteryBlock.swift; DashboardView wiring.

Implementation approach

A closed-form curve, not a simulation loop. With r(kW) = kW / capacityKwh × 100 and h hours to window end: soc≥100→100; soc≥95→soc+r(0.5)·h; else charge at 4.5 kW until 95% then trickle for the remaining time. Clamp to [soc,100], round with roundPower. Gated to nil outside the window / unparseable window / capacity≤0, plus the caller's liveFresh gate. The app consolidates the off-peak row into one computed property where the projection takes precedence over the delta row.

Trade-offs

Server-side single source (every client agrees); field on OffpeakData co-locates value with its windowEnd label; idealised + Pbat-independent (optimistic but deterministic); hardcoded constants (no config surface).

Technical deep dive

Capacity parity (AC 1.4): passes the same capacity local as computeCutoffTime — no second lookup. Window-end: startOfDaySydney(now).Add(endMin); the gate compares minute-of-day while h is seconds-precise, and the boundary mismatch (e.g. 13:59:30) is absorbed by the [soc,100] clamp, with 14:00:30 already failing the minute gate. DST-safe (window far from 02:00/03:00). Serialization (AC 3.2): pointer, no omitempty → explicit null; Swift Double? treats null/absent identically. SwiftUI: offpeakRow is the single source of truth; last: offpeakRow == nil after the review fix.

Architecture impact

No stored-model/DynamoDB change — derived live, no migration. The hot path re-parses the window a couple more times (allocation-free byte arithmetic on 5-char strings) — unmeasurable against four concurrent DynamoDB queries, and consistent with the handler's self-contained-helper convention. Day Detail / History unaffected (defaulted-nil params).

Potential issues

Optimistic by design (ignores derating + round-trip losses) — the most likely source of "it didn't reach that" feedback, but the accepted definition. Hardcoded rates need a code change if hardware changes.

Important changes — detailed

compute.go: closed-form two-rate projection

compute.go

Why it matters. The core of the feature. Correctness of the SoC math and the gating (window / capacity / nil) determines every downstream value and test.

What to look at. internal/api/compute.go — projectOffpeakEndSoc + constants

Takeaway. A two-segment charge curve is cleaner as a closed form (hoursTo95 split) than a time-stepping loop — O(1), deterministic, and trivially property-testable for monotonicity and clamp bounds.
Rationale. Decisions 3/4/7: hardcoded rates for a fixed battery, idealised best-case (no Pbat/loss blending), tie-break at 95% resolves to the trickle rate.

status.go: wiring that reuses the cutoff capacity

status.go

Why it matters. AC 1.4 (capacity parity) and AC 2.2 (fresh-live gate) both hold here. The placement after buildOffpeak and the reuse of the existing capacity local are the load-bearing details.

What to look at. internal/api/status.go — liveFresh branch after resp.Offpeak assignment

Takeaway. Sharing one already-resolved variable between two derived metrics makes consistency structural rather than a convention a future editor could break.
Rationale. Decision 8: compute outside buildOffpeak (which only knows energy deltas) and reuse the EstimatedCutoff capacity for guaranteed agreement.

response.go: explicit-null contract

response.go

Why it matters. AC 3.2 — clients must distinguish "no projection" from a real value. The pointer + no-omitempty choice is what makes absence serialise as null.

What to look at. internal/api/response.go:86 — ProjectedEndSoc *float64 json:"projectedEndSoc"

Takeaway. Pointer + omit the omitempty tag is the idiomatic Go way to expose a tri-state (value / explicit null / —) over JSON; mirrors the existing EstimatedCutoff field.
Rationale. Mirrors EstimatedCutoff so the whole response uses one absent-value convention.

BatteryBlock.swift: single mutually-exclusive off-peak row

BatteryBlock.swift

Why it matters. Decision 9 precedence lives here. Getting the selection wrong would show two off-peak rows (one a stray dash) during the window.

What to look at. Flux/Flux/Helpers/BatteryBlock.swift — offpeakRow computed property; last: offpeakRow == nil

Takeaway. Collapsing a precedence rule into one optional-returning computed property gives a single testable source of truth and lets dependent layout (the last: flag) read from it instead of re-deriving the condition.
Rationale. Decision 9: projection and delta never co-occur, so precedence is the clean expression of mutual exclusion; exposed as internal for logic tests without view hosting.

APIModels.swift: backward-compatible model field

APIModels.swift

Why it matters. The wire contract on the client. The trailing defaulted init parameter is what keeps the three existing OffpeakData construction sites compiling unchanged.

What to look at. Flux/Packages/FluxCore/.../APIModels.swift — projectedEndSoc: Double? + defaulted init param

Takeaway. Adding an optional with a trailing default to a memberwise-style init is a non-breaking way to extend a Codable model; Double? decodes JSON null and absent identically.
Rationale. Default nil preserves MockFluxAPIClient / OffPeakBlock / WidgetFixtures call sites.

Key decisions

Field on OffpeakData, not BatteryInfo.

Co-locates the projected value with the windowEnd that labels it, making the label/value pairing (AC 4.2) a single-object read, and keeps the SoC/capacity/freshness inputs out of buildOffpeak (which only deals with energy deltas). Decision 8.

Idealised, Pbat- and simulation-independent.

The function signature cannot see live battery power or simulated load, so AC 1.9 / AC 2.4 hold by construction rather than via a suppression branch (contrast CantEmptyBeforeOffpeak). Cost: optimistic vs real charging. Decisions 4 & 6.

Hardcoded charge-curve constants.

4.5 kW / 0.5 kW / 95% are Go constants mirroring maxDischargeKW — Flux monitors one fixed battery, so they change only with the hardware. No SSM/env config surface. Decision 3.

Window-end via startOfDaySydney (review fix).

The inlined time.Date(...sydneyTZ...) was replaced with the existing startOfDaySydney helper, keeping the Sydney-midnight construction in one place. Behaviour identical; tests unchanged.

last: offpeakRow == nil (review fix).

The "Lowest" row's last: flag previously re-derived !(projectedOffpeakEndSoc != nil || rendersOffpeakDelta). It now reads the offpeakRow optional directly, so the two-row precedence rule is expressed in exactly one place.

Review findings

SeverityAreaFindingResolution
minorcompute.go:244 (code reuse)projectOffpeakEndSoc inlined time.Date(local.Year(), local.Month(), local.Day(), 0,0,0,0, sydneyTZ), duplicating the existing startOfDaySydney(now) helper (the function's own comment even noted the duplication).Replaced with startOfDaySydney(now).Add(endMin); kept the local := now.In(sydneyTZ) needed for the h subtraction. go build/test/lint green, projection fixtures unchanged.
minorBatteryBlock.swift:45 (single source of truth)The "Lowest" row's last: re-derived the precedence condition !(projectedOffpeakEndSoc != nil || rendersOffpeakDelta), duplicating logic that offpeakRow already encodes.Changed to last: offpeakRow == nil. macOS build + BatteryBlock tests green, behaviour identical.
minorcompute.go / status.go (efficiency)projectOffpeakEndSoc calls withinOffpeakWindow (which parses the window) and then ParseOffpeakWindow again for endMin; the handler parses the window 4-5 times per request overall.Skipped — ParseOffpeakWindow is allocation-free byte arithmetic on two 5-char strings; tens of nanoseconds against four concurrent DynamoDB queries. Re-parsing per self-contained helper is the established handler convention; threading parsed minutes through every consumer is a handler-wide change for zero observable benefit.
minorBatteryBlock.swift (encapsulation)rendersOffpeakDelta and offpeakDeltaText were widened from private to internal to enable logic tests.Skipped — accepted trade-off, documented in the code; the repo's Swift rules say to test view-driving logic, not the view body. The properties are pure and side-effect-free.
minorBatteryBlock.swift (stringly-typed)Row labels ("Projected at …", "off-peak end" fallback, "Charged during off-peak") are inline string literals.Skipped — every FluxStatRow label across the codebase is an inline literal; there is no label-constant/enum pattern to deviate from and the app isn't localised. Introducing constants here would be inconsistent.
minorCLAUDE.md (docs)The architecture section's derived-stats list could mention the projection.Skipped — the list is explicitly illustrative and already non-exhaustive (omits low24h, pgridSustained, cantEmptyBeforeOffpeak). Adding one would be inconsistent; CHANGELOG.md and specs/OVERVIEW.md were updated instead.

Per-file diffs

Click to expand.

internal/api/compute.go Modified +59 / -1
diff --git a/internal/api/compute.go b/internal/api/compute.goindex be735b6..565bca8 100644--- a/internal/api/compute.go+++ b/internal/api/compute.go@@ -209,6 +209,65 @@ func computeCutoffTime(soc, pbat, capacityKwh, cutoffPercent float64, now time.T 	return &t } +// Off-peak charge-curve constants (Decision 3). Hardcoded like maxDischargeKW+// because Flux monitors a single, fixed battery system — these change only if+// the hardware changes. The tie-break at fastChargeMaxSoc is `>=` → trickle+// rate (Decision 7).+const (+	offpeakChargeRateKW  = 4.5  // grid charge rate while SoC < fastChargeMaxSoc+	offpeakTrickleRateKW = 0.5  // 500 W from fastChargeMaxSoc to 100%+	fastChargeMaxSoc     = 95.0 // percent+)++// projectOffpeakEndSoc projects the battery SoC (percent) at the off-peak+// window end using the idealised two-rate charge curve: offpeakChargeRateKW+// while SoC < fastChargeMaxSoc, then offpeakTrickleRateKW up to 100%.+//+// Returns nil when the window is unparseable, now is outside [start, end), or+// capacity is non-positive. The result is clamped to [soc, 100] and rounded to+// 1 dp. The projection is a best-case figure independent of the live charge+// power: it never reads Pbat or the simulated load, so AC 1.9 and AC 2.4 hold+// by construction (Decision 4, Decision 6).+func projectOffpeakEndSoc(soc, capacityKwh float64, now time.Time, offpeakStart, offpeakEnd string) *float64 {+	if capacityKwh <= 0 || !withinOffpeakWindow(now, offpeakStart, offpeakEnd) {+		return nil+	}+	_, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)+	if !ok {+		return nil+	}++	// Window-end instant: today's Sydney-local midnight + endMin, same+	// construction as nextOffpeakStart. withinOffpeakWindow gates on+	// minute-of-day so now is always before this instant here; h is a+	// positive, seconds-precise duration absorbed by the [soc, 100] clamp.+	local := now.In(sydneyTZ)+	windowEnd := startOfDaySydney(now).Add(time.Duration(endMin) * time.Minute)+	h := windowEnd.Sub(local).Hours()++	// r converts a charge power (kW) to a SoC rate (percent per hour).+	r := func(kw float64) float64 { return kw / capacityKwh * 100 }++	var projected float64+	switch {+	case soc >= 100:+		projected = 100+	case soc >= fastChargeMaxSoc:+		projected = soc + r(offpeakTrickleRateKW)*h+	default:+		hoursTo95 := (fastChargeMaxSoc - soc) / r(offpeakChargeRateKW)+		if hoursTo95 >= h {+			projected = soc + r(offpeakChargeRateKW)*h+		} else {+			projected = fastChargeMaxSoc + r(offpeakTrickleRateKW)*(h-hoursTo95)+		}+	}++	projected = max(soc, min(100, projected))+	projected = roundPower(projected)+	return &projected+}+ // cantEmptyInput bundles the inputs to computeCantEmptyBeforeOffpeak. // // Soc is the latest battery SOC (percent). CapacityKwh is the configured
internal/api/response.go Modified +7 / -0
diff --git a/internal/api/response.go b/internal/api/response.goindex 8385711..af826c2 100644--- a/internal/api/response.go+++ b/internal/api/response.go@@ -67,6 +67,12 @@ type RollingAvg struct { // written, or "pending" while the window is open and deltas are derived // from the current daily-energy snapshot. Empty when no record exists or // when deltas cannot be computed.+//+// ProjectedEndSoc is the idealised SoC (percent) the battery will reach by+// WindowEnd if charging continues at full capability. It is a pointer with no+// omitempty so absence serialises as explicit JSON null (mirrors+// EstimatedCutoff): null means "no projection — outside the window or no fresh+// live data", which clients must distinguish from a real value. See AC 3.2. type OffpeakData struct { 	WindowStart         string   `json:"windowStart"` 	WindowEnd           string   `json:"windowEnd"`@@ -77,6 +83,7 @@ type OffpeakData struct { 	BatteryDischargeKwh *float64 `json:"batteryDischargeKwh"` 	GridExportKwh       *float64 `json:"gridExportKwh"` 	BatteryDeltaPercent *float64 `json:"batteryDeltaPercent"`+	ProjectedEndSoc     *float64 `json:"projectedEndSoc"` }  // TodayEnergy contains cumulative energy totals for the current day.
internal/api/status.go Modified +15 / -0
diff --git a/internal/api/status.go b/internal/api/status.goindex 7df67a2..9fd3f99 100644--- a/internal/api/status.go+++ b/internal/api/status.go@@ -244,6 +244,21 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 	// from the readings already in memory for live compute). 	resp.Offpeak = buildOffpeak(opItem, allReadings, now, h.offpeakStart, h.offpeakEnd) +	// Projected SoC at the off-peak window end (T-1533). Computed only on the+	// fresh-live branch — same gate as EstimatedCutoff (AC 2.2) — and reusing+	// the `capacity` variable already resolved above so the two figures never+	// disagree about capacity (AC 1.4). projectOffpeakEndSoc returns nil+	// outside the window, on an unparseable window, or for non-positive+	// capacity; it never reads Pbat or the simulated load, so an active+	// simulation leaves the projection unchanged (AC 1.9, AC 2.4). resp.Offpeak+	// is always non-nil here (buildOffpeak always returns window times).+	if liveFresh {+		latest := allReadings[len(allReadings)-1]+		if p := projectOffpeakEndSoc(latest.Soc, capacity, now, h.offpeakStart, h.offpeakEnd); p != nil {+			resp.Offpeak.ProjectedEndSoc = p+		}+	}+ 	// 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
internal/api/compute_test.go Modified +148 / -0
diff --git a/internal/api/compute_test.go b/internal/api/compute_test.goindex 998e846..40875e8 100644--- a/internal/api/compute_test.go+++ b/internal/api/compute_test.go@@ -3,6 +3,7 @@ package api import ( 	"math" 	"testing"+	"testing/quick" 	"time"  	"github.com/ArjenSchwarz/flux/internal/derivedstats"@@ -1149,3 +1150,150 @@ func TestReconcileEnergy(t *testing.T) { 		}) 	} }++func TestProjectOffpeakEndSoc(t *testing.T) {+	// Window 11:00-14:00 in Sydney local time; capacity 13.34 kWh. These are+	// the design.md Testing Strategy fixtures, which double as the deferred+	// worked example from decision_log Decision 7.+	const (+		start = "11:00"+		end   = "14:00"+		cap   = 13.34+	)+	at := func(h, m int) time.Time {+		return time.Date(2026, 4, 15, h, m, 0, 0, sydneyTZ)+	}++	tests := map[string]struct {+		now  time.Time+		soc  float64+		cap  float64+		want *float64+	}{+		"crosses 95 boundary": {+			now: at(12, 0), soc: 50, cap: cap, want: floatPtr(97.5),+		},+		"fast rate only never reaches 95": {+			now: at(13, 30), soc: 40, cap: cap, want: floatPtr(56.9),+		},+		"already in trickle band clamps to 100": {+			now: at(13, 0), soc: 97, cap: cap, want: floatPtr(100.0),+		},+		"crosses 95 mid window": {+			now: at(13, 0), soc: 90, cap: cap, want: floatPtr(98.2),+		},+		"already full": {+			now: at(12, 0), soc: 100, cap: cap, want: floatPtr(100.0),+		},+		"before window returns nil": {+			now: at(10, 0), soc: 50, cap: cap, want: nil,+		},+		"after window returns nil": {+			now: at(14, 30), soc: 50, cap: cap, want: nil,+		},+		"zero capacity returns nil": {+			now: at(12, 0), soc: 50, cap: 0, want: nil,+		},+		"negative capacity returns nil": {+			now: at(12, 0), soc: 50, cap: -5, want: nil,+		},+		// 95% tie-break: SoC exactly 95 charges at the 500 W trickle rate. Over+		// the 2h from 12:00 the trickle gain is 0.5/13.34*100*2 = 7.5, so+		// 95 -> 102.5, clamped to 100.+		"soc exactly 95 uses trickle rate": {+			now: at(12, 0), soc: 95, cap: cap, want: floatPtr(100.0),+		},+	}++	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			got := projectOffpeakEndSoc(tc.soc, tc.cap, tc.now, start, end)+			if tc.want == nil {+				assert.Nil(t, got)+				return+			}+			require.NotNil(t, got)+			assert.InDelta(t, *tc.want, *got, 1e-9)+		})+	}+}++// TestPropertyProjectOffpeakEndSoc exercises the closed-form curve's+// invariants over random inputs: the result is always within [soc, 100], and+// it is monotonic non-decreasing in both the hours remaining and the starting+// SoC. testing/quick generates inputs independently, so each monotonicity+// property constructs a second input that differs from the generated base in+// exactly one dimension.+func TestPropertyProjectOffpeakEndSoc(t *testing.T) {+	const (+		start = "11:00"+		end   = "14:00"+	)+	// nowFor maps a fraction f in [0,1] onto an instant inside the window+	// [11:00, 14:00). f=0 is 11:00 (3h remaining); f→1 approaches 14:00+	// (0h remaining). A larger f means fewer hours remaining.+	nowFor := func(f float64) time.Time {+		f = math.Mod(math.Abs(f), 1)+		// Cap below 1 so we never land exactly on (or past) the window end,+		// where the minute-of-day gate flips to false.+		secs := int(f * (3*3600 - 1))+		return time.Date(2026, 4, 15, 11, 0, 0, 0, sydneyTZ).Add(time.Duration(secs) * time.Second)+	}+	// normSoc maps an arbitrary float onto [0, 100].+	normSoc := func(x float64) float64 {+		return math.Mod(math.Abs(x), 100.0000001)+	}+	// normCap maps an arbitrary float onto a positive capacity in (0, ~100].+	normCap := func(x float64) float64 {+		return math.Mod(math.Abs(x), 100) + 1+	}++	t.Run("result within [soc, 100]", func(t *testing.T) {+		f := func(socRaw, capRaw, fRaw float64) bool {+			soc := normSoc(socRaw)+			capKwh := normCap(capRaw)+			got := projectOffpeakEndSoc(soc, capKwh, nowFor(fRaw), start, end)+			if got == nil {+				return false // always in-window with positive capacity+			}+			return *got >= soc-1e-9 && *got <= 100+1e-9+		}+		require.NoError(t, quick.Check(f, nil))+	})++	t.Run("monotonic non-decreasing in hours remaining", func(t *testing.T) {+		f := func(socRaw, capRaw, fRaw float64) bool {+			soc := normSoc(socRaw)+			capKwh := normCap(capRaw)+			f0 := math.Mod(math.Abs(fRaw), 1)+			// Earlier now (more hours remaining) vs the same instant moved+			// later (fewer hours). More hours must not yield a lower SoC.+			fLate := f0 + (1-f0)/2 // strictly later than f0, still < 1+			early := projectOffpeakEndSoc(soc, capKwh, nowFor(f0), start, end)+			late := projectOffpeakEndSoc(soc, capKwh, nowFor(fLate), start, end)+			if early == nil || late == nil {+				return false+			}+			return *early >= *late-1e-9+		}+		require.NoError(t, quick.Check(f, nil))+	})++	t.Run("monotonic non-decreasing in soc", func(t *testing.T) {+		f := func(socRaw, capRaw, fRaw float64) bool {+			capKwh := normCap(capRaw)+			now := nowFor(fRaw)+			lowSoc := math.Mod(math.Abs(socRaw), 100)+			// Same now and capacity; a higher starting SoC must not project a+			// lower end SoC.+			highSoc := lowSoc + (100-lowSoc)/2 // strictly greater, <= 100+			low := projectOffpeakEndSoc(lowSoc, capKwh, now, start, end)+			high := projectOffpeakEndSoc(highSoc, capKwh, now, start, end)+			if low == nil || high == nil {+				return false+			}+			return *high >= *low-1e-9+		}+		require.NoError(t, quick.Check(f, nil))+	})+}
internal/api/status_test.go Modified +138 / -0
diff --git a/internal/api/status_test.go b/internal/api/status_test.goindex ce61a35..b42a22d 100644--- a/internal/api/status_test.go+++ b/internal/api/status_test.go@@ -1059,6 +1059,144 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 	}) } +// TestHandleStatusProjectedEndSoc covers the off-peak charge projection on the+// /status response (T-1533): it is present only inside the window on fresh live+// data, absent (explicit JSON null) otherwise, and unaffected by an active load+// simulation.+func TestHandleStatusProjectedEndSoc(t *testing.T) {+	// 12:00 Sydney, two hours before the 14:00 window end. With SoC 50 and the+	// fallback 13.34 kWh capacity the closed-form curve projects 97.5%.+	insideWindow := time.Date(2026, 4, 15, 12, 0, 0, 0, sydneyTZ)++	freshReadingsAt := func(now time.Time, soc float64) func(context.Context, string, int64, int64) ([]dynamo.ReadingItem, error) {+		return func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			nowUnix := now.Unix()+			return []dynamo.ReadingItem{+				{Timestamp: nowUnix - 20, Ppv: 0, Pload: 200, Pbat: -3000, Pgrid: 3200, Soc: soc},+				{Timestamp: nowUnix - 10, Ppv: 0, Pload: 200, Pbat: -3000, Pgrid: 3200, Soc: soc},+			}, nil+		}+	}++	t.Run("inside window and fresh live emits projection", func(t *testing.T) {+		mr := &mockReader{+			queryReadingsFn: freshReadingsAt(insideWindow, 50),+			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 insideWindow }++		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.Offpeak)+		require.NotNil(t, sr.Offpeak.ProjectedEndSoc, "projection present inside window with fresh live data")+		assert.InDelta(t, 97.5, *sr.Offpeak.ProjectedEndSoc, 1e-9)+	})++	t.Run("outside window emits null", func(t *testing.T) {+		// 10:00 Sydney — before the 11:00 window start.+		outside := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)+		mr := &mockReader{+			queryReadingsFn: freshReadingsAt(outside, 50),+			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 outside }++		resp, err := h.Handle(context.Background(), statusRequest())+		require.NoError(t, err)+		sr := parseStatusResponse(t, resp)+		require.NotNil(t, sr.Offpeak)+		assert.Nil(t, sr.Offpeak.ProjectedEndSoc, "projection absent outside the off-peak window")+	})++	t.Run("stale live emits null even inside window", func(t *testing.T) {+		// now inside window, but the latest reading is 2h old → !liveFresh.+		mr := &mockReader{+			queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+				return []dynamo.ReadingItem{+					{Timestamp: insideWindow.Unix() - 2*3600, Ppv: 0, Pload: 200, Pbat: -3000, Pgrid: 3200, Soc: 50},+				}, 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 insideWindow }++		resp, err := h.Handle(context.Background(), statusRequest())+		require.NoError(t, err)+		sr := parseStatusResponse(t, resp)+		assert.Nil(t, sr.Live, "precondition: live omitted when stale")+		require.NotNil(t, sr.Offpeak)+		assert.Nil(t, sr.Offpeak.ProjectedEndSoc, "projection absent when live data is stale")+	})++	t.Run("simulation does not change projection (AC 2.4)", func(t *testing.T) {+		newReader := func() *mockReader {+			return &mockReader{+				queryReadingsFn: freshReadingsAt(insideWindow, 50),+				getSystemFn: func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+					return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+				},+			}+		}++		// Unsimulated call.+		hPlain := NewHandler(newReader(), nil, testSerial, testToken, "11:00", "14:00")+		hPlain.nowFunc = func() time.Time { return insideWindow }+		respPlain, err := hPlain.Handle(context.Background(), simulateStatusRequest(""))+		require.NoError(t, err)+		srPlain := parseStatusResponse(t, respPlain)+		require.NotNil(t, srPlain.Offpeak)+		require.NotNil(t, srPlain.Offpeak.ProjectedEndSoc)++		// Simulated call with added load.+		hSim := NewHandler(newReader(), nil, testSerial, testToken, "11:00", "14:00")+		hSim.nowFunc = func() time.Time { return insideWindow }+		respSim, err := hSim.Handle(context.Background(), simulateStatusRequest("3000"))+		require.NoError(t, err)+		srSim := parseStatusResponse(t, respSim)+		require.NotNil(t, srSim.Offpeak)+		require.NotNil(t, srSim.Offpeak.ProjectedEndSoc, "projection must remain present under simulation")++		assert.Equal(t, *srPlain.Offpeak.ProjectedEndSoc, *srSim.Offpeak.ProjectedEndSoc,+			"projection is independent of Pbat/simulated load (AC 2.4)")+	})++	t.Run("absent projection serialises as JSON null (no omitempty)", func(t *testing.T) {+		// Outside the window → projection absent. The field must still appear in+		// the offpeak object as explicit null, never omitted.+		outside := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)+		mr := &mockReader{+			queryReadingsFn: freshReadingsAt(outside, 50),+			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 outside }++		resp, err := h.Handle(context.Background(), statusRequest())+		require.NoError(t, err)++		var raw map[string]json.RawMessage+		require.NoError(t, json.Unmarshal([]byte(resp.Body), &raw))+		var offpeak map[string]json.RawMessage+		require.NoError(t, json.Unmarshal(raw["offpeak"], &offpeak))+		require.Contains(t, offpeak, "projectedEndSoc", "field must always be serialised (no omitempty)")+		assert.Equal(t, "null", string(offpeak["projectedEndSoc"]))+	})+}+ 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.
Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift Modified +7 / -1
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swiftindex d612471..e0f2b41 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift@@ -132,6 +132,10 @@ public struct OffpeakData: Codable, Sendable {     public let batteryDischargeKwh: Double?     public let gridExportKwh: Double?     public let batteryDeltaPercent: Double?+    /// Server-computed projected SoC (percent) at the off-peak window end,+    /// assuming charging continues at the idealised max rate. Present only+    /// while inside the window with fresh live data; nil otherwise.+    public let projectedEndSoc: Double?      public init(         windowStart: String,@@ -142,7 +146,8 @@ public struct OffpeakData: Codable, Sendable {         batteryChargeKwh: Double?,         batteryDischargeKwh: Double?,         gridExportKwh: Double?,-        batteryDeltaPercent: Double?+        batteryDeltaPercent: Double?,+        projectedEndSoc: Double? = nil     ) {         self.windowStart = windowStart         self.windowEnd = windowEnd@@ -153,6 +158,7 @@ public struct OffpeakData: Codable, Sendable {         self.batteryDischargeKwh = batteryDischargeKwh         self.gridExportKwh = gridExportKwh         self.batteryDeltaPercent = batteryDeltaPercent+        self.projectedEndSoc = projectedEndSoc     } } 
Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift Modified +64 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swiftindex 22fbe59..6a02bfe 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsTests.swift@@ -219,6 +219,70 @@ struct APIModelsTests {         #expect(offpeak.batteryDeltaPercent == nil)     } +    @Test+    func decodeOffpeakWithProjectedEndSocPresent() throws {+        let json = """+        {+          "windowStart": "11:00",+          "windowEnd": "14:00",+          "gridUsageKwh": 6.1,+          "solarKwh": 3.2,+          "batteryChargeKwh": 2.5,+          "batteryDischargeKwh": 0.1,+          "gridExportKwh": 0.3,+          "batteryDeltaPercent": 42.3,+          "projectedEndSoc": 97.5+        }+        """++        let offpeak = try decoder.decode(OffpeakData.self, from: Data(json.utf8))++        #expect(offpeak.projectedEndSoc == 97.5)+    }++    @Test+    func decodeOffpeakWithProjectedEndSocNull() throws {+        let json = """+        {+          "windowStart": "11:00",+          "windowEnd": "14:00",+          "gridUsageKwh": null,+          "solarKwh": null,+          "batteryChargeKwh": null,+          "batteryDischargeKwh": null,+          "gridExportKwh": null,+          "batteryDeltaPercent": null,+          "projectedEndSoc": null+        }+        """++        let offpeak = try decoder.decode(OffpeakData.self, from: Data(json.utf8))++        #expect(offpeak.projectedEndSoc == nil)+    }++    @Test+    func decodeOffpeakWithProjectedEndSocAbsent() throws {+        // Forward/backward compatibility: payloads outside the window omit+        // the key entirely.+        let json = """+        {+          "windowStart": "11:00",+          "windowEnd": "14:00",+          "gridUsageKwh": 6.1,+          "solarKwh": 3.2,+          "batteryChargeKwh": 2.5,+          "batteryDischargeKwh": 0.1,+          "gridExportKwh": 0.3,+          "batteryDeltaPercent": 42.3+        }+        """++        let offpeak = try decoder.decode(OffpeakData.self, from: Data(json.utf8))++        #expect(offpeak.projectedEndSoc == nil)+    }+     // MARK: - /history response      @Test
Flux/Flux/Helpers/BatteryBlock.swift Modified +34 / -5
diff --git a/Flux/Flux/Helpers/BatteryBlock.swift b/Flux/Flux/Helpers/BatteryBlock.swiftindex a53d500..1ee2b24 100644--- a/Flux/Flux/Helpers/BatteryBlock.swift+++ b/Flux/Flux/Helpers/BatteryBlock.swift@@ -18,6 +18,15 @@ struct BatteryBlock: View {     var showsOffpeakDelta: Bool = false     /// When non-nil, renders an "Energy left" row above "Battery cycle".     var energyLeftKwh: Double?+    /// Server-computed projected SoC (percent) at the off-peak window end.+    /// When present, the projection row replaces the "Charged during off-peak"+    /// delta row (Decision 9 — they are mutually exclusive). Live-only, so it+    /// is nil on Day Detail / History.+    var projectedOffpeakEndSoc: Double?+    /// Off-peak window end label (e.g. "14:00") from the same `/status`+    /// response, used to label the projection row so the labelled time matches+    /// the time the projection targets (AC 4.2).+    var offpeakWindowEnd: String?      var body: some View {         FluxPanel {@@ -33,12 +42,12 @@ struct BatteryBlock: View {                     label: "Lowest",                     value: lowestValue,                     sub: lowestSubtitle,-                    last: !rendersOffpeakDelta+                    last: offpeakRow == nil                 )-                if rendersOffpeakDelta {+                if let offpeakRow {                     FluxStatRow(-                        label: "Charged during off-peak",-                        value: offpeakDeltaText,+                        label: offpeakRow.label,+                        value: offpeakRow.value,                         last: true                     )                 }@@ -46,11 +55,29 @@ struct BatteryBlock: View {         }     } -    private var rendersOffpeakDelta: Bool {+    /// The single off-peak row to render, or nil when none applies. The+    /// projection row takes precedence over the delta row (Decision 9); the+    /// two are mutually exclusive. Internal so the selection contract is+    /// testable without view-rendering infrastructure.+    var offpeakRow: (label: String, value: String)? {+        if let projected = projectedOffpeakEndSoc {+            return (projectedLabel, SOCFormatting.format(projected))+        }+        if rendersOffpeakDelta {+            return ("Charged during off-peak", offpeakDeltaText)+        }+        return nil+    }++    var projectedLabel: String {+        "Projected at \(offpeakWindowEnd ?? "off-peak end")"+    }++    var rendersOffpeakDelta: Bool {         showsOffpeakDelta || offpeakBatteryDeltaPercent != nil     } -    private var offpeakDeltaText: String {+    var offpeakDeltaText: String {         guard let value = offpeakBatteryDeltaPercent else { return "—" }         return String(format: "%+.0f%%", value)     }
Flux/FluxTests/BatteryBlockProjectionTests.swift Added +81 / -0
diff --git a/Flux/FluxTests/BatteryBlockProjectionTests.swift b/Flux/FluxTests/BatteryBlockProjectionTests.swiftnew file mode 100644index 0000000..56aff92--- /dev/null+++ b/Flux/FluxTests/BatteryBlockProjectionTests.swift@@ -0,0 +1,81 @@+import FluxCore+import Testing+@testable import Flux++// Verifies BatteryBlock's off-peak row selection (Decision 9): the projection+// row takes precedence over the "Charged during off-peak" delta row, and the+// two are mutually exclusive. The contract is exposed through internal+// computed properties so it is testable without view-rendering infrastructure+// (and so it runs on macOS, where UIKit hosting is unavailable).+@MainActor+@Suite+struct BatteryBlockProjectionTests {+    private func block(+        projected: Double?,+        windowEnd: String?,+        delta: Double? = nil,+        showsDelta: Bool = true+    ) -> BatteryBlock {+        BatteryBlock(+            batteryCharge: 6.2,+            batteryDischarge: 5.4,+            lowestSOC: 38,+            lowestSOCTimestamp: nil,+            offpeakBatteryDeltaPercent: delta,+            showsOffpeakDelta: showsDelta,+            projectedOffpeakEndSoc: projected,+            offpeakWindowEnd: windowEnd+        )+    }++    @Test+    func projectionRowLabelUsesWindowEnd() {+        let battery = block(projected: 97.5, windowEnd: "14:00")+        let row = battery.offpeakRow+        #expect(row?.label == "Projected at 14:00")+        #expect(row?.value == SOCFormatting.format(97.5))+    }++    @Test+    func projectionRowFallsBackToOffPeakEndWhenWindowEndNil() {+        let battery = block(projected: 80, windowEnd: nil)+        #expect(battery.offpeakRow?.label == "Projected at off-peak end")+    }++    @Test+    func projectionSuppressesDeltaRow() {+        // Projection present + a realised delta also present: only the+        // projection row renders (mutually exclusive, projection wins).+        let battery = block(projected: 97.5, windowEnd: "14:00", delta: 42)+        let row = battery.offpeakRow+        #expect(row?.label == "Projected at 14:00")+        #expect(row?.value == SOCFormatting.format(97.5))+        #expect(row?.label != "Charged during off-peak")+    }++    @Test+    func deltaRowRendersWhenProjectionNil() {+        // No projection: behaviour unchanged — the delta row renders per the+        // existing showsOffpeakDelta logic.+        let battery = block(projected: nil, windowEnd: "14:00", delta: 42)+        let row = battery.offpeakRow+        #expect(row?.label == "Charged during off-peak")+        #expect(row?.value == "+42%")+    }++    @Test+    func deltaRowShowsDashWhenProjectionNilAndDeltaNil() {+        // No projection, no delta yet, but showsOffpeakDelta keeps the row.+        let battery = block(projected: nil, windowEnd: "14:00", delta: nil)+        let row = battery.offpeakRow+        #expect(row?.label == "Charged during off-peak")+        #expect(row?.value == "—")+    }++    @Test+    func noOffpeakRowWhenProjectionNilAndDeltaHidden() {+        // Neither projection nor delta context (Day Detail / History style).+        let battery = block(projected: nil, windowEnd: nil, delta: nil, showsDelta: false)+        #expect(battery.offpeakRow == nil)+    }+}
Flux/Flux/Dashboard/DashboardView.swift Modified +3 / -1
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex 09de407..f753bdf 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -255,7 +255,9 @@ struct DashboardView: View {                 .flatMap(DateFormatting.parseTimestamp),             offpeakBatteryDeltaPercent: viewModel.status?.offpeak?.batteryDeltaPercent,             showsOffpeakDelta: true,-            energyLeftKwh: energyLeftKwh+            energyLeftKwh: energyLeftKwh,+            projectedOffpeakEndSoc: viewModel.status?.offpeak?.projectedEndSoc,+            offpeakWindowEnd: viewModel.status?.offpeak?.windowEnd         )     } 
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 0f39c0e..58302ee 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,10 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- **Off-peak charge projection** (T-1533). During the off-peak charging window the Dashboard battery panel shows a "Projected at HH:MM" row with the state-of-charge the battery is expected to reach by the window end if charging continues at full capability. The figure is computed server-side on `GET /status` from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W to 100%), clamped to `[current SoC, 100%]`, and is a best-case estimate independent of the live battery power and of load simulation. It reuses the cutoff estimate's battery capacity so the two figures never disagree, appears only inside the window with fresh live data (otherwise an explicit `null`), and replaces the "Charged during off-peak" delta row while the window is open (Day Detail and History are unaffected). iOS + macOS. - **History period navigation** (T-1497). The History screen's Wk and Mo ranges gain a period header — previous/next chevrons, a tappable label opening a graphical date picker (which follows the system's first-day-of-week), and a Current button sitting between the chevrons — to browse past weeks and months, with ←/→ key navigation on macOS. Past periods are fetched via a new past-only `start`/`end` date-range form on `GET /history` that serves stored values only (no live compute), while the current period keeps the unchanged `days=N` form; a cross-handler parity test pins `/day` and `/history` to identical numbers for dates past the readings TTL. App-side: a `HistoryQuery` enum through `FluxAPIClient` and the chart-expansion scope, a Sydney-calendar `HistoryPeriod` model (DST-safe, property-tested), view-model navigation intents with request coalescing keyed on the requested period and a resolved snapshot driving the header and chart domain, an offline cache bounded to the viewed period, a distinct no-data notice for empty past periods (cards stay rendered), and an "N of M days" subtitle when a past period is partially covered. iOS + macOS.  ### Internal +- **Off-peak charge projection spec** (T-1533). Full spec for showing the projected battery state-of-charge at the off-peak window end on the Dashboard: requirements, design, 9-entry decision log, and an 8-task TDD plan across two streams (Go backend, Swift app) in `specs/offpeak-charge-projection/`. During the off-peak window, `GET /status` returns a server-computed projection from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W to 100%), independent of live battery power and load simulation, clamped to `[currentSoC, 100]`; it is rendered as a single contextual row on the Dashboard battery panel that takes precedence over the off-peak delta row, and reuses the cutoff estimate's capacity so the two figures stay consistent. `specs/OVERVIEW.md` updated. - **History period navigation spec** (T-1497). Full spec for navigating to past weeks/months on the History screen: requirements, design, 17-entry decision log, and a 12-task TDD implementation plan in `specs/history-period-navigation/`. Covers prev/next period chevrons, a Sydney-zoned date-picker jump, a return-to-current action, and a new past-only `start`/`end` date-range form on `GET /history` (existing `days=N` form kept permanently per Decision 16; follow-up ticket T-1540 tracks the unification question). `specs/OVERVIEW.md` updated. No code changes yet.  ## [1.6] - 2026-06-10
specs/OVERVIEW.md Modified +10 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 6524138..6887549 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -30,6 +30,7 @@ | [History Month and Week to Date](#history-month-and-week-to-date) | 2026-06-04 | Done | Two calendar-anchored History ranges — week-to-date (Wk) and month-to-date (Mo) — added to the existing fixed 7/14/30-day segmented control. The app resolves each to an inclusive day-count `N` against the Sydney calendar (only the week's first weekday comes from the device locale) and reuses `GET /history?days=N`; the backend's sole change is widening its `{7,14,30}` allowlist to a `1–31` bounds check. Selection moves from a bare `Int` to a `HistoryRange` enum; new FluxCore `DateFormatting` boundary helpers; offline cache fallback switches from newest-N-by-count to date-bounded; downstream cards/charts/`DerivedState` unchanged (already data-adaptive). T-1361. | | [Dashboard Simulation](#dashboard-simulation) | 2026-06-09 | Planned | Dashboard what-if toggle that applies a named predetermined load (watts) as a clearly-labelled simulation, showing the effect on house load, battery discharge, and the "empty by" estimate. Computed server-side via a `simulateLoadWatts` query param on `GET /status` (one source of truth — added load raises live `Pload`/`Pbat` and the rolling cutoff, suppresses `cantEmptyBeforeOffpeak`); added load is allocated by a priority waterfall (reduce grid export → battery, capped at the inverter's 5 kW ceiling → grid import) so car charging is accurate in every state — evening peak, mild sun, and full-sun export — validated 1–20000 W. Named presets are a system-wide CRUD resource mirroring `/pricing` (`flux-simulation-presets`, id-only key, synced across devices). Client adds a separate `fetchStatus(simulateLoadWatts:)` so widget/settings paths can't simulate; in-memory active state resets on cold launch; Settings ▸ Simulation editor + a Dashboard Simulate menu and distinct SimulationBanner. iOS + macOS. T-1495. | | [History Period Navigation](#history-period-navigation) | 2026-06-11 | Done | Previous/next stepping, a date-picker jump, and a return-to-current action for the History Wk/Mo ranges, backed by a new past-only `start`/`end` date-range form of `GET /history`. Anchor state in the view model (`nil` = current to-date period); past periods render the full calendar period from stored values only; chart expansion carries the same `HistoryQuery` so enlarged charts match. T-1497. |+| [Off-Peak Charge Projection](#off-peak-charge-projection) | 2026-06-13 | Done | During the off-peak window, `/status` returns a projected battery SoC at the window end computed server-side from an idealised two-rate charge curve (4.5 kW to 95%, then 500 W to 100%; independent of live `Pbat` and load simulation), shown as a single contextual row on the Dashboard battery panel that takes precedence over the off-peak delta row. New nullable `projectedEndSoc` on `OffpeakData` (explicit `null` when absent); reuses the `EstimatedCutoff` capacity for consistency; gated like `EstimatedCutoff` (fresh-live, within-window). iOS + macOS. T-1533. |  --- @@ -299,3 +300,12 @@ Navigation to past calendar periods on the History screen's Wk/Mo ranges: chevro - [implementation.md](history-period-navigation/implementation.md) - [requirements.md](history-period-navigation/requirements.md) - [tasks.md](history-period-navigation/tasks.md)++## Off-Peak Charge Projection++During the off-peak charging window, `GET /status` returns a projected battery SoC at the window end, computed server-side from an idealised two-rate charge curve (4.5 kW up to 95%, then 500 W from 95% to 100%) over the time remaining to the window end. The projection is independent of the battery's live `Pbat` and of load simulation (`simulateLoadWatts`) by construction, clamped to `[currentSoC, 100]`, and rounded to 1 dp. New nullable `ProjectedEndSoc`/`projectedEndSoc` on `OffpeakData` co-locates the value with `windowEnd` so the Dashboard label and the projected time come from one response object; absence is an explicit `null` (pointer, no `omitempty`), mirroring `EstimatedCutoff`. Gated exactly like `EstimatedCutoff` — fresh-live branch, within the window, positive capacity — and reuses the same capacity value so the two figures never disagree. Shown on the Dashboard battery panel (`BatteryBlock`, an app-target view shared with macOS) as a single row that takes precedence over the "Charged during off-peak" delta row, which is unknown mid-window; Day Detail/History pass `nil` and stay unchanged. Hardcoded charge constants (Decision 3), no new config, no stored-data changes. iOS + macOS. T-1533.++- [decision_log.md](offpeak-charge-projection/decision_log.md)+- [design.md](offpeak-charge-projection/design.md)+- [requirements.md](offpeak-charge-projection/requirements.md)+- [tasks.md](offpeak-charge-projection/tasks.md)

Things to double-check

Optimistic projection vs reality.

The figure ignores charge-rate derating and round-trip losses, so it typically reads higher than the battery achieves. This is the accepted definition (Decisions 4/6), but it is the most likely source of "it didn't reach that" user feedback — worth a glance at real-world values after a few off-peak windows.

AC 3.3 has no automated test.

"Clients render the value without re-deriving it locally" asserts the absence of client-side math; verified by reading the Dashboard wiring (viewModel.status?.offpeak?.projectedEndSoc passed straight through). Nothing to assert in a unit test.