flux branch T-1497/history-period-navigation commits 11 files 45 touched lines +3333 / -231 findings 20 raised / 13 fixed

Pre-push review: T-1497/history-period-navigation

History period navigation (T-1497): browse past weeks and months on the History screen via chevrons, a Sydney-zoned date picker, and a Current button — backed by a new past-only start/end range form on GET /history. Four parallel review agents (reuse, quality, efficiency, spec adherence); 13 fixes applied, 7 findings consciously skipped.

At a glance

  • Backend: GET /history gains a mutually-exclusive past-only ?start=&end= form (max 31 days, end strictly before Sydney today) that serves stored values only — no readings query, no live compute. The days=N path is byte-for-byte unchanged.
  • FluxCore: new HistoryQuery enum + fetchHistory(query:) with a protocol-extension default so ~30 existing mocks compile unchanged.
  • App: HistoryPeriod (Sydney-calendar period math, property-tested across DST), view-model navigation intents with anchor + resolved-snapshot state, request coalescing keyed on the requested period, a period header with a Sydney-environment date picker, and macOS ←/→ keys.
  • Consistency: a cross-handler parity test pins /day and /history to identical numbers for TTL-expired dates; chart expansion now carries the actual HistoryQuery end-to-end.
  • Review outcome: 13 fixes (dedup of period math, derived-state cleanups, dead-value pipeline removal, doc updates, two new tests); 7 skips, each with a reason.

Verdict

Ready to push

All 28 spec requirements implemented, 26 directly tested (the two gaps flagged in review were closed with new tests). No major findings: the review agents raised minors and nits only, all either fixed in commit c045ac0 or skipped with a recorded reason. Full verification after fixes: make macos-lint, make macos-test, make ios-build, FluxCore swift test, and Go make check all pass. The one mid-implementation correctness bug (navigateNext overshoot) was caught by the design-critic phase, fixed, tested, and documented as Decision 17 before this review ran.

Review findings

20 raised · 13 fixed · 7 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

The History screen shows charts of the home's energy — solar, usage, battery — for the current week or month. This change adds time travel: arrow buttons step back to last week or last month, a calendar jumps to any past date, and a Current button returns to today.

The server also learned a new way of being asked for data. Before, the app could only say "give me the last N days". Now it can say "give me exactly March 1st through March 31st". Past requests are handled in a simpler, safer way: the server only returns numbers it already saved and never computes anything live.

Why It Matters

Questions like "how much solar did we generate in March?" are now answerable in the app. Because past numbers come straight from stored records, the same date shows identical numbers on every screen — a rule this project enforces.

Key Concepts

  • Period — one calendar week or month in Sydney time (where the battery lives), so days always match what the hardware recorded, even on a phone in another timezone.
  • Anchor — the app remembers which period you're viewing as a single date; no anchor means "the current period".
  • Live vs stored data — today's numbers change every 10 seconds; past days are fixed records. Past-period requests deal only in fixed records.

Changes Overview

Go backend (internal/api/history.go): a second, mutually-exclusive request form ?start=YYYY-MM-DD&end=YYYY-MM-DD. Past-only (end strictly before Sydney today, Decision 15), 31-day inclusive cap, mixed forms 400. The range path pre-fills the readings channel empty, so no readings query and no live compute, while downstream merge code stays identical — which is what keeps the days path byte-for-byte unchanged.

FluxCore: HistoryQuery (.days(Int) / .dateRange(start:end:), Hashable + Sendable + Codable) and fetchHistory(query:). A protocol-extension default delegates .days to the existing method and throws for .dateRange — the established fetchStatus(simulateLoadWatts:) evolution pattern, so existing mocks compile unchanged.

App: HistoryPeriod owns all period boundary math (Sydney-midnight start + endExclusive, calendar arithmetic only, never interval division); HistoryChartDomain.make delegates to it. HistoryViewModel gains periodAnchor: Date? (nil = current) mutated only by five intent methods; the load path is anchor-agnostic so refresh never moves you (req 1.8). Coalescing keys on RequestedPeriod (range + anchor) so a navigation issued mid-load wins. A resolvedQuery snapshot — adopted atomically with the data — drives the header, chart domain, and chevron disabled state, so the UI describes rendered data, never an in-flight request.

Trade-offs

  • Two request forms, permanently (Decision 16): days=N anchors today server-side, so a current-period request built just before midnight stays consistent. Migrating to start/end everywhere would reintroduce the hybrid live-compute path Decision 15 removed.
  • Requested vs resolved state split: they genuinely differ during loads; conflating them caused the navigateNext overshoot bug (Decision 17 documents the view-model clamp that fixed it).
  • Full calendar periods for past, to-date for current: handled by HistoryChartDomain taking a reference date instead of always "now".

Technical Deep Dive

  • Validation order: days-present vs days-defaulted is distinguished before parsing, so ?days=7&start=… 400s despite 7 being the default. The past-only check string-compares the raw end param against today's 2006-01-02 string — safe because Go's fixed-width layout rejects non-zero-padded input, so lexical order equals date order. The 31-day cap (end.After(start.AddDate(0,0,30))) is calendar-aware; both 31-OK and 32-reject spans crossing the April DST fallback are in the test matrix.
  • Coalescing: keyed on RequestedPeriod(range, anchor) — navigate-while-loading is a different key and is honoured; a redundant reload of the same period coalesces. lastRequestedPeriod is a computed property (all mutations are MainActor-synchronous before the loop re-reads it).
  • HistoryQuery.dayCount: single derivation for day counts (Sydney parse + inclusive count); the chart-expansion path was refactored in review to pass the real query end-to-end instead of collapsing to an Int and fabricating a nominal .days query whose only consumer was dead code.
  • Parity as a test: /day vs /history (range form) equality for a 45-day-old date — readings TTL-expired, so both must serve the persisted row — across energy totals, peak grid import, and the off-peak split.
  • Property-based period tests: seeded SplitMix64 dates plus hand-picked edges (both 2026 Sydney DST transitions, leap Feb, month/year boundaries) assert next(previous(p)) == p, half-open containment, 7-slot weeks, and week(containing:) stability across the week.

Architecture Impact

HistoryPeriod is app-layer (presentation policy); HistoryQuery is FluxCore (wire format). The ChartScope Codable shape changed — verified safe because scopes are never persisted (in-memory registry only). The protocol-default evolution pattern now has two precedents and is the de-facto convention for extending FluxAPIClient.

Potential Issues

  • The header label reflects resolved state while chevrons step from requested state; rapid prev-taps on a slow connection step periods the label hasn't shown yet. Accepted consequence of the resolved-snapshot rule.
  • HistoryPeriodHeader.navButton duplicates DayNavigationHeader.navButton; a shared CircularNavButton would also fix Day Detail's missing accessibility labels (deferred).
  • Clock skew at Sydney midnight can make the app send end == server-today and get a 400; surfaced as the error state with Retry, self-healing once either clock ticks over.

Important changes — detailed

internal/api/history.go: past-only start/end range form

internal/api/history.go

Why it matters. New public API surface and the correctness core of the feature: validation matrix (mixed-form 400, end strictly before Sydney today, 31-day calendar-aware cap) and the guarantee that past requests never trigger live compute.

What to look at. handleHistory — form detection, ParseInLocation validation, pre-filled readings channel on the range path

Takeaway. Pre-filling a result channel with an empty value lets one downstream merge path serve two request forms — the conditional lives at the producer, not threaded through every consumer.
Rationale. Decision 15: every permitted input should have a consumer and a test. Making the range form past-only splits the handler into two clean paths — days=N (may include live today) and start/end (stored only) — instead of a hybrid.

FluxCore: HistoryQuery + fetchHistory(query:) protocol default

Flux/Packages/FluxCore/Sources/FluxCore/Networking/HistoryQuery.swift

Why it matters. The wire-format abstraction every layer above rides on; its protocol-extension default is what kept ~30 existing mocks compiling without edits.

What to look at. HistoryQuery.swift, FluxAPIClient.swift, URLSessionAPIClient+History.swift

Takeaway. Evolving a protocol by adding a defaulted method that delegates to the existing requirement (and throws for genuinely-new cases) lets a wide mock surface absorb API growth with zero churn — second use of this pattern in the codebase, now the convention.
Rationale. Decision 11 in the log: mirrors the fetchStatus(simulateLoadWatts:) evolution precedent.

HistoryPeriod: single owner of Sydney period math

Flux/Flux/History/HistoryPeriod.swift

Why it matters. All week/month boundary arithmetic in one value type; HistoryChartDomain delegates to it (review fix), so the chart axis and header label cannot drift apart — the project's data-consistency rule applied to dates.

What to look at. HistoryPeriod.swift — week/month factories, previous/next via calendar arithmetic, contains/dayCount; property-tested across both 2026 DST transitions

Takeaway. Period stepping via byAdding .day ±7 / .month ±1 on the start date (never interval division) is what makes DST transitions a non-event; the property tests (next∘previous == identity, 7-slot weeks) pin it.
Rationale. Design: asserts on .days ranges rather than silently self-returning, because navigation controls are never shown there and a silent no-op would hide a wiring bug.

HistoryViewModel: anchor intents, RequestedPeriod coalescing, resolved snapshot

Flux/Flux/History/HistoryViewModel.swift

Why it matters. The behavioural heart: who may move the anchor (five intents only — refresh never moves you), how mid-load navigation wins (coalescing re-keyed on range+anchor), and why the UI always describes rendered data (resolvedQuery adopted atomically with the data).

What to look at. HistoryViewModel.swift — periodAnchor, selectRange/navigatePrevious/navigateNext/jumpTo/returnToCurrent, RequestedPeriod, resolveTarget/load

Takeaway. Splitting requested state (anchor) from resolved state (snapshot) makes slow-network UI honest, but disabled states derived from the resolved side lag — so invariants must also be enforced at the intent layer, not only in presentation.
Rationale. Decision 17: navigateNext clamps in the view model because the chevron's resolved-snapshot disabled state lags in-flight loads; macOS key repeat outpaces a network round trip. Mirrors DayDetailViewModel's isToday guard.

HistoryPeriodHeader: Sydney-environment date picker

Flux/Flux/History/HistoryPeriodHeader.swift

Why it matters. User-visible entry point; the graphical DatePicker is explicitly rendered with Sydney calendar/timeZone environment values and capped at sydneyTodayEnd — on a non-Sydney device the default calendar would render selectable days off by one.

What to look at. HistoryPeriodHeader.swift — chevrons, tappable label, popover/sheet picker, Current button

Takeaway. SwiftUI DatePicker renders in the environment's calendar/timezone, not the date's: pinning .environment(\.calendar)/(\.timeZone) is required whenever the domain timezone differs from the device.
Rationale. Requirement 3.2 plus the design's explicit call-out of the non-Sydney off-by-one risk; now covered by a test with a clock where device-date and Sydney-date differ.

Chart expansion carries the real HistoryQuery

Flux/Flux/Charts/Expansion/ChartKind.swift

Why it matters. An expanded chart must fetch exactly what its card showed — for a past period that's the date range, not a day count. The review removed a dead-value pipeline that collapsed the query to an Int and re-fabricated a nominal query.

What to look at. ChartKind.swift (ChartScope.historyRange(HistoryQuery)), ChartSceneObserver, ChartExpansionContent, ExpandedHistoryHost

Takeaway. When a value is collapsed to a weaker type and later rebuilt, the rebuild is usually fiction — pass the original through. The Codable shape change was safe only because scopes are never persisted; that was verified, not assumed.
Rationale. Decision 13 (expansion parity) for the carry-through; the Int-collapse removal was a review finding — the fabricated query's only consumer was dead code behind a disabled-expansion environment flag.

cross_handler_test.go: /day vs /history parity pinned

internal/api/cross_handler_test.go

Why it matters. Makes the project's data-consistency rule executable: a 45-day-old date (readings TTL-expired) must show identical energy totals, peak grid import, and off-peak split whichever endpoint serves it.

What to look at. TestCrossHandlerEquivalence_OldDateSummaryFields

Takeaway. Consistency rules between endpoints rot silently unless a test computes both sides from one fixture and asserts equality field-by-field — the fixture had to be extended (PeakGridImportKwh, offpeak row) precisely because the old one left those nil.
Rationale. CLAUDE.md data-consistency rule; requirement 5.7.

Key decisions

The date-range form is past-only (Decision 15).

end == today is rejected with 400, so the range path never performs live compute or the readings query. Every permitted input has a consumer and a test; the hybrid live-compute range path the original Decision 10 allowed was untested, unrequired surface.

days=N and start/end stay permanently distinct (Decision 16).

days=N anchors "today" server-side, so a current-period request built just before midnight and processed after stays internally consistent. Migrating everything to client-computed ranges would either silently drop today at the boundary or 400 — and would force end == today back open. Follow-up ticket T-1540 tracks the unification question.

navigateNext clamps in the view model, not only via the disabled chevron (Decision 17).

The chevron's disabled state derives from the resolved snapshot, which lags during loads; macOS key repeat fires faster than a network round trip. The intent method guards periodAnchor != nil, mirroring DayDetailViewModel's isToday clamp. Added mid-implementation after the design-critic review reproduced the overshoot.

Anchor state is a single optional date; nil means current.

periodAnchor: Date? with five intent methods as the only mutators keeps the load path anchor-agnostic — refresh and retry reload what you're looking at without moving you (req 1.8). Resolved presentation state is derived separately from the data actually rendered.

HistoryPeriod lives in the app target; HistoryQuery in FluxCore.

Period semantics (what a "week" means on this screen, first-weekday locale rule) are presentation policy; the query enum is wire format shared with widgets/expansion. The boundary held under review — no app concern leaked into FluxCore.

(inferred — not stated by the author.)
Empty past periods keep cards rendered with a compact notice.

A period with no data is a normal outcome of unbounded back-navigation (req 1.6), not an error: the chart scaffold reserves the axis so layout doesn't jump, and the notice replaces only the note row. The error state (fetch failed, no cache) stays a distinct flag — the two are separately tested.

Review findings

SeverityAreaFindingResolution
minorHistoryPeriodHeader formattersLocal shortMonthDay formatter duplicated FluxCore's DateFormatting.shortMonthDay(from:) byte-for-byte; doc comment claimed no equivalent existed.Deleted the local formatter; both call sites use the FluxCore helper; comment corrected.
minorHistoryChartDomainmake re-implemented the week/month boundary math HistoryPeriod centralises — two owners for the same boundaries on one screen risks axis/label drift.make delegates to HistoryPeriod.week/.month; boundary math has one owner.
minorHistoryViewModel displayedPerioddisplayedPeriod duplicated the private period(for:containing:) switch (third copy of the range→period mapping).Helper widened to internal; displayedPeriod calls it.
minorHistoryView render pathviewModel.chartDomain computed three times per body evaluation — interval math plus, on past periods, a DateFormatter parse, per access.Captured once per body alongside the existing derived capture and passed down.
minorHistoryViewModel jumpToPicking a date inside the already-displayed period refetched the identical window (picker opens preset to period.start, so an unchanged confirm refetched).navigate(to:) early-returns when the target resolves to the rendered period and no error is showing; new no-op test added.
minorChart expansion pipelineScope's HistoryQuery collapsed to an Int (including a .dateRange parse branch), then re-wrapped as a fabricated .days query whose only consumer was dead code behind the disabled-expansion flag.The actual HistoryQuery is passed end-to-end; historyRangeDays(from:) and the fabricated query deleted.
minorHistoryViewModel stored statelastRequestedPeriod and resolvedRangeDays were stored copies of derivable values; day-count derivation existed twice.Both are computed properties; new HistoryQuery.dayCount in FluxCore unifies the derivation.
minordocs/flux-v1.mdThe authoritative product spec described /history as days=N-only; the T-1361 amendment precedent says this section is kept current.Endpoint section and History display paragraph amended for the past-only start/end form.
minorTest gap, req 3.2sydneyTodayEnd (picker upper bound) had no test for the non-Sydney-device off-by-one the design itself called out.New test with a clock where device-date and Sydney-date differ asserts the bound is the end of the Sydney day.
minorTest gap, req 6.1Past-period averages dividing by recorded days was argued structurally but never asserted — a DerivedState refactor could silently break it.New test: 31-day March range with 11 recorded days asserts averages divide by 11.
nitHistoryView presentation ruleView computed isViewingCurrentPeriod ? nil : resolvedRangeDays inline — a presentation rule living in the view.Moved to HistoryViewModel+Presentation as pastPeriodDayCount.
nitCHANGELOG.mdSpec entry said "16-entry decision log"; Decision 17 was added after it was written.Bumped to 17-entry.
nitHistoryPeriodHeader label parsedisplayedPeriod re-parses the resolved start string via chartReferenceDate on each access (single access per body — minor).Covered by the once-per-body chartDomain capture; remaining single parse accepted.
nitperiodQuery aliasperiodQuery is a pure alias of resolvedQuery — two names for one value.Skipped: existing tests reference both names (HistoryRangeConsistencyTests uses periodQuery, navigation tests use resolvedQuery); removing either would require editing existing test assertions, which the review constraints forbid.
minornavButton duplicationHistoryPeriodHeader.navButton duplicates DayNavigationHeader.navButton verbatim (plus an accessibility label the original lacks). A shared CircularNavButton would dedupe and fix Day Detail's accessibility gap.Skipped: duplication was accepted in the earlier design review as "matching" styling; the clean extraction touches Day Detail and belongs in its own change.
nitGo date-param parsingstart/end parsed with two identical ParseInLocation+400 blocks; day.go, note.go, pricing_handler.go inline the same pattern — five sites that would share a parseDayParam helper.Skipped: the new code follows the existing repo convention; the helper is a worthwhile follow-up, not a blocker for this branch.
nitMock URLProtocol copiesHistoryQueryMockURLProtocol is the fourth near-identical lock-guarded mock URLProtocol (~60 lines each).Skipped: per-suite copies are the established convention here and avoid shared static state between concurrently running suites.
nitCalendar-anchored conceptThe "Wk/Mo vs fixed ranges" distinction is restated in five places; a HistoryRange.isCalendarAnchored property would name it once.Skipped: low value individually; worth doing if a sixth restatement appears.
nitCLAUDE.md endpoint listThe architecture summary lists three endpoints and predates /note and /pricing — now also under-describes /history.Skipped: pre-existing staleness across multiple endpoints, not a regression of this branch; fix wholesale separately.
nitTest gaps acceptedreq 1.7 (macOS arrow-key wiring) and req 7.1 (past days entering the cache) are only indirectly covered.Skipped: consistent with Day Detail's precedent for key handling; cacheHistoricalDays is unchanged code. The intents the keys call are fully tested.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +10 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d7b9ec0..ba1ba4a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased]++### Added++- **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 Sydney-zoned graphical date picker, and a Current button — 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++- **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  ### Added
Flux/Flux/Charts/Expansion/ChartExpansionContent.swift Modified +8 / -5
diff --git a/Flux/Flux/Charts/Expansion/ChartExpansionContent.swift b/Flux/Flux/Charts/Expansion/ChartExpansionContent.swiftindex ef5e683..9e4f071 100644--- a/Flux/Flux/Charts/Expansion/ChartExpansionContent.swift+++ b/Flux/Flux/Charts/Expansion/ChartExpansionContent.swift@@ -28,7 +28,7 @@ struct ChartExpansionContent: View {                     kind: kind,                     history: observer.historyController,                     day: observer.dayController,-                    historyRangeDays: Self.historyRangeDays(from: observer.scope),+                    historyQuery: Self.historyQuery(from: observer.scope),                     selectedHistoryDate: $selectedHistoryDate,                     // Bar-tap navigation from the enlarged History view to                     // Day Detail is intentionally not wired. Decisions 11@@ -98,10 +98,13 @@ struct ChartExpansionContent: View {         observer = ChartSceneObserver(kind: kind, scope: scope, api: api)     } -    static func historyRangeDays(from scope: ChartScope) -> Int {-        if case let .historyRange(days) = scope {-            return days+    /// The scope's query, passed through unchanged so the enlarged chart can+    /// never disagree with the card it came from (Decision 13). The day-scope+    /// fallback mirrors `ExpandedChartView.resolvedScope`'s history default.+    static func historyQuery(from scope: ChartScope) -> HistoryQuery {+        guard case let .historyRange(query) = scope else {+            return .days(ExpandedChartView.defaultHistoryRangeDays)         }-        return ExpandedChartView.defaultHistoryRangeDays+        return query     } }
Flux/Flux/Charts/Expansion/ChartKind.swift Modified +5 / -1
diff --git a/Flux/Flux/Charts/Expansion/ChartKind.swift b/Flux/Flux/Charts/Expansion/ChartKind.swiftindex ca0ed90..74cb6b9 100644--- a/Flux/Flux/Charts/Expansion/ChartKind.swift+++ b/Flux/Flux/Charts/Expansion/ChartKind.swift@@ -1,3 +1,4 @@+import FluxCore import Foundation  enum ChartKind: String, Hashable, Codable, CaseIterable, Identifiable {@@ -44,6 +45,9 @@ enum ChartKind: String, Hashable, Codable, CaseIterable, Identifiable { }  enum ChartScope: Hashable, Codable {-    case historyRange(days: Int)+    /// Carries the full `HistoryQuery` rather than a day count so an enlarged+    /// chart fetches the same window as the card it came from — including a+    /// navigated past period (Decision 13).+    case historyRange(HistoryQuery)     case daySpecific(date: Date) }
Flux/Flux/Charts/Expansion/ExpandedChartView.swift Modified +6 / -5
diff --git a/Flux/Flux/Charts/Expansion/ExpandedChartView.swift b/Flux/Flux/Charts/Expansion/ExpandedChartView.swiftindex 98278ab..b4ec74e 100644--- a/Flux/Flux/Charts/Expansion/ExpandedChartView.swift+++ b/Flux/Flux/Charts/Expansion/ExpandedChartView.swift@@ -1,3 +1,4 @@+import FluxCore import SwiftUI  struct ExpandedChartView: View {@@ -8,7 +9,7 @@ struct ExpandedChartView: View {     let kind: ChartKind     let history: ExpandedHistoryHostController?     let day: ExpandedDayHostController?-    let historyRangeDays: Int+    let historyQuery: HistoryQuery     let onSelectHistoryDay: ((String) -> Void)?     let selectedHistoryDate: Binding<Date?>?     let selectedDayDate: Binding<Date?>?@@ -20,7 +21,7 @@ struct ExpandedChartView: View {         kind: ChartKind,         history: ExpandedHistoryHostController? = nil,         day: ExpandedDayHostController? = nil,-        historyRangeDays: Int = ExpandedChartView.defaultHistoryRangeDays,+        historyQuery: HistoryQuery = .days(ExpandedChartView.defaultHistoryRangeDays),         selectedHistoryDate: Binding<Date?>? = nil,         onSelectHistoryDay: ((String) -> Void)? = nil,         selectedDayDate: Binding<Date?>? = nil@@ -28,7 +29,7 @@ struct ExpandedChartView: View {         self.kind = kind         self.history = history         self.day = day-        self.historyRangeDays = historyRangeDays+        self.historyQuery = historyQuery         self.selectedHistoryDate = selectedHistoryDate         self.onSelectHistoryDay = onSelectHistoryDay         self.selectedDayDate = selectedDayDate@@ -58,7 +59,7 @@ struct ExpandedChartView: View {                     kind: kind,                     controller: history,                     selectedDate: selectedHistoryDate ?? .constant(nil),-                    rangeDays: historyRangeDays,+                    periodQuery: historyQuery,                     onSelect: onSelectHistoryDay ?? { _ in }                 )             } else {@@ -88,7 +89,7 @@ struct ExpandedChartView: View {         }         switch kind.hostKind {         case .history:-            return .historyRange(days: defaultHistoryRangeDays)+            return .historyRange(.days(defaultHistoryRangeDays))         case .day:             return .daySpecific(date: today())         }
Flux/Flux/Charts/Expansion/ExpandedHistoryHost.swift Modified +8 / -4
diff --git a/Flux/Flux/Charts/Expansion/ExpandedHistoryHost.swift b/Flux/Flux/Charts/Expansion/ExpandedHistoryHost.swiftindex 0426885..2244b73 100644--- a/Flux/Flux/Charts/Expansion/ExpandedHistoryHost.swift+++ b/Flux/Flux/Charts/Expansion/ExpandedHistoryHost.swift@@ -53,7 +53,11 @@ struct ExpandedHistoryHost: View {     let kind: ChartKind     @Bindable var controller: ExpandedHistoryHostController     @Binding var selectedDate: Date?-    let rangeDays: Int+    /// The observer scope's query, passed through unchanged. It only feeds+    /// the cards' `expansionScope`, and their expansion affordance is disabled+    /// in this host (see the environment override), so it never spawns a+    /// further expansion.+    let periodQuery: HistoryQuery     let onSelect: (String) -> Void      var body: some View {@@ -64,7 +68,7 @@ struct ExpandedHistoryHost: View {                     entries: controller.displayed.solar,                     summary: controller.displayed.summary,                     selectedDate: selectedDate,-                    rangeDays: rangeDays,+                    periodQuery: periodQuery,                     onSelect: handleSelect                 )                 .simultaneousGesture(dragLifecycleGesture)@@ -73,7 +77,7 @@ struct ExpandedHistoryHost: View {                     entries: controller.displayed.grid,                     summary: controller.displayed.summary,                     selectedDate: selectedDate,-                    rangeDays: rangeDays,+                    periodQuery: periodQuery,                     onSelect: handleSelect                 )                 .simultaneousGesture(dragLifecycleGesture)@@ -82,7 +86,7 @@ struct ExpandedHistoryHost: View {                     entries: controller.displayed.dailyUsage,                     summary: controller.displayed.summary,                     selectedDate: selectedDate,-                    rangeDays: rangeDays,+                    periodQuery: periodQuery,                     onSelect: handleSelect                 )                 .simultaneousGesture(dragLifecycleGesture)
Flux/Flux/Charts/Expansion/macOS/ChartSceneObserver.swift Modified +4 / -4
diff --git a/Flux/Flux/Charts/Expansion/macOS/ChartSceneObserver.swift b/Flux/Flux/Charts/Expansion/macOS/ChartSceneObserver.swiftindex 80b9684..aa4890a 100644--- a/Flux/Flux/Charts/Expansion/macOS/ChartSceneObserver.swift+++ b/Flux/Flux/Charts/Expansion/macOS/ChartSceneObserver.swift@@ -80,16 +80,16 @@ final class ChartSceneObserver {         lastFetched = clock()          switch scope {-        case let .historyRange(days):-            await fetchHistory(days: days)+        case let .historyRange(query):+            await fetchHistory(query: query)         case let .daySpecific(date):             await fetchDay(at: date)         }     } -    private func fetchHistory(days: Int) async {+    private func fetchHistory(query: HistoryQuery) async {         do {-            let response = try await api.fetchHistory(days: days)+            let response = try await api.fetchHistory(query: query)             let derived = HistoryViewModel.DerivedState(days: response.days, now: clock())             historyController?.adopt(                 ExpandedHistoryHostSnapshot(
Flux/Flux/History/HistoryChartDomain.swift Modified +10 / -14
diff --git a/Flux/Flux/History/HistoryChartDomain.swift b/Flux/Flux/History/HistoryChartDomain.swiftindex fb198c2..6be9c77 100644--- a/Flux/Flux/History/HistoryChartDomain.swift+++ b/Flux/Flux/History/HistoryChartDomain.swift@@ -21,26 +21,22 @@ struct HistoryChartDomain: Equatable {      /// Builds the domain for a to-date range, or `nil` for a fixed `.days`     /// range (no reservation needed) or if the calendar arithmetic fails.-    static func make(range: HistoryRange, now: Date, firstWeekday: Int) -> HistoryChartDomain? {-        let calendar = DateFormatting.sydneyCalendar-        let start: Date-        let endExclusive: Date+    /// `referenceDate` is any instant inside the period to reserve — `now` for+    /// the current period, the period start for a navigated past period+    /// (req 1.4: past periods always reserve the full week/month).+    static func make(range: HistoryRange, referenceDate: Date, firstWeekday: Int) -> HistoryChartDomain? {+        // HistoryPeriod owns the week/month boundary math; this only turns the+        // resolved boundaries into slot dates.+        let period: HistoryPeriod         switch range {         case .days:             return nil         case .weekToDate:-            // A week is always seven calendar days from the locale week start.-            start = DateFormatting.startOfWeek(now: now, firstWeekday: firstWeekday)-            guard let end = calendar.date(byAdding: .day, value: 7, to: start) else { return nil }-            endExclusive = end+            period = .week(containing: referenceDate, firstWeekday: firstWeekday)         case .monthToDate:-            // The calendar-month interval gives both boundaries without a-            // hard-coded length, so 28–31-day months are handled uniformly.-            guard let interval = calendar.dateInterval(of: .month, for: now) else { return nil }-            start = interval.start-            endExclusive = interval.end+            period = .month(containing: referenceDate)         }-        return build(start: start, endExclusive: endExclusive, calendar: calendar)+        return build(start: period.start, endExclusive: period.endExclusive, calendar: DateFormatting.sydneyCalendar)     }      private static func build(start: Date, endExclusive: Date, calendar: Calendar) -> HistoryChartDomain? {
Flux/Flux/History/HistoryDailyUsageCard.swift Modified +5 / -2
diff --git a/Flux/Flux/History/HistoryDailyUsageCard.swift b/Flux/Flux/History/HistoryDailyUsageCard.swiftindex a4ec0b7..11ebddc 100644--- a/Flux/Flux/History/HistoryDailyUsageCard.swift+++ b/Flux/Flux/History/HistoryDailyUsageCard.swift@@ -8,13 +8,16 @@ struct HistoryDailyUsageCard: View {     let entries: [HistoryViewModel.DailyUsageEntry]     let summary: HistoryViewModel.PeriodSummary     let selectedDate: Date?-    let rangeDays: Int+    /// The rendered window, passed by `HistoryView` from the view model's+    /// resolved query so the enlarged chart fetches the same period —+    /// including a navigated past one (Decision 13).+    let periodQuery: HistoryQuery     /// Full-period x-axis reservation (Wk/Mo). `nil` (the default, used by the     /// expanded host which only knows a day count) leaves the chart auto-fitting.     var chartDomain: HistoryChartDomain?     let onSelect: (String) -> Void -    var expansionScope: ChartScope { .historyRange(days: rangeDays) }+    var expansionScope: ChartScope { .historyRange(periodQuery) }      static let placeholderCopy = "No load breakdown available for this range." 
Flux/Flux/History/HistoryGridUsageCard.swift Modified +6 / -2
diff --git a/Flux/Flux/History/HistoryGridUsageCard.swift b/Flux/Flux/History/HistoryGridUsageCard.swiftindex 8e41ff6..9125211 100644--- a/Flux/Flux/History/HistoryGridUsageCard.swift+++ b/Flux/Flux/History/HistoryGridUsageCard.swift@@ -1,4 +1,5 @@ import Charts+import FluxCore import SwiftUI  struct HistoryGridUsageCard: View {@@ -7,13 +8,16 @@ struct HistoryGridUsageCard: View {     let entries: [HistoryViewModel.GridEntry]     let summary: HistoryViewModel.PeriodSummary     let selectedDate: Date?-    let rangeDays: Int+    /// The rendered window, passed by `HistoryView` from the view model's+    /// resolved query so the enlarged chart fetches the same period —+    /// including a navigated past one (Decision 13).+    let periodQuery: HistoryQuery     /// Full-period x-axis reservation (Wk/Mo). `nil` (the default, used by the     /// expanded host which only knows a day count) leaves the chart auto-fitting.     var chartDomain: HistoryChartDomain?     let onSelect: (String) -> Void -    var expansionScope: ChartScope { .historyRange(days: rangeDays) }+    var expansionScope: ChartScope { .historyRange(periodQuery) }      var body: some View {         HistoryCardChrome(
Flux/Flux/History/HistoryPeriod.swift Added +100 / -0
diff --git a/Flux/Flux/History/HistoryPeriod.swift b/Flux/Flux/History/HistoryPeriod.swiftnew file mode 100644index 0000000..4ae9e15--- /dev/null+++ b/Flux/Flux/History/HistoryPeriod.swift@@ -0,0 +1,100 @@+import FluxCore+import Foundation++/// A resolved calendar period (one week or one month) with all the period date+/// math in one place, composed from the existing `DateFormatting` helpers+/// (Sydney calendar, locale-derived first weekday per the T-1361 rules).+struct HistoryPeriod: Equatable {+    /// Sydney midnight on the period's first day.+    let start: Date+    /// Sydney midnight on the day after the period's last day.+    let endExclusive: Date++    /// The calendar week containing `date`. `firstWeekday` follows Calendar's+    /// convention (1 = Sunday … 7 = Saturday).+    static func week(containing date: Date, firstWeekday: Int) -> HistoryPeriod {+        let start = DateFormatting.startOfWeek(now: date, firstWeekday: firstWeekday)+        guard let end = DateFormatting.sydneyCalendar.date(byAdding: .day, value: 7, to: start) else {+            assertionFailure("date(byAdding: .day) returned nil for a valid date")+            return HistoryPeriod(start: start, endExclusive: start)+        }+        return HistoryPeriod(start: start, endExclusive: end)+    }++    /// The calendar month containing `date`. The month interval gives both+    /// boundaries without a hard-coded length, so 28–31-day months are+    /// handled uniformly (same rule as `HistoryChartDomain`).+    static func month(containing date: Date) -> HistoryPeriod {+        guard let interval = DateFormatting.sydneyCalendar.dateInterval(of: .month, for: date) else {+            assertionFailure("dateInterval(of: .month) returned nil for a valid date")+            return HistoryPeriod(start: date, endExclusive: date)+        }+        return HistoryPeriod(start: interval.start, endExclusive: interval.end)+    }++    /// The period immediately before this one. Shifts `start` by calendar+    /// arithmetic — never interval division — so DST days don't drift.+    ///+    /// Asserts on `.days` ranges: navigation is undefined there and the+    /// controls are never shown (req 1.5); a silent self-return would hide a+    /// wiring bug.+    func previous(range: HistoryRange, firstWeekday: Int) -> HistoryPeriod {+        shifted(by: -1, range: range, firstWeekday: firstWeekday)+    }++    /// The period immediately after this one. See `previous` for the rules.+    func next(range: HistoryRange, firstWeekday: Int) -> HistoryPeriod {+        shifted(by: 1, range: range, firstWeekday: firstWeekday)+    }++    private func shifted(by direction: Int, range: HistoryRange, firstWeekday: Int) -> HistoryPeriod {+        let calendar = DateFormatting.sydneyCalendar+        switch range {+        case .days:+            assertionFailure("Period navigation is undefined for fixed .days ranges")+            return self+        case .weekToDate:+            guard let newStart = calendar.date(byAdding: .day, value: direction * 7, to: start) else {+                assertionFailure("date(byAdding: .day) returned nil for a valid date")+                return self+            }+            return .week(containing: newStart, firstWeekday: firstWeekday)+        case .monthToDate:+            guard let newStart = calendar.date(byAdding: .month, value: direction, to: start) else {+                assertionFailure("date(byAdding: .month) returned nil for a valid date")+                return self+            }+            return .month(containing: newStart)+        }+    }++    /// True when `date` falls inside `start ..< endExclusive`.+    func contains(_ date: Date) -> Bool {+        date >= start && date < endExclusive+    }++    /// Number of calendar days in the period — the M in "N of M days".+    var dayCount: Int {+        DateFormatting.inclusiveDayCount(from: start, through: lastDay)+    }++    /// `YYYY-MM-DD` of the period's first day.+    var startDateString: String {+        DateFormatting.dayDateString(from: start)+    }++    /// `YYYY-MM-DD` of the period's last day (inclusive bound).+    var endDateString: String {+        DateFormatting.dayDateString(from: lastDay)+    }++    /// Sydney midnight on the period's last day (inclusive bound). Used by the+    /// header label formatters alongside the date strings.+    var lastDay: Date {+        guard let day = DateFormatting.sydneyCalendar.date(byAdding: .day, value: -1, to: endExclusive) else {+            assertionFailure("date(byAdding: .day) returned nil for a valid date")+            return start+        }+        return day+    }+}
Flux/Flux/History/HistoryPeriodHeader.swift Added +157 / -0
diff --git a/Flux/Flux/History/HistoryPeriodHeader.swift b/Flux/Flux/History/HistoryPeriodHeader.swiftnew file mode 100644index 0000000..e0b3ec2--- /dev/null+++ b/Flux/Flux/History/HistoryPeriodHeader.swift@@ -0,0 +1,157 @@+import FluxCore+import SwiftUI++/// Period-navigation header for the Wk/Mo History ranges (req 1.1), rendered+/// in content on both platforms (Decision 14) and styled after+/// `DayNavigationHeader`: chevrons flanking a centred period label. Tapping+/// the label opens a graphical date picker capped at Sydney today (req 3.2);+/// a compact "Current" button appears trailing the header only when viewing a+/// past period (req 2.1/2.2, Decision 8). The next chevron is visible but+/// disabled at the current period (req 1.3).+struct HistoryPeriodHeader: View {+    let range: HistoryRange+    let period: HistoryPeriod+    let isViewingCurrentPeriod: Bool+    /// Last selectable instant for the jump picker — the end of the Sydney+    /// day containing now.+    let pickerUpperBound: Date+    let onPrevious: () -> Void+    let onNext: () -> Void+    let onJump: (Date) -> Void+    let onReturnToCurrent: () -> Void++    @State private var showingPicker = false+    @State private var pickerDate = Date()++    var body: some View {+        HStack(spacing: 8) {+            navButton(symbol: "chevron.left", disabled: false, accessibilityLabel: "Previous period") {+                onPrevious()+            }+            Spacer()+            periodLabel+            Spacer()+            navButton(+                symbol: "chevron.right",+                disabled: isViewingCurrentPeriod,+                accessibilityLabel: "Next period"+            ) {+                onNext()+            }+            if !isViewingCurrentPeriod {+                currentButton+            }+        }+        .foregroundStyle(FluxTheme.Palette.primaryText)+    }++    private var periodLabel: some View {+        Button {+            pickerDate = period.start+            showingPicker = true+        } label: {+            Text(Self.label(for: range, period: period))+                .appFontSystem(size: 22, weight: .semibold)+                .tracking(-0.4)+                .foregroundStyle(FluxTheme.Palette.primaryText)+        }+        .buttonStyle(.plain)+        .accessibilityHint("Opens a calendar to jump to a past period.")+        // Popover on macOS and regular iOS; the system adapts it to a sheet+        // on compact iOS, matching the design's presentation split.+        .popover(isPresented: $showingPicker) {+            jumpPicker+        }+    }++    private var jumpPicker: some View {+        DatePicker(+            "Jump to period",+            selection: $pickerDate,+            in: ...pickerUpperBound,+            displayedComponents: .date+        )+        .datePickerStyle(.graphical)+        // The picker renders and caps in the environment calendar — without+        // these, "today" and the snapped period could be off by a day on a+        // non-Sydney device.+        .environment(\.calendar, DateFormatting.sydneyCalendar)+        .environment(\.timeZone, DateFormatting.sydneyTimeZone)+        .labelsHidden()+        .padding()+        .frame(minWidth: 320)+        .onChange(of: pickerDate) { _, newDate in+            showingPicker = false+            onJump(newDate)+        }+    }++    private var currentButton: some View {+        Button("Current") {+            onReturnToCurrent()+        }+        .appFontSystem(size: 13, weight: .semibold)+        .buttonStyle(.bordered)+        .buttonBorderShape(.capsule)+        .accessibilityLabel("Return to current period")+    }++    @ViewBuilder+    private func navButton(+        symbol: String,+        disabled: Bool,+        accessibilityLabel: String,+        action: @escaping () -> Void+    ) -> some View {+        Button(action: action) {+            Image(systemName: symbol)+                .padding(8)+                .opacity(disabled ? 0.3 : 1)+        }+        .buttonStyle(.plain)+        .disabled(disabled)+        .background(Circle().fill(Color.white.opacity(0.05)))+        .overlay(Circle().strokeBorder(FluxTheme.Palette.border, lineWidth: FluxTheme.Metrics.hairline))+        .accessibilityLabel(accessibilityLabel)+    }++    /// Period label (req 4.1): week → `"Jun 2 – 8"` (cross-month+    /// `"May 29 – Jun 4"`); month → `"May 2026"`. Static so the view checks+    /// can assert the formatting without rendering.+    static func label(for range: HistoryRange, period: HistoryPeriod) -> String {+        switch range {+        case .days:+            // The header is never shown for fixed ranges (req 1.5).+            return ""+        case .weekToDate:+            let calendar = DateFormatting.sydneyCalendar+            let sameMonth = calendar.isDate(period.start, equalTo: period.lastDay, toGranularity: .month)+            let start = DateFormatting.shortMonthDay(from: period.start)+            let end = sameMonth+                ? HistoryPeriodLabelFormatter.dayOnly.string(from: period.lastDay)+                : DateFormatting.shortMonthDay(from: period.lastDay)+            return "\(start) – \(end)"+        case .monthToDate:+            return HistoryPeriodLabelFormatter.monthYear.string(from: period.start)+        }+    }+}++/// Sydney-zoned label formatters, mirroring the `HistorySummaryDateFormatter`+/// precedent. Only formats that exist nowhere else live here — the shared+/// `"MMM d"` form comes from `DateFormatting.shortMonthDay(from:)`.+private enum HistoryPeriodLabelFormatter {+    static let dayOnly: DateFormatter = {+        let formatter = DateFormatter()+        formatter.timeZone = DateFormatting.sydneyTimeZone+        formatter.dateFormat = "d"+        return formatter+    }()++    static let monthYear: DateFormatter = {+        let formatter = DateFormatter()+        formatter.timeZone = DateFormatting.sydneyTimeZone+        formatter.dateFormat = "MMM yyyy"+        return formatter+    }()+}
Flux/Flux/History/HistorySolarCard.swift Modified +6 / -2
diff --git a/Flux/Flux/History/HistorySolarCard.swift b/Flux/Flux/History/HistorySolarCard.swiftindex 87648c0..acdfddb 100644--- a/Flux/Flux/History/HistorySolarCard.swift+++ b/Flux/Flux/History/HistorySolarCard.swift@@ -1,4 +1,5 @@ import Charts+import FluxCore import SwiftUI  struct HistorySolarCard: View {@@ -7,13 +8,16 @@ struct HistorySolarCard: View {     let entries: [HistoryViewModel.SolarEntry]     let summary: HistoryViewModel.PeriodSummary     let selectedDate: Date?-    let rangeDays: Int+    /// The rendered window, passed by `HistoryView` from the view model's+    /// resolved query so the enlarged chart fetches the same period —+    /// including a navigated past one (Decision 13).+    let periodQuery: HistoryQuery     /// Full-period x-axis reservation (Wk/Mo). `nil` (the default, used by the     /// expanded host which only knows a day count) leaves the chart auto-fitting.     var chartDomain: HistoryChartDomain?     let onSelect: (String) -> Void -    var expansionScope: ChartScope { .historyRange(days: rangeDays) }+    var expansionScope: ChartScope { .historyRange(periodQuery) }      var body: some View {         HistoryCardChrome(
Flux/Flux/History/HistoryStatsOverviewCard.swift Modified +13 / -1
diff --git a/Flux/Flux/History/HistoryStatsOverviewCard.swift b/Flux/Flux/History/HistoryStatsOverviewCard.swiftindex 4f4d404..120d01b 100644--- a/Flux/Flux/History/HistoryStatsOverviewCard.swift+++ b/Flux/Flux/History/HistoryStatsOverviewCard.swift@@ -4,6 +4,10 @@ import SwiftUI struct HistoryStatsOverviewCard: View {     let summary: HistoryViewModel.PeriodSummary     let entries: [HistoryViewModel.SolarEntry]+    /// Calendar day-count of the rendered past period, or `nil` for the+    /// current period. Drives the "N of M days" coverage subtitle (req 6.2);+    /// the costs card's "days priced" caption is intentionally unchanged.+    var periodDays: Int?     let onSelect: (String) -> Void      /// Case order is load-bearing: `ForEach(TileKey.allCases, …)` renders tiles@@ -33,7 +37,7 @@ struct HistoryStatsOverviewCard: View {         HistoryCardChrome(             title: "Period overview",             kpi: HistoryStatsFormatters.dateRange(entries: entries),-            subtitle: nil+            subtitle: Self.periodCoverageSubtitle(dayCount: summary.dayCount, periodDays: periodDays)         ) {             grid         }@@ -79,6 +83,14 @@ struct HistoryStatsOverviewCard: View { // MARK: - Static helpers  extension HistoryStatsOverviewCard {+    /// "N of M days" when a past period has fewer recorded days than calendar+    /// days (req 6.2); `nil` for the current period (`periodDays == nil`,+    /// req 6.3) or a fully recorded past period.+    static func periodCoverageSubtitle(dayCount: Int, periodDays: Int?) -> String? {+        guard let periodDays, dayCount < periodDays else { return nil }+        return "\(dayCount) of \(periodDays) days"+    }+     static func label(for tile: TileKey) -> String {         switch tile {         case .totalUsage: return "Total usage"
Flux/Flux/History/HistoryView.swift Modified +88 / -43
diff --git a/Flux/Flux/History/HistoryView.swift b/Flux/Flux/History/HistoryView.swiftindex e7c4f58..2874626 100644--- a/Flux/Flux/History/HistoryView.swift+++ b/Flux/Flux/History/HistoryView.swift@@ -66,13 +66,37 @@ struct HistoryView: View {                 }                 .pickerStyle(.segmented) -                NoteRowView(text: viewModel.selectedDay?.note)+                // Period navigation is Wk/Mo only (req 1.5); fixed ranges keep+                // their existing chrome untouched.+                if isPeriodNavigable, let period = viewModel.displayedPeriod {+                    HistoryPeriodHeader(+                        range: viewModel.resolvedRange,+                        period: period,+                        isViewingCurrentPeriod: viewModel.isViewingCurrentPeriod,+                        pickerUpperBound: viewModel.sydneyTodayEnd,+                        onPrevious: { Task { await viewModel.navigatePrevious() } },+                        onNext: { Task { await viewModel.navigateNext() } },+                        onJump: { date in Task { await viewModel.jumpTo(date: date) } },+                        onReturnToCurrent: { Task { await viewModel.returnToCurrent() } }+                    )+                }++                if viewModel.showsEmptyPeriodNotice {+                    noDataForPeriodNotice+                } else {+                    NoteRowView(text: viewModel.selectedDay?.note)+                }                  if viewModel.days.isEmpty, let error = viewModel.error, !viewModel.isLoading {                     errorState(error)-                } else if viewModel.days.isEmpty, !viewModel.isLoading {+                } else if viewModel.days.isEmpty, !viewModel.isLoading, !viewModel.showsEmptyPeriodNotice {                     emptyState                 } else {+                    // An empty past period falls through here on purpose: the+                    // cards stay rendered and the HistoryChartDomain scaffold+                    // reserves the full-period axis (req 1.6), with the+                    // compact notice above replacing the note row — never the+                    // replace-everything emptyState.                     if usesRegularLayout {                         historyContentRegular                     } else {@@ -102,13 +126,13 @@ struct HistoryView: View {             }         }         .task {-            async let history: Void = viewModel.loadHistory(range: selectedRange)+            async let history: Void = viewModel.selectRange(selectedRange)             async let pricing: Void = viewModel.refreshPricing()             _ = await (history, pricing)         }         .onChange(of: selectedRange) { _, newRange in             Task {-                async let history: Void = viewModel.loadHistory(range: newRange)+                async let history: Void = viewModel.selectRange(newRange)                 async let pricing: Void = viewModel.refreshPricing()                 _ = await (history, pricing)             }@@ -117,9 +141,24 @@ struct HistoryView: View {         .macRefreshAction { [viewModel] in             await viewModel.reload()         }+        .focusable()+        .onKeyPress(.leftArrow) {+            guard isPeriodNavigable else { return .ignored }+            Task { await viewModel.navigatePrevious() }+            return .handled+        }+        .onKeyPress(.rightArrow) {+            guard isPeriodNavigable, !viewModel.isViewingCurrentPeriod else { return .ignored }+            Task { await viewModel.navigateNext() }+            return .handled+        }         #endif+        // reload() rather than loadHistory(range: selectedRange): refresh+        // re-fetches the displayed period — including a navigated past one+        // (req 1.8) — and a stale `selectedRange` capture can never diverge+        // from the view model's `lastRequestedRange`.         .refreshable {-            await viewModel.loadHistory(range: selectedRange)+            await viewModel.reload()         }         #if !os(macOS)         .sheet(isPresented: $showingSettings) {@@ -139,18 +178,36 @@ struct HistoryView: View {      private var usesRegularLayout: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) } +    /// Period navigation exists only for the calendar-anchored ranges (req 1.5).+    private var isPeriodNavigable: Bool {+        selectedRange == .weekToDate || selectedRange == .monthToDate+    }++    /// Compact no-data notice for a successfully fetched but empty past period+    /// (req 1.6) — replaces the note row, visually distinct from `errorState`.+    private var noDataForPeriodNotice: some View {+        FluxPanel {+            Label("No data for this period", systemImage: "calendar.badge.exclamationmark")+                .appFontSystem(size: 13)+                .foregroundStyle(FluxTheme.Palette.secondaryText)+                .frame(maxWidth: .infinity, alignment: .leading)+        }+    }+     @ViewBuilder     private var historyContent: some View {         let derived = viewModel.derived+        // Captured once like `derived`: each access re-runs the interval math.+        let chartDomain = viewModel.chartDomain         let selectedDate = viewModel.selectedDay.flatMap { DateFormatting.parseDayDate($0.date) }         VStack(alignment: .leading, spacing: 16) {             statsOverviewCard(derived: derived)             if let periodCosts = viewModel.periodCosts {                 HistoryPeriodCostsCard(costs: periodCosts)             }-            solarCard(derived: derived, selectedDate: selectedDate)-            gridUsageCard(derived: derived, selectedDate: selectedDate)-            dailyUsageCard(derived: derived, selectedDate: selectedDate)+            solarCard(derived: derived, selectedDate: selectedDate, chartDomain: chartDomain)+            gridUsageCard(derived: derived, selectedDate: selectedDate, chartDomain: chartDomain)+            dailyUsageCard(derived: derived, selectedDate: selectedDate, chartDomain: chartDomain)             if let selectedDay = viewModel.selectedDay {                 summaryCard(for: selectedDay)             }@@ -162,6 +219,7 @@ struct HistoryView: View {     @ViewBuilder     private var historyContentRegular: some View {         let derived = viewModel.derived+        let chartDomain = viewModel.chartDomain         let selectedDate = viewModel.selectedDay.flatMap { DateFormatting.parseDayDate($0.date) }         VStack(alignment: .leading, spacing: 16) {             statsOverviewCard(derived: derived)@@ -169,9 +227,9 @@ struct HistoryView: View {                 HistoryPeriodCostsCard(costs: periodCosts)             }             AdaptiveColumnsLayout {-                solarCard(derived: derived, selectedDate: selectedDate)-                gridUsageCard(derived: derived, selectedDate: selectedDate)-                dailyUsageCard(derived: derived, selectedDate: selectedDate)+                solarCard(derived: derived, selectedDate: selectedDate, chartDomain: chartDomain)+                gridUsageCard(derived: derived, selectedDate: selectedDate, chartDomain: chartDomain)+                dailyUsageCard(derived: derived, selectedDate: selectedDate, chartDomain: chartDomain)                 if let selectedDay = viewModel.selectedDay {                     summaryCard(for: selectedDay)                 }@@ -186,42 +244,49 @@ struct HistoryView: View {         HistoryStatsOverviewCard(             summary: derived.summary,             entries: derived.solar,+            periodDays: viewModel.pastPeriodDayCount,             onSelect: selectDay         )     }      @ViewBuilder-    private func solarCard(derived: HistoryViewModel.DerivedState, selectedDate: Date?) -> some View {+    private func solarCard(+        derived: HistoryViewModel.DerivedState, selectedDate: Date?, chartDomain: HistoryChartDomain?+    ) -> some View {         HistorySolarCard(             entries: derived.solar,             summary: derived.summary,             selectedDate: selectedDate,-            rangeDays: viewModel.resolvedRangeDays,-            chartDomain: viewModel.chartDomain,+            periodQuery: viewModel.periodQuery,+            chartDomain: chartDomain,             onSelect: selectDay         )     }      @ViewBuilder-    private func gridUsageCard(derived: HistoryViewModel.DerivedState, selectedDate: Date?) -> some View {+    private func gridUsageCard(+        derived: HistoryViewModel.DerivedState, selectedDate: Date?, chartDomain: HistoryChartDomain?+    ) -> some View {         HistoryGridUsageCard(             entries: derived.grid,             summary: derived.summary,             selectedDate: selectedDate,-            rangeDays: viewModel.resolvedRangeDays,-            chartDomain: viewModel.chartDomain,+            periodQuery: viewModel.periodQuery,+            chartDomain: chartDomain,             onSelect: selectDay         )     }      @ViewBuilder-    private func dailyUsageCard(derived: HistoryViewModel.DerivedState, selectedDate: Date?) -> some View {+    private func dailyUsageCard(+        derived: HistoryViewModel.DerivedState, selectedDate: Date?, chartDomain: HistoryChartDomain?+    ) -> some View {         HistoryDailyUsageCard(             entries: derived.dailyUsage,             summary: derived.summary,             selectedDate: selectedDate,-            rangeDays: viewModel.resolvedRangeDays,-            chartDomain: viewModel.chartDomain,+            periodQuery: viewModel.periodQuery,+            chartDomain: chartDomain,             onSelect: selectDay         )     }@@ -291,8 +356,10 @@ struct HistoryView: View {                 .appFont(.subheadline)                 .foregroundStyle(.secondary)             HStack {+                // reload() keeps a navigated past period in place on retry+                // (req 1.8) — see the .refreshable note above.                 Button("Retry") {-                    Task { await viewModel.loadHistory(range: selectedRange) }+                    Task { await viewModel.reload() }                 }                 .buttonStyle(.borderedProminent) @@ -331,25 +398,3 @@ private enum HistorySummaryDateFormatter {         return formatter     }() }--#if DEBUG-#Preview("Compact") {-    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)-    // swiftlint:disable:next force_try-    let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)-    NavigationStack {-        HistoryView(apiClient: MockFluxAPIClient.preview, modelContext: ModelContext(container))-    }-}--#Preview("Regular 770") {-    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)-    // swiftlint:disable:next force_try-    let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)-    NavigationStack {-        HistoryView(apiClient: MockFluxAPIClient.preview, modelContext: ModelContext(container))-    }-    .frame(width: 770)-    .environment(\.horizontalSizeClass, .regular)-}-#endif
Flux/Flux/History/HistoryViewModel+Presentation.swift Added +95 / -0
diff --git a/Flux/Flux/History/HistoryViewModel+Presentation.swift b/Flux/Flux/History/HistoryViewModel+Presentation.swiftnew file mode 100644index 0000000..7627912--- /dev/null+++ b/Flux/Flux/History/HistoryViewModel+Presentation.swift@@ -0,0 +1,95 @@+import FluxCore+import Foundation++// MARK: - Presentation-facing derived state+//+// Lives in its own file to keep `HistoryViewModel.swift` under the SwiftLint+// file-length cap. Everything here is derived from the resolved snapshot+// (`resolvedRange`/`resolvedQuery`) or `days`, never from in-flight state.++extension HistoryViewModel {+    /// Series and period summary derived from `days`. With at most 30+    /// entries the recomputation is cheap; storing the result would just+    /// add cache-invalidation work. Callers (notably the View) should+    /// capture this once per render rather than reading the convenience+    /// accessors below repeatedly.+    var derived: DerivedState {+        DerivedState(days: days, now: nowProvider())+    }++    /// Full-period x-axis reservation for the Wk/Mo ranges (current or past+    /// period — past periods always reserve the full week/month, req 1.4), or+    /// `nil` for the fixed `.days` ranges, which always span N days ending+    /// today and so need no reservation.+    var chartDomain: HistoryChartDomain? {+        HistoryChartDomain.make(+            range: resolvedRange,+            referenceDate: chartReferenceDate,+            firstWeekday: firstWeekdayProvider()+        )+    }++    /// An instant inside the rendered period: the period start for a resolved+    /// past range, falling back to `now` for the `.days` form.+    private var chartReferenceDate: Date {+        if case let .dateRange(start, _) = resolvedQuery,+           let date = DateFormatting.parseDayDate(start) {+            return date+        }+        return nowProvider()+    }++    /// The expansion scope's query — the rendered window, so an enlarged chart+    /// can never disagree with the card it came from (Decision 13).+    var periodQuery: HistoryQuery { resolvedQuery }++    /// True when the rendered period contains Sydney today — i.e. the last+    /// load resolved to the `.days` form. Keyed off `resolvedQuery` rather+    /// than `periodAnchor` so the next-chevron disabled state stays correct+    /// across a midnight period-rollover until the next load re-resolves.+    var isViewingCurrentPeriod: Bool {+        if case .days = resolvedQuery { return true }+        return false+    }++    /// True when a past period fetched successfully but holds no data: the+    /// cards stay rendered with the full-period axis and a compact notice+    /// (req 1.6). Distinct from the fetch-error state (req 7.3), which sets+    /// `error` instead.+    var showsEmptyPeriodNotice: Bool {+        days.isEmpty && error == nil && !isViewingCurrentPeriod+    }++    /// The rendered period for the header label — `nil` for fixed `.days`+    /// ranges, where the header is not shown (req 1.5).+    var displayedPeriod: HistoryPeriod? {+        period(for: resolvedRange, containing: chartReferenceDate)+    }++    /// The stats card's "N of M days" denominator: the period's calendar+    /// day-count for a past period, or `nil` for the current one, which needs+    /// no coverage indicator (req 6.3).+    var pastPeriodDayCount: Int? {+        isViewingCurrentPeriod ? nil : resolvedRangeDays+    }++    /// Upper bound for the jump picker: the last instant of the Sydney day+    /// containing now, so no date after Sydney-today is selectable (req 3.2).+    var sydneyTodayEnd: Date {+        let calendar = DateFormatting.sydneyCalendar+        let startOfToday = calendar.startOfDay(for: nowProvider())+        guard let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) else {+            return startOfToday+        }+        return startOfTomorrow.addingTimeInterval(-1)+    }++    /// Convenience accessors for tests and previews. Each rebuilds+    /// `DerivedState` independently — production callers should read+    /// `derived` once and destructure instead.+    var solarSeries: [SolarEntry] { derived.solar }+    var gridSeries: [GridEntry] { derived.grid }+    var batterySeries: [BatteryEntry] { derived.battery }+    var dailyUsageSeries: [DailyUsageEntry] { derived.dailyUsage }+    var summary: PeriodSummary { derived.summary }+}
Flux/Flux/History/HistoryViewModel.swift Modified +179 / -64
diff --git a/Flux/Flux/History/HistoryViewModel.swift b/Flux/Flux/History/HistoryViewModel.swiftindex 9eacafa..dd8f4ef 100644--- a/Flux/Flux/History/HistoryViewModel.swift+++ b/Flux/Flux/History/HistoryViewModel.swift@@ -10,20 +10,50 @@ final class HistoryViewModel {     private(set) var isLoading = false     private(set) var error: FluxAPIError?     private(set) var lastRequestedRange: HistoryRange = .days(7)-    /// Inclusive day-count resolved from `lastRequestedRange` on the last load.-    /// Read by `HistoryView` for the cards' `rangeDays:` (which carries the-    /// expansion scope's `N`).-    private(set) var resolvedRangeDays: Int = 7+    /// Inclusive day-count of the rendered window: the requested `N` for the+    /// `.days` form, or the period's calendar day-count for a past range — the+    /// M in the stats card's "N of M days". Derived from `resolvedQuery` so it+    /// can never disagree with the rendered data; the unparseable-`dateRange`+    /// arm is unreachable (the only producer is `HistoryPeriod`'s formatted+    /// strings), so 0 is just a conservative fallback.+    var resolvedRangeDays: Int { resolvedQuery.dayCount ?? 0 }     /// The range whose data is currently in `days`. Drives `chartDomain` so the     /// charts' x-axis reservation matches the rendered data, not an in-flight     /// selection.     private(set) var resolvedRange: HistoryRange = .days(7)+    /// The query whose data is currently in `days` — the resolved counterpart+    /// of `periodAnchor`, set atomically with `days` inside `load()` so+    /// everything user-visible (chart domain, period label, expansion scope,+    /// next-chevron state) reflects rendered data, never an in-flight request.+    private(set) var resolvedQuery: HistoryQuery = .days(7)+    /// Sydney-midnight start of the displayed past period, or `nil` for the+    /// current (to-date) period. Mutated ONLY by the intent methods — the load+    /// path is anchor-agnostic, so refresh can never reset the user's place+    /// (req 1.8).+    private(set) var periodAnchor: Date?++    /// The (range, anchor) pair the in-flight coalescing loop is keyed on, so+    /// a navigation issued mid-load is honoured the same way a range change is.+    private struct RequestedPeriod: Equatable {+        let range: HistoryRange+        let anchor: Date?+    }++    /// Always the latest selection: `lastRequestedRange` is recorded before the+    /// isLoading guard and `periodAnchor` is mutated only by the intent+    /// methods, so reading them live is exactly the pair the coalescing loop+    /// must converge on.+    private var lastRequestedPeriod: RequestedPeriod {+        RequestedPeriod(range: lastRequestedRange, anchor: periodAnchor)+    }      private let apiClient: any FluxAPIClient     private let modelContext: ModelContext     private let pricingService: PricingService-    private let nowProvider: @Sendable () -> Date-    private let firstWeekdayProvider: @Sendable () -> Int+    // Internal (not private) so the presentation extension in+    // HistoryViewModel+Presentation.swift can derive from the same clock.+    let nowProvider: @Sendable () -> Date+    let firstWeekdayProvider: @Sendable () -> Int     private let warn: (String) -> Void      init(@@ -56,43 +86,53 @@ final class HistoryViewModel {     }      func loadHistory(range: HistoryRange) async {-        // Record the latest selection before the guard so a range chosen during-        // an in-flight load is not dropped — the in-flight load coalesces to it.+        // Record the latest selection before the guard so a range or period+        // chosen during an in-flight load is not dropped — the in-flight load+        // coalesces to it. The anchor is read, never written, here (req 1.8).         lastRequestedRange = range         guard !isLoading else { return }          isLoading = true         defer { isLoading = false } -        // Loop so a newer range selected mid-load is honoured: after each fetch-        // we re-check `lastRequestedRange` and reload if it changed. The latest-        // selection always wins, so the picker and the rendered data agree. The-        // loop terminates as soon as no newer range arrived during the last-        // fetch; each iteration is a real network round-trip, so it can only-        // keep looping while selections keep arriving faster than they complete.-        var loadedRange = range+        // Loop so a newer (range, anchor) pair selected mid-load is honoured:+        // after each fetch we re-check `lastRequestedPeriod` and reload if it+        // changed. The latest selection always wins, so the picker, the period+        // header, and the rendered data agree. The loop terminates as soon as+        // no newer request arrived during the last fetch; each iteration is a+        // real network round-trip, so it can only keep looping while requests+        // keep arriving faster than they complete.+        var loadedPeriod = lastRequestedPeriod         while true {-            await load(loadedRange)-            if lastRequestedRange == loadedRange { break }-            loadedRange = lastRequestedRange+            await load(loadedPeriod)+            if lastRequestedPeriod == loadedPeriod { break }+            loadedPeriod = lastRequestedPeriod         }     } -    private func load(_ range: HistoryRange) async {+    /// The query and cache window for one load. Resolved before the fetch;+    /// adopted into the resolved snapshot only once the fetch has settled,+    /// atomically with `days`.+    private struct LoadTarget {+        let query: HistoryQuery+        let cacheStart: String+        let cacheEnd: String+    }++    private func load(_ period: RequestedPeriod) async {         let now = nowProvider()-        let resolvedDays = range.resolvedDays(now: now, firstWeekday: firstWeekdayProvider())-        resolvedRangeDays = resolvedDays-        resolvedRange = range+        let target = resolveTarget(for: period, now: now)          do {-            let response = try await apiClient.fetchHistory(days: resolvedDays)+            let response = try await apiClient.fetchHistory(query: target.query)+            adopt(target, range: period.range)             days = response.days             error = nil             selectDefaultDayIfNeeded()             try cacheHistoricalDays(response.days)         } catch {-            let startDate = DateFormatting.windowStartDateString(inclusiveDays: resolvedDays, now: now)-            let fallbackDays = loadCachedDays(onOrAfter: startDate)+            adopt(target, range: period.range)+            let fallbackDays = loadCachedDays(from: target.cacheStart, through: target.cacheEnd)             if fallbackDays.isEmpty {                 self.error = FluxAPIError.from(error)                 days = []@@ -105,6 +145,27 @@ final class HistoryViewModel {         }     } +    private func resolveTarget(for period: RequestedPeriod, now: Date) -> LoadTarget {+        if let anchor = period.anchor, let resolved = self.period(for: period.range, containing: anchor) {+            return LoadTarget(+                query: .dateRange(start: resolved.startDateString, end: resolved.endDateString),+                cacheStart: resolved.startDateString,+                cacheEnd: resolved.endDateString+            )+        }+        let resolvedDays = period.range.resolvedDays(now: now, firstWeekday: firstWeekdayProvider())+        return LoadTarget(+            query: .days(resolvedDays),+            cacheStart: DateFormatting.windowStartDateString(inclusiveDays: resolvedDays, now: now),+            cacheEnd: DateFormatting.todayDateString(now: now)+        )+    }++    private func adopt(_ target: LoadTarget, range: HistoryRange) {+        resolvedRange = range+        resolvedQuery = target.query+    }+     func selectDay(_ day: DayEnergy) {         selectedDay = day     }@@ -113,6 +174,87 @@ final class HistoryViewModel {         await loadHistory(range: lastRequestedRange)     } +    // MARK: - Period-navigation intents++    /// Range picker selection: any range change resets to the current period+    /// for the newly selected range (req 2.3, Decision 4).+    func selectRange(_ range: HistoryRange) async {+        periodAnchor = nil+        await loadHistory(range: range)+    }++    /// Steps to the period immediately before the displayed one (req 1.2).+    func navigatePrevious() async {+        guard let base = navigationBasePeriod() else { return }+        await navigate(to: base.previous(range: lastRequestedRange, firstWeekday: firstWeekdayProvider()))+    }++    /// Steps to the period immediately after the displayed one; landing on the+    /// period containing Sydney-today collapses to the current to-date view+    /// (req 1.3).+    func navigateNext() async {+        // Already at the current period — nothing lies after it. Clamping here+        // (like DayDetailViewModel's isToday guard) matters because the UI's+        // disabled state reads the resolved snapshot, which lags an in-flight+        // load: a rapid double-tap or macOS key-repeat would otherwise issue a+        // future-dated range request the server rejects.+        guard periodAnchor != nil, let base = navigationBasePeriod() else { return }+        await navigate(to: base.next(range: lastRequestedRange, firstWeekday: firstWeekdayProvider()))+    }++    /// Jumps to the week/month containing `date`; a date inside the current+    /// period shows the current to-date view (req 3.1, 3.3).+    func jumpTo(date: Date) async {+        guard let target = period(for: lastRequestedRange, containing: date) else { return }+        await navigate(to: target)+    }++    /// One-tap return to the current to-date period (req 2.1).+    func returnToCurrent() async {+        periodAnchor = nil+        await loadHistory(range: lastRequestedRange)+    }++    private func navigate(to target: HistoryPeriod) async {+        // The current-period check uses Sydney "today" via nowProvider(),+        // re-evaluated on each trigger (requirements Definitions).+        let isCurrent = target.contains(nowProvider())+        // Re-selecting the already-rendered period (the jump picker landing on+        // a date inside the displayed week/month) would re-fetch an identical+        // window — skip it. Only when settled (an in-flight load may still+        // coalesce to a newer selection) and healthy (an error state must keep+        // its retry path).+        if !isLoading, error == nil, isAlreadyRendered(target, isCurrent: isCurrent) {+            return+        }+        periodAnchor = isCurrent ? nil : target.start+        await loadHistory(range: lastRequestedRange)+    }++    /// True when `target` is exactly the period whose data is in `days`,+    /// keyed off the resolved snapshot like every other rendered-state check.+    private func isAlreadyRendered(_ target: HistoryPeriod, isCurrent: Bool) -> Bool {+        if isCurrent { return isViewingCurrentPeriod }+        return resolvedQuery == .dateRange(start: target.startDateString, end: target.endDateString)+    }++    private func navigationBasePeriod() -> HistoryPeriod? {+        period(for: lastRequestedRange, containing: periodAnchor ?? nowProvider())+    }++    // Internal (not private) so the presentation extension's `displayedPeriod`+    // derives the rendered period through the same switch.+    func period(for range: HistoryRange, containing date: Date) -> HistoryPeriod? {+        switch range {+        case .days:+            return nil+        case .weekToDate:+            return .week(containing: date, firstWeekday: firstWeekdayProvider())+        case .monthToDate:+            return .month(containing: date)+        }+    }+     private func cacheHistoricalDays(_ dayEnergies: [DayEnergy]) throws {         let now = nowProvider()         let datesToCache = dayEnergies@@ -170,17 +312,21 @@ final class HistoryViewModel {         }     } -    /// Offline fallback bounded by the resolved window's start date. The dates-    /// are zero-padded `YYYY-MM-DD`, so a lexicographic `>=` matches chronological-    /// order. Returned ascending to mirror the online response shape, so-    /// `selectDefaultDayIfNeeded` auto-selects the newest (today) day from-    /// `days.last` just as it does online.-    private func loadCachedDays(onOrAfter startDate: String) -> [DayEnergy] {-        // Captured as a `let` so the macro can embed it in the predicate.+    /// Offline fallback bounded by both window ends (req 7.2) — a past period+    /// must not pull in newer cached days, and the current window must not pull+    /// in pre-window ones. The dates are zero-padded `YYYY-MM-DD`, so+    /// lexicographic comparison matches chronological order. Returned ascending+    /// to mirror the online response shape, so `selectDefaultDayIfNeeded`+    /// auto-selects the newest day from `days.last` just as it does online.+    /// For the current window the upper bound is today, which excludes nothing+    /// extra since nothing later than today is ever cached.+    private func loadCachedDays(from startDate: String, through endDate: String) -> [DayEnergy] {+        // Captured as `let`s so the macro can embed them in the predicate.         let lowerBound = startDate+        let upperBound = endDate         let descriptor = FetchDescriptor<CachedDayEnergy>(             predicate: #Predicate<CachedDayEnergy> { cached in-                cached.date >= lowerBound+                cached.date >= lowerBound && cached.date <= upperBound             },             sortBy: [SortDescriptor(\CachedDayEnergy.date, order: .forward)]         )@@ -201,34 +347,3 @@ final class HistoryViewModel {         self.selectedDay = days.first(where: { $0.date == selectedDay.date }) ?? days.last     } }--extension HistoryViewModel {-    /// Series and period summary derived from `days`. With at most 30-    /// entries the recomputation is cheap; storing the result would just-    /// add cache-invalidation work. Callers (notably the View) should-    /// capture this once per render rather than reading the convenience-    /// accessors below repeatedly.-    var derived: DerivedState {-        DerivedState(days: days, now: nowProvider())-    }--    /// Full-period x-axis reservation for the to-date ranges (Wk → full week,-    /// Mo → full calendar month), or `nil` for the fixed `.days` ranges, which-    /// always span N days ending today and so need no reservation.-    var chartDomain: HistoryChartDomain? {-        HistoryChartDomain.make(-            range: resolvedRange,-            now: nowProvider(),-            firstWeekday: firstWeekdayProvider()-        )-    }--    /// Convenience accessors for tests and previews. Each rebuilds-    /// `DerivedState` independently — production callers should read-    /// `derived` once and destructure instead.-    var solarSeries: [SolarEntry] { derived.solar }-    var gridSeries: [GridEntry] { derived.grid }-    var batterySeries: [BatteryEntry] { derived.battery }-    var dailyUsageSeries: [DailyUsageEntry] { derived.dailyUsage }-    var summary: PeriodSummary { derived.summary }-}
Flux/Flux/History/HistoryViewPreviews.swift Added +28 / -0
diff --git a/Flux/Flux/History/HistoryViewPreviews.swift b/Flux/Flux/History/HistoryViewPreviews.swiftnew file mode 100644index 0000000..d6b4ffd--- /dev/null+++ b/Flux/Flux/History/HistoryViewPreviews.swift@@ -0,0 +1,28 @@+import FluxCore+import SwiftData+import SwiftUI++// Previews live in their own file to keep `HistoryView.swift` under the+// SwiftLint file-length cap.++#if DEBUG+#Preview("Compact") {+    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+    // swiftlint:disable:next force_try+    let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+    NavigationStack {+        HistoryView(apiClient: MockFluxAPIClient.preview, modelContext: ModelContext(container))+    }+}++#Preview("Regular 770") {+    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+    // swiftlint:disable:next force_try+    let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+    NavigationStack {+        HistoryView(apiClient: MockFluxAPIClient.preview, modelContext: ModelContext(container))+    }+    .frame(width: 770)+    .environment(\.horizontalSizeClass, .regular)+}+#endif
Flux/Flux/RootView.swift Modified +1 / -1
diff --git a/Flux/Flux/RootView.swift b/Flux/Flux/RootView.swiftindex fd21d46..895463c 100644--- a/Flux/Flux/RootView.swift+++ b/Flux/Flux/RootView.swift@@ -10,7 +10,7 @@ struct RootView: View {     // held in `ChartScopeRegistry`, which is an in-memory `@MainActor`     // store and therefore lost on cold relaunch. After restoration,     // `ExpandedChartView.resolvedScope` falls back to-    // `historyRange(days: defaultHistoryRangeDays)` for History kinds+    // `historyRange(.days(defaultHistoryRangeDays))` for History kinds     // and `daySpecific(date: today())` for Day kinds. A user who     // backgrounded with a 14-day range will see the chart reopen at     // 7 days; documented gap.
Flux/Flux/Services/MockFluxAPIClient.swift Modified +14 / -0
diff --git a/Flux/Flux/Services/MockFluxAPIClient.swift b/Flux/Flux/Services/MockFluxAPIClient.swiftindex 5465d86..ba7376b 100644--- a/Flux/Flux/Services/MockFluxAPIClient.swift+++ b/Flux/Flux/Services/MockFluxAPIClient.swift@@ -261,6 +261,20 @@ final actor MockFluxAPIClient: FluxAPIClient {         return HistoryResponse(days: selectedDays)     } +    /// Real implementation rather than the protocol default: this mock backs+    /// every preview, and under the default a `.dateRange` would throw, so+    /// previews navigating to a past period would show the error state.+    func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse {+        switch query {+        case let .days(days):+            return try await fetchHistory(days: days)+        case let .dateRange(start, end):+            // Zero-padded YYYY-MM-DD strings compare chronologically.+            let selectedDays = Self.historyDays.filter { $0.date >= start && $0.date <= end }+            return HistoryResponse(days: selectedDays)+        }+    }+     func fetchDay(date: String) async throws -> DayDetailResponse {         Self.dayDetailResponse(for: date)     }
Flux/FluxTests/Charts/ChartKindTests.swift Modified +6 / -5
diff --git a/Flux/FluxTests/Charts/ChartKindTests.swift b/Flux/FluxTests/Charts/ChartKindTests.swiftindex d96cf91..ece7ced 100644--- a/Flux/FluxTests/Charts/ChartKindTests.swift+++ b/Flux/FluxTests/Charts/ChartKindTests.swift@@ -1,3 +1,4 @@+import FluxCore import Foundation import Testing @testable import Flux@@ -31,7 +32,7 @@ struct ChartKindTests {      @Test("ChartScope.historyRange round-trips through JSON")     func chartScopeHistoryRangeRoundTrips() throws {-        let scope = ChartScope.historyRange(days: 14)+        let scope = ChartScope.historyRange(.days(14))         let data = try JSONEncoder().encode(scope)         let decoded = try JSONDecoder().decode(ChartScope.self, from: data)         #expect(decoded == scope)@@ -50,9 +51,9 @@ struct ChartKindTests {     func chartScopeHashableDistinguishesCases() {         let date = Date(timeIntervalSince1970: 1_700_000_000)         let scopes: Set<ChartScope> = [-            .historyRange(days: 7),-            .historyRange(days: 14),-            .historyRange(days: 30),+            .historyRange(.days(7)),+            .historyRange(.days(14)),+            .historyRange(.days(30)),             .daySpecific(date: date)         ]         #expect(scopes.count == 4)@@ -60,6 +61,6 @@ struct ChartKindTests {      @Test("ChartScope.historyRange differing only in days are not equal")     func chartScopeHistoryRangeDiffersByDays() {-        #expect(ChartScope.historyRange(days: 7) != ChartScope.historyRange(days: 14))+        #expect(ChartScope.historyRange(.days(7)) != ChartScope.historyRange(.days(14)))     } }
Flux/FluxTests/Charts/ChartSceneIntegrationTests.swift Modified +5 / -4
diff --git a/Flux/FluxTests/Charts/ChartSceneIntegrationTests.swift b/Flux/FluxTests/Charts/ChartSceneIntegrationTests.swiftindex e6a85e1..4bb2122 100644--- a/Flux/FluxTests/Charts/ChartSceneIntegrationTests.swift+++ b/Flux/FluxTests/Charts/ChartSceneIntegrationTests.swift@@ -1,4 +1,5 @@ #if os(macOS)+import FluxCore import Foundation import Testing @testable import Flux@@ -31,11 +32,11 @@ struct ChartSceneIntegrationTests {         }          action(.dayPower, scope: .daySpecific(date: Date(timeIntervalSince1970: 1_714_521_600)))-        action(.historySolar, scope: .historyRange(days: 7))+        action(.historySolar, scope: .historyRange(.days(7)))          #expect(registry.current.count == 2)         #expect(registry.current[.dayPower] == .daySpecific(date: Date(timeIntervalSince1970: 1_714_521_600)))-        #expect(registry.current[.historySolar] == .historyRange(days: 7))+        #expect(registry.current[.historySolar] == .historyRange(.days(7)))     }      @Test("Relaunch: a fresh registry is empty (no persistence)")@@ -56,8 +57,8 @@ struct ChartSceneIntegrationTests {         let solar = ExpandedChartView.resolvedScope(for: .historySolar, in: registry, today: { today })         let dayPower = ExpandedChartView.resolvedScope(for: .dayPower, in: registry, today: { today }) -        if case let .historyRange(days) = solar {-            #expect(days > 0)+        if case let .historyRange(query) = solar {+            #expect(query == .days(ExpandedChartView.defaultHistoryRangeDays))         } else {             Issue.record("Expected historyRange fallback for historySolar")         }
Flux/FluxTests/Charts/ChartScopeRegistryTests.swift Modified +3 / -2
diff --git a/Flux/FluxTests/Charts/ChartScopeRegistryTests.swift b/Flux/FluxTests/Charts/ChartScopeRegistryTests.swiftindex 8792030..6b960a0 100644--- a/Flux/FluxTests/Charts/ChartScopeRegistryTests.swift+++ b/Flux/FluxTests/Charts/ChartScopeRegistryTests.swift@@ -1,3 +1,4 @@+import FluxCore import Foundation import Testing @testable import Flux@@ -8,7 +9,7 @@ struct ChartScopeRegistryTests {     @Test("Writing a scope is readable by the same kind")     func writeThenReadByKind() {         let registry = ChartScopeRegistry()-        let scope = ChartScope.historyRange(days: 7)+        let scope = ChartScope.historyRange(.days(7))          registry.current[.historySolar] = scope @@ -30,7 +31,7 @@ struct ChartScopeRegistryTests {     @Test("Different kinds are stored independently")     func differentKindsAreIndependent() {         let registry = ChartScopeRegistry()-        let historyScope = ChartScope.historyRange(days: 14)+        let historyScope = ChartScope.historyRange(.days(14))         let dayScope = ChartScope.daySpecific(date: Date(timeIntervalSince1970: 1_700_000_000))          registry.current[.historyGridUsage] = historyScope
Flux/FluxTests/Charts/ExpandableChartContainerTests.swift Modified +4 / -3
diff --git a/Flux/FluxTests/Charts/ExpandableChartContainerTests.swift b/Flux/FluxTests/Charts/ExpandableChartContainerTests.swiftindex 4cea6fb..c776cc5 100644--- a/Flux/FluxTests/Charts/ExpandableChartContainerTests.swift+++ b/Flux/FluxTests/Charts/ExpandableChartContainerTests.swift@@ -1,3 +1,4 @@+import FluxCore import Foundation import SwiftUI import Testing@@ -38,7 +39,7 @@ struct ExpandableChartContainerTests {         var currentDays = 7         let container = ExpandableChartContainer(             kind: .historySolar,-            scopeProvider: { .historyRange(days: currentDays) },+            scopeProvider: { .historyRange(.days(currentDays)) },             content: { EmptyView() }         ) @@ -48,7 +49,7 @@ struct ExpandableChartContainerTests {         currentDays = 14         container.invoke(action: action) -        #expect(captured == .historyRange(days: 14))+        #expect(captured == .historyRange(.days(14)))     }      @Test("Each ChartKind passed in is forwarded unchanged to the action")@@ -59,7 +60,7 @@ struct ExpandableChartContainerTests {              let container = ExpandableChartContainer(                 kind: kind,-                scopeProvider: { .historyRange(days: 1) },+                scopeProvider: { .historyRange(.days(1)) },                 content: { EmptyView() }             ) 
Flux/FluxTests/Charts/ExpandedChartViewTests.swift Modified +3 / -2
diff --git a/Flux/FluxTests/Charts/ExpandedChartViewTests.swift b/Flux/FluxTests/Charts/ExpandedChartViewTests.swiftindex c98e4ac..dba1d71 100644--- a/Flux/FluxTests/Charts/ExpandedChartViewTests.swift+++ b/Flux/FluxTests/Charts/ExpandedChartViewTests.swift@@ -1,3 +1,4 @@+import FluxCore import Foundation import Testing @testable import Flux@@ -37,7 +38,7 @@ struct ExpandedChartViewTests {         #expect(resolved == .daySpecific(date: Date(timeIntervalSince1970: 1_700_000_000)))     } -    @Test("Router falls back to historyRange(days: 7) for history scope when registry is empty")+    @Test("Router falls back to historyRange(.days(7)) for history scope when registry is empty")     func historyScopeFallbackUsesDefault() {         let registry = ChartScopeRegistry()         let resolved = ExpandedChartView.resolvedScope(@@ -45,7 +46,7 @@ struct ExpandedChartViewTests {             in: registry,             today: { Date(timeIntervalSince1970: 1_700_000_000) }         )-        #expect(resolved == .historyRange(days: 7))+        #expect(resolved == .historyRange(.days(7)))     }      @Test("Router reads the registered scope when present")
Flux/FluxTests/Charts/HistoryCardExpansionTests.swift Modified +30 / -21
diff --git a/Flux/FluxTests/Charts/HistoryCardExpansionTests.swift b/Flux/FluxTests/Charts/HistoryCardExpansionTests.swiftindex 09fb6f1..09121a8 100644--- a/Flux/FluxTests/Charts/HistoryCardExpansionTests.swift+++ b/Flux/FluxTests/Charts/HistoryCardExpansionTests.swift@@ -20,56 +20,65 @@ struct HistoryCardExpansionTests {         #expect(kinds.count == 3)     } -    @Test("HistorySolarCard.expansionScope tracks the current rangeDays")-    func solarExpansionScopeTracksRange() {-        for days in [7, 14, 30] {-            let card = makeSolarCard(rangeDays: days)-            #expect(card.expansionScope == .historyRange(days: days))+    @Test("HistorySolarCard.expansionScope carries the current periodQuery")+    func solarExpansionScopeTracksQuery() {+        for query in Self.sampleQueries {+            let card = makeSolarCard(periodQuery: query)+            #expect(card.expansionScope == .historyRange(query))         }     } -    @Test("HistoryGridUsageCard.expansionScope tracks the current rangeDays")-    func gridExpansionScopeTracksRange() {-        for days in [7, 14, 30] {-            let card = makeGridCard(rangeDays: days)-            #expect(card.expansionScope == .historyRange(days: days))+    @Test("HistoryGridUsageCard.expansionScope carries the current periodQuery")+    func gridExpansionScopeTracksQuery() {+        for query in Self.sampleQueries {+            let card = makeGridCard(periodQuery: query)+            #expect(card.expansionScope == .historyRange(query))         }     } -    @Test("HistoryDailyUsageCard.expansionScope tracks the current rangeDays")-    func dailyUsageExpansionScopeTracksRange() {-        for days in [7, 14, 30] {-            let card = makeDailyUsageCard(rangeDays: days)-            #expect(card.expansionScope == .historyRange(days: days))+    @Test("HistoryDailyUsageCard.expansionScope carries the current periodQuery")+    func dailyUsageExpansionScopeTracksQuery() {+        for query in Self.sampleQueries {+            let card = makeDailyUsageCard(periodQuery: query)+            #expect(card.expansionScope == .historyRange(query))         }     } -    private func makeSolarCard(rangeDays: Int) -> HistorySolarCard {+    /// Both query forms: the fixed day-count windows and a navigated past+    /// period, which the expansion scope must carry unchanged (Decision 13).+    private static let sampleQueries: [HistoryQuery] = [+        .days(7),+        .days(14),+        .days(30),+        .dateRange(start: "2026-04-06", end: "2026-04-12")+    ]++    private func makeSolarCard(periodQuery: HistoryQuery) -> HistorySolarCard {         HistorySolarCard(             entries: [],             summary: .empty,             selectedDate: nil,-            rangeDays: rangeDays,+            periodQuery: periodQuery,             onSelect: { _ in }         )     } -    private func makeGridCard(rangeDays: Int) -> HistoryGridUsageCard {+    private func makeGridCard(periodQuery: HistoryQuery) -> HistoryGridUsageCard {         HistoryGridUsageCard(             entries: [],             summary: .empty,             selectedDate: nil,-            rangeDays: rangeDays,+            periodQuery: periodQuery,             onSelect: { _ in }         )     } -    private func makeDailyUsageCard(rangeDays: Int) -> HistoryDailyUsageCard {+    private func makeDailyUsageCard(periodQuery: HistoryQuery) -> HistoryDailyUsageCard {         HistoryDailyUsageCard(             entries: [],             summary: .empty,             selectedDate: nil,-            rangeDays: rangeDays,+            periodQuery: periodQuery,             onSelect: { _ in }         )     }
Flux/FluxTests/Charts/MacOSScopedObserverTests.swift Modified +1 / -1
diff --git a/Flux/FluxTests/Charts/MacOSScopedObserverTests.swift b/Flux/FluxTests/Charts/MacOSScopedObserverTests.swiftindex 284d4d2..edf0bf9 100644--- a/Flux/FluxTests/Charts/MacOSScopedObserverTests.swift+++ b/Flux/FluxTests/Charts/MacOSScopedObserverTests.swift@@ -181,7 +181,7 @@ struct MacOSScopedObserverTests {         stub.historyResponse = HistoryResponse(days: [])         let observer = makeObserver(             kind: .historySolar,-            scope: .historyRange(days: 14),+            scope: .historyRange(.days(14)),             api: stub,             clock: { now }         )
Flux/FluxTests/Charts/iOSExpandIntegrationTests.swift Modified +4 / -3
diff --git a/Flux/FluxTests/Charts/iOSExpandIntegrationTests.swift b/Flux/FluxTests/Charts/iOSExpandIntegrationTests.swiftindex e1afa1c..554adee 100644--- a/Flux/FluxTests/Charts/iOSExpandIntegrationTests.swift+++ b/Flux/FluxTests/Charts/iOSExpandIntegrationTests.swift@@ -1,4 +1,5 @@ #if canImport(UIKit) && !os(macOS)+import FluxCore import Foundation import SwiftUI import Testing@@ -20,9 +21,9 @@ struct iOSExpandIntegrationTests {         }          let cases: [(ChartKind, ChartScope)] = [-            (.historySolar, .historyRange(days: 7)),-            (.historyGridUsage, .historyRange(days: 14)),-            (.historyDailyUsage, .historyRange(days: 30)),+            (.historySolar, .historyRange(.days(7))),+            (.historyGridUsage, .historyRange(.days(14))),+            (.historyDailyUsage, .historyRange(.days(30))),             (.dayPower, .daySpecific(date: Date(timeIntervalSince1970: 1_700_000_000))),             (.dayBatteryCombined, .daySpecific(date: Date(timeIntervalSince1970: 1_710_000_000)))         ]
Flux/FluxTests/HistoryChartDomainTests.swift Modified +8 / -8
diff --git a/Flux/FluxTests/HistoryChartDomainTests.swift b/Flux/FluxTests/HistoryChartDomainTests.swiftindex f7d1371..2c8594c 100644--- a/Flux/FluxTests/HistoryChartDomainTests.swift+++ b/Flux/FluxTests/HistoryChartDomainTests.swift@@ -18,16 +18,16 @@ struct HistoryChartDomainTests {     @Test("Fixed .days ranges reserve nothing")     func fixedRangesReturnNil() {         let now = sydneyDate(2026, 6, 10)-        #expect(HistoryChartDomain.make(range: .days(7), now: now, firstWeekday: 2) == nil)-        #expect(HistoryChartDomain.make(range: .days(14), now: now, firstWeekday: 1) == nil)-        #expect(HistoryChartDomain.make(range: .days(30), now: now, firstWeekday: 2) == nil)+        #expect(HistoryChartDomain.make(range: .days(7), referenceDate: now, firstWeekday: 2) == nil)+        #expect(HistoryChartDomain.make(range: .days(14), referenceDate: now, firstWeekday: 1) == nil)+        #expect(HistoryChartDomain.make(range: .days(30), referenceDate: now, firstWeekday: 2) == nil)     }      @Test("Week-to-date reserves a full 7-day week from the week start")     func weekToDateReservesFullWeek() throws {         // 2026-06-10 is a Wednesday; Monday-start week begins 2026-06-08.         let now = sydneyDate(2026, 6, 10)-        let domain = try #require(HistoryChartDomain.make(range: .weekToDate, now: now, firstWeekday: 2))+        let domain = try #require(HistoryChartDomain.make(range: .weekToDate, referenceDate: now, firstWeekday: 2))          let weekStart = DateFormatting.startOfWeek(now: now, firstWeekday: 2)         #expect(domain.slotDates.count == 7)@@ -43,8 +43,8 @@ struct HistoryChartDomainTests {     @Test("Week start honours the locale first weekday")     func weekStartFollowsFirstWeekday() throws {         let now = sydneyDate(2026, 6, 10) // Wednesday-        let mondayStart = try #require(HistoryChartDomain.make(range: .weekToDate, now: now, firstWeekday: 2))-        let sundayStart = try #require(HistoryChartDomain.make(range: .weekToDate, now: now, firstWeekday: 1))+        let mondayStart = try #require(HistoryChartDomain.make(range: .weekToDate, referenceDate: now, firstWeekday: 2))+        let sundayStart = try #require(HistoryChartDomain.make(range: .weekToDate, referenceDate: now, firstWeekday: 1))         // Monday-start begins 2026-06-08; Sunday-start begins 2026-06-07.         #expect(mondayStart.slotDates.first == sydneyDate(2026, 6, 8, hour: 0))         #expect(sundayStart.slotDates.first == sydneyDate(2026, 6, 7, hour: 0))@@ -56,7 +56,7 @@ struct HistoryChartDomainTests {     func monthToDateReservesFullMonth() throws {         // June has 30 days.         let now = sydneyDate(2026, 6, 9)-        let domain = try #require(HistoryChartDomain.make(range: .monthToDate, now: now, firstWeekday: 2))+        let domain = try #require(HistoryChartDomain.make(range: .monthToDate, referenceDate: now, firstWeekday: 2))          let monthStart = DateFormatting.startOfMonth(now: now)         #expect(domain.slotDates.count == 30)@@ -70,7 +70,7 @@ struct HistoryChartDomainTests {         // Sydney DST ends in early April 2026; April still has 30 calendar days,         // and calendar-day arithmetic keeps every slot at Sydney midnight.         let now = sydneyDate(2026, 4, 15)-        let domain = try #require(HistoryChartDomain.make(range: .monthToDate, now: now, firstWeekday: 2))+        let domain = try #require(HistoryChartDomain.make(range: .monthToDate, referenceDate: now, firstWeekday: 2))         #expect(domain.slotDates.count == 30)         #expect(domain.slotDates.allSatisfy { calendar.startOfDay(for: $0) == $0 })         #expect(domain.span.upperBound == sydneyDate(2026, 5, 1, hour: 0))
Flux/FluxTests/HistoryPeriodNavigationViewTests.swift Added +163 / -0
diff --git a/Flux/FluxTests/HistoryPeriodNavigationViewTests.swift b/Flux/FluxTests/HistoryPeriodNavigationViewTests.swiftnew file mode 100644index 0000000..1c2b1ae--- /dev/null+++ b/Flux/FluxTests/HistoryPeriodNavigationViewTests.swift@@ -0,0 +1,163 @@+import FluxCore+import Foundation+import SwiftData+import Testing+@testable import Flux++/// Snapshot-free view checks for period navigation: the header's visibility+/// gate and label formats, the "Current" button gate, the stats card's+/// coverage subtitle, and the empty-past-period rendering split.+@MainActor @Suite(.serialized)+struct HistoryPeriodNavigationViewTests {+    // MARK: - Header visibility gate (req 1.5)++    @Test+    func displayedPeriodIsNilForFixedRangesSoTheHeaderIsHidden() async throws {+        let viewModel = try makeViewModel()+        await viewModel.loadHistory(range: .days(7))++        #expect(viewModel.displayedPeriod == nil)+    }++    @Test+    func displayedPeriodExistsForWeekAndMonthRanges() async throws {+        let viewModel = try makeViewModel()++        await viewModel.loadHistory(range: .weekToDate)+        let week = try #require(viewModel.displayedPeriod)+        #expect(week.startDateString == "2026-04-13")++        await viewModel.selectRange(.monthToDate)+        let month = try #require(viewModel.displayedPeriod)+        #expect(month.startDateString == "2026-04-01")+    }++    // MARK: - "Current" button gate (req 2.1/2.2, Decision 8)++    @Test+    func currentButtonGateFollowsViewedPeriod() async throws {+        let viewModel = try makeViewModel()+        await viewModel.loadHistory(range: .weekToDate)++        // Current period: the button is hidden, the next chevron disabled.+        #expect(viewModel.isViewingCurrentPeriod)+        #expect(viewModel.periodAnchor == nil)++        await viewModel.navigatePrevious()+        #expect(!viewModel.isViewingCurrentPeriod)+        #expect(viewModel.periodAnchor != nil)+    }++    // MARK: - Period label formats (req 4.1)++    @Test+    func weekLabelWithinOneMonthUsesCompactForm() {+        let period = HistoryPeriod.week(containing: sydneyDate(2026, 4, 15), firstWeekday: 2)+        #expect(HistoryPeriodHeader.label(for: .weekToDate, period: period) == "Apr 13 – 19")+    }++    @Test+    func weekLabelAcrossMonthsNamesBothMonths() {+        // Monday-start week containing Thu 2026-04-30 is Apr 27 – May 3.+        let period = HistoryPeriod.week(containing: sydneyDate(2026, 4, 30), firstWeekday: 2)+        #expect(HistoryPeriodHeader.label(for: .weekToDate, period: period) == "Apr 27 – May 3")+    }++    @Test+    func monthLabelShowsMonthAndYear() {+        let period = HistoryPeriod.month(containing: sydneyDate(2026, 5, 10))+        #expect(HistoryPeriodHeader.label(for: .monthToDate, period: period) == "May 2026")+    }++    // MARK: - Stats card coverage subtitle (req 6.2, 6.3)++    @Test+    func subtitleShowsCoverageOnlyForPartialPastPeriods() {+        #expect(+            HistoryStatsOverviewCard.periodCoverageSubtitle(dayCount: 11, periodDays: 30) == "11 of 30 days"+        )+        #expect(+            HistoryStatsOverviewCard.periodCoverageSubtitle(dayCount: 30, periodDays: 30) == nil,+            "a fully recorded past period needs no indicator"+        )+        #expect(+            HistoryStatsOverviewCard.periodCoverageSubtitle(dayCount: 11, periodDays: nil) == nil,+            "the current period is unchanged (req 6.3)"+        )+    }++    // MARK: - Jump picker upper bound (req 3.2)++    @Test+    func sydneyTodayEndIsTheEndOfTheSydneyDayNotTheDeviceDay() throws {+        // 15:00 UTC on 2026-04-15 is already 01:00 AEST on 2026-04-16, so the+        // Sydney date differs from the UTC/device date. The picker bound must+        // be the last instant of the Sydney day — Apr 16, not Apr 15.+        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+        let container = try ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+        let now = Calendar(identifier: .gregorian).date(from: DateComponents(+            timeZone: TimeZone(secondsFromGMT: 0),+            year: 2026, month: 4, day: 15, hour: 15, minute: 0+        ))!+        let viewModel = HistoryViewModel(+            apiClient: EmptyHistoryAPIClient(),+            modelContext: ModelContext(container),+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )++        // One second before Sydney midnight of Apr 17 — the end of Apr 16.+        #expect(viewModel.sydneyTodayEnd == sydneyDate(2026, 4, 17).addingTimeInterval(-1))+    }++    // MARK: - Empty past period keeps the cards rendered (req 1.6)++    @Test+    func emptyPastPeriodReservesFullAxisInsteadOfEmptyState() async throws {+        let viewModel = try makeViewModel()+        await viewModel.loadHistory(range: .monthToDate)+        await viewModel.navigatePrevious()++        // The view keys the replace-everything emptyState off this flag being+        // false; here it is true, so the cards stay rendered with the+        // scaffold axis spanning the whole past month.+        #expect(viewModel.showsEmptyPeriodNotice)+        let domain = try #require(viewModel.chartDomain)+        #expect(domain.slotDates.count == 31)+        #expect(domain.slotDates.first == sydneyDate(2026, 3, 1))+    }++    // MARK: - Helpers++    /// 02:00 UTC on 2026-04-15 = 12:00 AEST Wednesday; Monday-start weeks.+    private func makeViewModel() throws -> HistoryViewModel {+        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+        let container = try ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+        let now = Calendar(identifier: .gregorian).date(from: DateComponents(+            timeZone: TimeZone(secondsFromGMT: 0),+            year: 2026, month: 4, day: 15, hour: 2, minute: 0+        ))!+        return HistoryViewModel(+            apiClient: EmptyHistoryAPIClient(),+            modelContext: ModelContext(container),+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )+    }++    private func sydneyDate(_ year: Int, _ month: Int, _ day: Int) -> Date {+        DateFormatting.sydneyCalendar.date(from: DateComponents(+            timeZone: DateFormatting.sydneyTimeZone,+            year: year, month: month, day: day+        ))!+    }+}++/// Succeeds with an empty day set for both query forms.+private final class EmptyHistoryAPIClient: FluxAPIClient, @unchecked Sendable {+    func fetchStatus() async throws -> StatusResponse { throw FluxAPIError.notConfigured }+    func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+    func fetchHistory(query _: HistoryQuery) async throws -> HistoryResponse { HistoryResponse(days: []) }+    func fetchDay(date _: String) async throws -> DayDetailResponse { throw FluxAPIError.notConfigured }+    func saveNote(date _: String, text _: String) async throws -> NoteResponse { throw FluxAPIError.notConfigured }+}
Flux/FluxTests/HistoryPeriodTests.swift Added +256 / -0
diff --git a/Flux/FluxTests/HistoryPeriodTests.swift b/Flux/FluxTests/HistoryPeriodTests.swiftnew file mode 100644index 0000000..4a0b1c0--- /dev/null+++ b/Flux/FluxTests/HistoryPeriodTests.swift@@ -0,0 +1,256 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++/// Property-based and unit tests for `HistoryPeriod`. All inputs are seeded+/// (never `Date.now`) so results are deterministic regardless of when or where+/// the suite runs — the T-1361 `DateBoundaryTests` style. Seeds span both+/// Sydney DST transitions, leap/non-leap February, and month-length edges,+/// plus deterministic pseudo-random dates from a fixed-seed generator.+@Suite struct HistoryPeriodTests {+    // MARK: - Seeded dates++    /// Hand-picked edge dates: DST transitions, month lengths, year edges.+    static let edgeSeeds: [Date] = {+        let seeds: [(Int, Int, Int, Int, Int)] = [+            (2026, 1, 1, 0, 0),     // Jan 1 (year edge)+            (2026, 2, 28, 23, 59),  // Feb 28 non-leap last day+            (2024, 2, 29, 12, 0),   // Feb 29 leap day+            (2026, 3, 31, 6, 30),   // Mar 31 (31-day month)+            (2026, 4, 5, 12, 0),    // Apr 5 DST-end day (25-hour day)+            (2026, 4, 15, 13, 37),  // mid-April+            (2026, 6, 1, 0, 1),     // Jun 1 (month start)+            (2026, 6, 30, 22, 0),   // Jun 30 (30-day month last day)+            (2026, 10, 4, 12, 0),   // Oct 4 DST-start day (23-hour day)+            (2026, 10, 31, 18, 0),  // Oct 31 (31-day month last day)+            (2026, 12, 31, 23, 0)   // Dec 31 (year edge)+        ]+        return seeds.map { year, month, day, hour, minute in+            makeSydneyDate(year: year, month: month, day: day, hour: hour, minute: minute)+        }+    }()++    /// Deterministic pseudo-random dates across 2024–2027 from a fixed-seed+    /// SplitMix64, so the property tests sweep arbitrary days without ever+    /// becoming flaky.+    static let randomSeeds: [Date] = {+        var generator = SplitMix64(seed: 0x1497)+        let epoch = makeSydneyDate(year: 2024, month: 1, day: 1, hour: 0, minute: 0)+        let spanSeconds = 4 * 365 * 24 * 3600+        return (0 ..< 40).map { _ in+            epoch.addingTimeInterval(TimeInterval(Int(generator.next() % UInt64(spanSeconds))))+        }+    }()++    static let allSeeds: [Date] = edgeSeeds + randomSeeds++    // MARK: - Round-trip invariants++    @Test(arguments: allSeeds, [1, 2, 7])+    func weekNextPreviousRoundTrips(date: Date, firstWeekday: Int) {+        let period = HistoryPeriod.week(containing: date, firstWeekday: firstWeekday)+        let forward = period.next(range: .weekToDate, firstWeekday: firstWeekday)+            .previous(range: .weekToDate, firstWeekday: firstWeekday)+        let backward = period.previous(range: .weekToDate, firstWeekday: firstWeekday)+            .next(range: .weekToDate, firstWeekday: firstWeekday)++        #expect(forward == period)+        #expect(backward == period)+    }++    @Test(arguments: allSeeds)+    func monthNextPreviousRoundTrips(date: Date) {+        let period = HistoryPeriod.month(containing: date)+        let forward = period.next(range: .monthToDate, firstWeekday: 2)+            .previous(range: .monthToDate, firstWeekday: 2)+        let backward = period.previous(range: .monthToDate, firstWeekday: 2)+            .next(range: .monthToDate, firstWeekday: 2)++        #expect(forward == period)+        #expect(backward == period)+    }++    // MARK: - Containment invariants++    @Test(arguments: allSeeds, [1, 2, 7])+    func weekContainsStartButNotEndExclusive(date: Date, firstWeekday: Int) {+        let period = HistoryPeriod.week(containing: date, firstWeekday: firstWeekday)+        #expect(period.contains(period.start))+        #expect(!period.contains(period.endExclusive))+        #expect(period.contains(date))+    }++    @Test(arguments: allSeeds)+    func monthContainsStartButNotEndExclusive(date: Date) {+        let period = HistoryPeriod.month(containing: date)+        #expect(period.contains(period.start))+        #expect(!period.contains(period.endExclusive))+        #expect(period.contains(date))+    }++    // MARK: - Period lengths++    @Test(arguments: allSeeds, [1, 2, 7])+    func weekPeriodsAlwaysSpanSevenDays(date: Date, firstWeekday: Int) {+        let period = HistoryPeriod.week(containing: date, firstWeekday: firstWeekday)+        #expect(period.dayCount == 7)+        #expect(period.previous(range: .weekToDate, firstWeekday: firstWeekday).dayCount == 7)+        #expect(period.next(range: .weekToDate, firstWeekday: firstWeekday).dayCount == 7)+    }++    @Test(arguments: allSeeds)+    func monthPeriodsSpanTwentyEightToThirtyOneDays(date: Date) {+        let period = HistoryPeriod.month(containing: date)+        #expect(period.dayCount >= 28)+        #expect(period.dayCount <= 31)+        let previous = period.previous(range: .monthToDate, firstWeekday: 2)+        #expect(previous.dayCount >= 28)+        #expect(previous.dayCount <= 31)+    }++    // MARK: - Same-period stability++    /// `week(containing: d)` must be identical for every `d` in the same week+    /// (likewise month) — the picker snaps any chosen day to one period.+    @Test(arguments: allSeeds, [1, 2, 7])+    func everyDayInAWeekResolvesTheSamePeriod(date: Date, firstWeekday: Int) {+        let period = HistoryPeriod.week(containing: date, firstWeekday: firstWeekday)+        let calendar = DateFormatting.sydneyCalendar+        for offset in 0 ..< 7 {+            guard let day = calendar.date(byAdding: .day, value: offset, to: period.start) else {+                Issue.record("date arithmetic failed for offset \(offset)")+                return+            }+            #expect(HistoryPeriod.week(containing: day, firstWeekday: firstWeekday) == period)+        }+    }++    @Test(arguments: allSeeds)+    func everyDayInAMonthResolvesTheSamePeriod(date: Date) {+        let period = HistoryPeriod.month(containing: date)+        let calendar = DateFormatting.sydneyCalendar+        for offset in 0 ..< period.dayCount {+            guard let day = calendar.date(byAdding: .day, value: offset, to: period.start) else {+                Issue.record("date arithmetic failed for offset \(offset)")+                return+            }+            #expect(HistoryPeriod.month(containing: day) == period)+        }+    }++    // MARK: - Boundaries at Sydney midnight++    @Test(arguments: allSeeds, [1, 2, 7])+    func weekBoundariesAreSydneyMidnights(date: Date, firstWeekday: Int) {+        let period = HistoryPeriod.week(containing: date, firstWeekday: firstWeekday)+        let calendar = DateFormatting.sydneyCalendar+        #expect(calendar.startOfDay(for: period.start) == period.start)+        #expect(calendar.startOfDay(for: period.endExclusive) == period.endExclusive)+        #expect(calendar.component(.weekday, from: period.start) == firstWeekday)+    }++    @Test(arguments: allSeeds)+    func monthStartsOnTheFirstAtSydneyMidnight(date: Date) {+        let period = HistoryPeriod.month(containing: date)+        let calendar = DateFormatting.sydneyCalendar+        #expect(calendar.startOfDay(for: period.start) == period.start)+        #expect(calendar.component(.day, from: period.start) == 1)+        #expect(calendar.component(.day, from: period.endExclusive) == 1)+    }++    // MARK: - Date strings and day counts (unit checks)++    @Test+    func weekDateStringsAndDayCountAreCorrect() {+        // 2026-04-15 is a Wednesday; Monday-start week is Apr 13–19.+        let date = Self.makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        let period = HistoryPeriod.week(containing: date, firstWeekday: 2)++        #expect(period.startDateString == "2026-04-13")+        #expect(period.endDateString == "2026-04-19")+        #expect(period.dayCount == 7)+    }++    @Test+    func monthDateStringsAndDayCountAreCorrect() {+        let date = Self.makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        let period = HistoryPeriod.month(containing: date)++        #expect(period.startDateString == "2026-04-01")+        #expect(period.endDateString == "2026-04-30")+        #expect(period.dayCount == 30)+    }++    @Test+    func februaryDayCountsRespectLeapYears() {+        let nonLeap = HistoryPeriod.month(+            containing: Self.makeSydneyDate(year: 2026, month: 2, day: 10, hour: 9, minute: 0)+        )+        let leap = HistoryPeriod.month(+            containing: Self.makeSydneyDate(year: 2024, month: 2, day: 10, hour: 9, minute: 0)+        )++        #expect(nonLeap.dayCount == 28)+        #expect(nonLeap.endDateString == "2026-02-28")+        #expect(leap.dayCount == 29)+        #expect(leap.endDateString == "2024-02-29")+    }++    @Test+    func previousWeekCrossesAprilDstFallbackWithoutDrift() {+        // 2026 Sydney DST ends Sun 2026-04-05. The Monday-start week containing+        // Wed 2026-04-08 is Apr 6–12; the previous week (Mar 30 – Apr 5)+        // contains the 25-hour day and must still start at Sydney midnight Monday.+        let date = Self.makeSydneyDate(year: 2026, month: 4, day: 8, hour: 12, minute: 0)+        let period = HistoryPeriod.week(containing: date, firstWeekday: 2)+        let previous = period.previous(range: .weekToDate, firstWeekday: 2)++        #expect(previous.startDateString == "2026-03-30")+        #expect(previous.endDateString == "2026-04-05")+        #expect(previous.dayCount == 7)+        #expect(previous.next(range: .weekToDate, firstWeekday: 2) == period)+    }++    @Test+    func previousMonthFromMarchIsFebruary() {+        let date = Self.makeSydneyDate(year: 2026, month: 3, day: 31, hour: 12, minute: 0)+        let period = HistoryPeriod.month(containing: date)+        let previous = period.previous(range: .monthToDate, firstWeekday: 2)++        #expect(previous.startDateString == "2026-02-01")+        #expect(previous.endDateString == "2026-02-28")+        #expect(previous.dayCount == 28)+    }++    // MARK: - Helpers++    private static func makeSydneyDate(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {+        DateFormatting.sydneyCalendar.date(from: DateComponents(+            timeZone: DateFormatting.sydneyTimeZone,+            year: year,+            month: month,+            day: day,+            hour: hour,+            minute: minute+        ))!+    }+}++/// Tiny deterministic PRNG so the "random" property-test dates are stable+/// across runs and machines.+private struct SplitMix64: RandomNumberGenerator {+    private var state: UInt64++    init(seed: UInt64) {+        state = seed+    }++    mutating func next() -> UInt64 {+        state &+= 0x9E3779B97F4A7C15+        var result = state+        result = (result ^ (result >> 30)) &* 0xBF58476D1CE4E5B9+        result = (result ^ (result >> 27)) &* 0x94D049BB133111EB+        return result ^ (result >> 31)+    }+}
Flux/FluxTests/HistoryRangeConsistencyTests.swift Modified +10 / -10
diff --git a/Flux/FluxTests/HistoryRangeConsistencyTests.swift b/Flux/FluxTests/HistoryRangeConsistencyTests.swiftindex 8f9180d..7333930 100644--- a/Flux/FluxTests/HistoryRangeConsistencyTests.swift+++ b/Flux/FluxTests/HistoryRangeConsistencyTests.swift@@ -7,7 +7,7 @@ import Testing /// Requirement 5.2 / 5.3: the producing range must not influence the cards. /// Given an identical day set, every card produces identical `DerivedState` /// and `PeriodSummary`, and the resolved day-count `N` flows unchanged into the-/// card's `ChartScope.historyRange(days:)` expansion scope.+/// card's `ChartScope.historyRange(_:)` expansion scope. @MainActor @Suite(.serialized) struct HistoryRangeConsistencyTests {     private static let sampleDays: [DayEnergy] = [@@ -69,26 +69,26 @@ struct HistoryRangeConsistencyTests {          #expect(viewModel.resolvedRangeDays == 12) -        // The cards receive `rangeDays: viewModel.resolvedRangeDays`, which-        // becomes `ChartScope.historyRange(days:)` — the resolved N, not the-        // fixed 7/14/30 ([5.3]).+        // The cards receive `periodQuery: viewModel.periodQuery`, which+        // becomes `ChartScope.historyRange(_:)` — the resolved `.days(N)`,+        // not the fixed 7/14/30 ([5.3]).         let derived = viewModel.derived         let solar = HistorySolarCard(             entries: derived.solar, summary: derived.summary,-            selectedDate: nil, rangeDays: viewModel.resolvedRangeDays, onSelect: { _ in }+            selectedDate: nil, periodQuery: viewModel.periodQuery, onSelect: { _ in }         )         let grid = HistoryGridUsageCard(             entries: derived.grid, summary: derived.summary,-            selectedDate: nil, rangeDays: viewModel.resolvedRangeDays, onSelect: { _ in }+            selectedDate: nil, periodQuery: viewModel.periodQuery, onSelect: { _ in }         )         let usage = HistoryDailyUsageCard(             entries: derived.dailyUsage, summary: derived.summary,-            selectedDate: nil, rangeDays: viewModel.resolvedRangeDays, onSelect: { _ in }+            selectedDate: nil, periodQuery: viewModel.periodQuery, onSelect: { _ in }         ) -        #expect(solar.expansionScope == .historyRange(days: 12))-        #expect(grid.expansionScope == .historyRange(days: 12))-        #expect(usage.expansionScope == .historyRange(days: 12))+        #expect(solar.expansionScope == .historyRange(.days(12)))+        #expect(grid.expansionScope == .historyRange(.days(12)))+        #expect(usage.expansionScope == .historyRange(.days(12)))     }      // MARK: - Helpers
Flux/FluxTests/HistoryViewModelNavigationTests.swift Added +469 / -0
diff --git a/Flux/FluxTests/HistoryViewModelNavigationTests.swift b/Flux/FluxTests/HistoryViewModelNavigationTests.swiftnew file mode 100644index 0000000..3b87b52--- /dev/null+++ b/Flux/FluxTests/HistoryViewModelNavigationTests.swift@@ -0,0 +1,469 @@+import FluxCore+import Foundation+import SwiftData+import Testing+@testable import Flux++/// Period-navigation behaviour: intent methods drive the expected+/// `HistoryQuery`, the anchor is only mutated by intents (req 1.8), the+/// resolved snapshot reflects rendered data, and the offline cache fallback is+/// bounded by both period ends (req 7.2).+///+/// Clock for every test: 02:00 UTC on 2026-04-15 = 12:00 AEST Wednesday.+/// With Monday-start weeks (firstWeekday 2): current week Apr 13–19 (to-date+/// count 3), previous week Apr 6–12; current month Apr 1–30 (to-date 15),+/// previous month Mar 1–31.+@MainActor @Suite(.serialized)+struct HistoryViewModelNavigationTests {+    private static let previousWeekQuery = HistoryQuery.dateRange(start: "2026-04-06", end: "2026-04-12")++    // MARK: - Intent methods issue the expected query++    @Test+    func navigatePreviousRequestsThePreviousCalendarWeek() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()++        #expect(apiClient.requestedQueries == [.days(3), Self.previousWeekQuery])+        #expect(viewModel.periodAnchor == sydneyMidnight(year: 2026, month: 4, day: 6))+        #expect(viewModel.resolvedQuery == Self.previousWeekQuery)+        #expect(viewModel.resolvedRangeDays == 7)+    }++    @Test+    func navigatePreviousRequestsThePreviousCalendarMonth() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .monthToDate)+        await viewModel.navigatePrevious()++        let march = HistoryQuery.dateRange(start: "2026-03-01", end: "2026-03-31")+        #expect(apiClient.requestedQueries == [.days(15), march])+        #expect(viewModel.resolvedRangeDays == 31)+    }++    @Test+    func navigateNextFromPreviousWeekCollapsesToCurrent() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()+        await viewModel.navigateNext()++        // The period containing Sydney-today is requested via the days form,+        // never as a date range (Decision 15).+        #expect(apiClient.requestedQueries.last == .days(3))+        #expect(viewModel.periodAnchor == nil)+        #expect(viewModel.isViewingCurrentPeriod)+    }++    @Test+    func navigateNextAtCurrentPeriodIsANoOp() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigateNext()++        // No future period exists after the current one: no request, no+        // anchor movement.+        #expect(apiClient.requestedQueries == [.days(3)])+        #expect(viewModel.periodAnchor == nil)+        #expect(viewModel.isViewingCurrentPeriod)+    }++    @Test+    func rapidDoubleNavigateNextStopsAtTheCurrentPeriod() async throws {+        let context = try makeModelContext()+        let apiClient = GatedQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()++        // First navigateNext collapses to current; park its fetch in-flight.+        apiClient.armGate()+        let firstNext = Task { await viewModel.navigateNext() }+        await apiClient.waitForGatedFetch()++        // A second navigateNext lands mid-load (rapid double-tap or macOS+        // key-repeat). The anchor is already nil, so it must be a no-op —+        // not a step into the future week the server would reject.+        await viewModel.navigateNext()++        apiClient.release()+        await firstNext.value++        #expect(apiClient.requestedQueries == [.days(3), Self.previousWeekQuery, .days(3)])+        #expect(viewModel.periodAnchor == nil)+        #expect(viewModel.isViewingCurrentPeriod)+    }++    @Test+    func jumpToPastDateRequestsTheContainingPeriod() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .monthToDate)+        await viewModel.jumpTo(date: sydneyMidnight(year: 2026, month: 3, day: 10))++        #expect(apiClient.requestedQueries.last == .dateRange(start: "2026-03-01", end: "2026-03-31"))+        #expect(viewModel.periodAnchor == sydneyMidnight(year: 2026, month: 3, day: 1))+    }++    @Test+    func jumpToDateInCurrentMonthGivesNilAnchor() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .monthToDate)+        await viewModel.jumpTo(date: sydneyMidnight(year: 2026, month: 4, day: 2))++        // Req 3.3: a date inside the current period shows the to-date view.+        #expect(viewModel.periodAnchor == nil)+        #expect(apiClient.requestedQueries.last == .days(15))+    }++    @Test+    func jumpToDateInsideTheDisplayedPeriodIssuesNoRequest() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()++        // The picker lands on another date inside the already-displayed past+        // week: nothing would change, so no request is issued and the anchor+        // stays where it is.+        await viewModel.jumpTo(date: sydneyMidnight(year: 2026, month: 4, day: 9))+        #expect(apiClient.requestedQueries == [.days(3), Self.previousWeekQuery])+        #expect(viewModel.periodAnchor == sydneyMidnight(year: 2026, month: 4, day: 6))++        // Same for re-selecting a date inside the displayed current period.+        await viewModel.returnToCurrent()+        await viewModel.jumpTo(date: sydneyMidnight(year: 2026, month: 4, day: 14))+        #expect(apiClient.requestedQueries == [.days(3), Self.previousWeekQuery, .days(3)])+        #expect(viewModel.periodAnchor == nil)+    }++    @Test+    func returnToCurrentClearsAnchorAndRequestsDaysForm() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()+        await viewModel.returnToCurrent()++        #expect(viewModel.periodAnchor == nil)+        #expect(apiClient.requestedQueries.last == .days(3))+    }++    // MARK: - Anchor mutation rules (req 1.8, 2.3)++    @Test+    func reloadAndLoadHistoryKeepTheAnchor() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()++        await viewModel.reload()+        #expect(viewModel.periodAnchor == sydneyMidnight(year: 2026, month: 4, day: 6))+        #expect(apiClient.requestedQueries.last == Self.previousWeekQuery)++        await viewModel.loadHistory(range: .weekToDate)+        #expect(viewModel.periodAnchor == sydneyMidnight(year: 2026, month: 4, day: 6))+        #expect(apiClient.requestedQueries.last == Self.previousWeekQuery)+    }++    @Test+    func selectRangeResetsTheAnchor() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        await viewModel.navigatePrevious()+        await viewModel.selectRange(.monthToDate)++        #expect(viewModel.periodAnchor == nil)+        #expect(apiClient.requestedQueries.last == .days(15))+    }++    // MARK: - Coalescing on the (range, anchor) pair++    @Test+    func navigationIssuedMidLoadIsHonoured() async throws {+        let context = try makeModelContext()+        let apiClient = GatedQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        // First load parks inside fetchHistory(query:).+        apiClient.armGate()+        let firstLoad = Task { await viewModel.loadHistory(range: .weekToDate) }+        await apiClient.waitForGatedFetch()++        // A navigation arrives during the in-flight load. It returns early at+        // the isLoading guard but records the requested (range, anchor) pair.+        await viewModel.navigatePrevious()++        apiClient.release()+        await firstLoad.value++        #expect(apiClient.requestedQueries == [.days(3), Self.previousWeekQuery])+        #expect(viewModel.resolvedQuery == Self.previousWeekQuery)+    }++    // MARK: - Resolved snapshot reflects rendered data++    @Test+    func resolvedSnapshotIgnoresInFlightNavigation() async throws {+        let context = try makeModelContext()+        let apiClient = GatedQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)+        #expect(viewModel.resolvedQuery == .days(3))++        // Park the navigation's fetch: the rendered data is still the current+        // week, so the resolved snapshot must not move yet.+        apiClient.armGate()+        let navigation = Task { await viewModel.navigatePrevious() }+        await apiClient.waitForGatedFetch()++        #expect(viewModel.resolvedQuery == .days(3))+        #expect(viewModel.isViewingCurrentPeriod)+        let inFlightDomain = try #require(viewModel.chartDomain)+        #expect(inFlightDomain.slotDates.first == sydneyMidnight(year: 2026, month: 4, day: 13))++        apiClient.release()+        await navigation.value++        #expect(viewModel.resolvedQuery == Self.previousWeekQuery)+        #expect(!viewModel.isViewingCurrentPeriod)+        let renderedDomain = try #require(viewModel.chartDomain)+        #expect(renderedDomain.slotDates.first == sydneyMidnight(year: 2026, month: 4, day: 6))+        #expect(renderedDomain.slotDates.count == 7)+    }++    // MARK: - Partial past period averages (req 6.1)++    @Test+    func pastPeriodAveragesDivideByRecordedDaysNotPeriodLength() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)+        await viewModel.loadHistory(range: .monthToDate)++        // March 2026 spans 31 days but only 11 of them have recorded data.+        let recordedDays = (1 ... 11).map { day in+            DayEnergy(+                date: String(format: "2026-03-%02d", day),+                epv: 4.0, eInput: 1.0, eOutput: 0.5, eCharge: 2.0, eDischarge: 3.0+            )+        }+        apiClient.historyResult = .success(HistoryResponse(days: recordedDays))+        await viewModel.navigatePrevious()++        // Req 6.1: per-day averages divide by the 11 recorded days — the+        // 31-day period length only feeds the "N of M days" subtitle.+        let summary = viewModel.derived.summary+        #expect(viewModel.resolvedRangeDays == 31)+        #expect(summary.dayCount == 11)+        #expect(summary.solarPerDayKwh == 4.0)+        #expect(summary.dischargePerDayKwh == 3.0)+    }++    // MARK: - Cache fallback bounded both ends (req 7.2)++    @Test+    func offlineFallbackForPastPeriodIsBoundedByBothEnds() async throws {+        let context = try makeModelContext()+        // Rows before the period start, inside the period, and after the+        // period end (inside the current week). Only the inside rows may show.+        for date in ["2026-04-04", "2026-04-08", "2026-04-12", "2026-04-14"] {+            context.insert(CachedDayEnergy(from: DayEnergy(+                date: date, epv: 5.0, eInput: 1.0, eOutput: 0.3, eCharge: 1.5, eDischarge: 2.0+            )))+        }+        try context.save()++        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)+        await viewModel.loadHistory(range: .weekToDate)++        apiClient.historyResult = .failure(FluxAPIError.networkError("offline"))+        await viewModel.navigatePrevious()++        #expect(viewModel.days.map(\.date) == ["2026-04-08", "2026-04-12"])+        #expect(viewModel.error == nil)+    }++    // MARK: - Error vs no-data states (req 1.6, 7.3)++    @Test+    func failedPastFetchWithoutCacheShowsErrorState() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)+        await viewModel.loadHistory(range: .weekToDate)++        apiClient.historyResult = .failure(FluxAPIError.serverError)+        await viewModel.navigatePrevious()++        #expect(viewModel.days.isEmpty)+        #expect(viewModel.error == .serverError)+        #expect(!viewModel.showsEmptyPeriodNotice, "error and no-data states are distinct")+    }++    @Test+    func successfulEmptyPastFetchShowsNoDataState() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)+        await viewModel.loadHistory(range: .weekToDate)++        await viewModel.navigatePrevious()++        #expect(viewModel.days.isEmpty)+        #expect(viewModel.error == nil)+        #expect(viewModel.showsEmptyPeriodNotice)+        // Req 1.6: the full-period axis is still reserved for the empty period.+        let domain = try #require(viewModel.chartDomain)+        #expect(domain.slotDates.count == 7)+        #expect(domain.slotDates.first == sydneyMidnight(year: 2026, month: 4, day: 6))+    }++    @Test+    func currentPeriodEmptyFetchDoesNotShowPeriodNotice() async throws {+        let context = try makeModelContext()+        let apiClient = RecordingQueryAPIClient()+        let viewModel = makeViewModel(apiClient: apiClient, modelContext: context)++        await viewModel.loadHistory(range: .weekToDate)++        #expect(viewModel.days.isEmpty)+        #expect(!viewModel.showsEmptyPeriodNotice, "the current period keeps the existing empty state")+    }++    // MARK: - Helpers++    /// 02:00 UTC on 2026-04-15 = 12:00 AEST Wednesday.+    private var now: Date {+        Calendar(identifier: .gregorian).date(from: DateComponents(+            timeZone: TimeZone(secondsFromGMT: 0),+            year: 2026, month: 4, day: 15, hour: 2, minute: 0+        ))!+    }++    private func makeViewModel(apiClient: any FluxAPIClient, modelContext: ModelContext) -> HistoryViewModel {+        let now = self.now+        return HistoryViewModel(+            apiClient: apiClient,+            modelContext: modelContext,+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )+    }++    private func makeModelContext() throws -> ModelContext {+        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+        let container = try ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+        return ModelContext(container)+    }++    private func sydneyMidnight(year: Int, month: Int, day: Int) -> Date {+        DateFormatting.sydneyCalendar.date(from: DateComponents(+            timeZone: DateFormatting.sydneyTimeZone,+            year: year, month: month, day: day+        ))!+    }+}++/// Records every `HistoryQuery` the view model issues; the result is settable+/// per test so navigation can succeed, fail, or return an empty period.+@MainActor+private final class RecordingQueryAPIClient: FluxAPIClient {+    var historyResult: Result<HistoryResponse, Error> = .success(HistoryResponse(days: []))+    private(set) var requestedQueries: [HistoryQuery] = []++    func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse {+        requestedQueries.append(query)+        return try historyResult.get()+    }++    nonisolated func fetchStatus() async throws -> StatusResponse { throw FluxAPIError.notConfigured }+    nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { throw FluxAPIError.notConfigured }+    nonisolated func fetchDay(date _: String) async throws -> DayDetailResponse { throw FluxAPIError.notConfigured }+    nonisolated func saveNote(date _: String, text _: String) async throws -> NoteResponse {+        throw FluxAPIError.notConfigured+    }+}++/// Parks the next `fetchHistory(query:)` after `armGate()` until released, so+/// tests can interleave navigation with an in-flight load.+@MainActor+private final class GatedQueryAPIClient: FluxAPIClient {+    var historyResult: Result<HistoryResponse, Error> = .success(HistoryResponse(days: []))+    private(set) var requestedQueries: [HistoryQuery] = []++    private var gateArmed = false+    private var didStartGatedFetch = false+    private var didRelease = false+    private var started: CheckedContinuation<Void, Never>?+    private var parked: CheckedContinuation<Void, Never>?++    func armGate() {+        gateArmed = true+        didStartGatedFetch = false+        didRelease = false+    }++    func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse {+        requestedQueries.append(query)+        if gateArmed {+            gateArmed = false+            didStartGatedFetch = true+            started?.resume()+            started = nil+            if !didRelease {+                await withCheckedContinuation { parked = $0 }+            }+        }+        return try historyResult.get()+    }++    func waitForGatedFetch() async {+        if didStartGatedFetch { return }+        await withCheckedContinuation { started = $0 }+    }++    func release() {+        didRelease = true+        parked?.resume()+        parked = nil+    }++    nonisolated func fetchStatus() async throws -> StatusResponse { throw FluxAPIError.notConfigured }+    nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { throw FluxAPIError.notConfigured }+    nonisolated func fetchDay(date _: String) async throws -> DayDetailResponse { throw FluxAPIError.notConfigured }+    nonisolated func saveNote(date _: String, text _: String) async throws -> NoteResponse {+        throw FluxAPIError.notConfigured+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift Modified +19 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swiftindex 9d60041..98bd0f7 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift@@ -4,6 +4,12 @@ public protocol FluxAPIClient: Sendable {     func fetchDay(date: String) async throws -> DayDetailResponse     func saveNote(date: String, text: String) async throws -> NoteResponse +    // History period navigation — history-period-navigation spec. Carries+    // either the existing day-count form or an explicit past date range in a+    // single value, so the view model, API client, and chart-expansion scope+    // all fetch the same window.+    func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse+     // Dashboard simulation — dashboard-simulation spec. A SEPARATE method from     // `fetchStatus()` so the widget timeline and settings-validation call     // sites stay param-free and can never simulate. Only DashboardViewModel@@ -93,6 +99,19 @@ public extension FluxAPIClient {         throw FluxAPIError.notConfigured     } +    // Default so existing conformers (~30 test mocks) need no change — the+    // fetchStatus(simulateLoadWatts:) evolution pattern. `.days` delegates to+    // the required `fetchHistory(days:)`; `.dateRange` throws, so only mocks+    // that exercise past-period navigation implement the new method.+    func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse {+        switch query {+        case let .days(days):+            return try await fetchHistory(days: days)+        case .dateRange:+            throw FluxAPIError.notConfigured+        }+    }+     func createPreset(_: SimulationPresetDraft) async throws -> SimulationPreset {         throw FluxAPIError.notConfigured     }
Flux/Packages/FluxCore/Sources/FluxCore/Networking/HistoryQuery.swift Added +28 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/HistoryQuery.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/HistoryQuery.swiftnew file mode 100644index 0000000..a629821--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/HistoryQuery.swift@@ -0,0 +1,28 @@+/// The one value that travels from view model to API client to chart-expansion+/// scope, so every consumer fetches the same history window (the data-consistency+/// rule). `Codable` is required, not optional: `ChartScope` carries this type+/// and is encoded for scene/expansion restoration.+public enum HistoryQuery: Hashable, Sendable, Codable {+    /// Existing anchored-to-today form: an inclusive day-count window ending on+    /// the server's Sydney today (may include a live today row).+    case days(Int)+    /// Explicit past-only window with inclusive `YYYY-MM-DD` bounds. The server+    /// rejects ranges that are not strictly before Sydney today (Decision 15).+    case dateRange(start: String, end: String)++    /// Inclusive day-count of the window: the `N` of `.days(N)`, or the+    /// Sydney-calendar span of a `.dateRange`. `nil` when a `.dateRange`+    /// bound fails to parse.+    public var dayCount: Int? {+        switch self {+        case let .days(days):+            return days+        case let .dateRange(start, end):+            guard let startDate = DateFormatting.parseDayDate(start),+                  let endDate = DateFormatting.parseDayDate(end) else {+                return nil+            }+            return DateFormatting.inclusiveDayCount(from: startDate, through: endDate)+        }+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+History.swift Added +23 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+History.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+History.swiftnew file mode 100644index 0000000..2a92d90--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+History.swift@@ -0,0 +1,23 @@+import Foundation++// MARK: - History period navigation (history-period-navigation spec)++extension URLSessionAPIClient {+    /// Real encoding for both query forms: `.days` reuses the existing+    /// `?days=N` request; `.dateRange` sends the inclusive past-only+    /// `?start=YYYY-MM-DD&end=YYYY-MM-DD` form.+    public func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse {+        switch query {+        case let .days(days):+            return try await fetchHistory(days: days)+        case let .dateRange(start, end):+            return try await performRequest(+                path: "history",+                queryItems: [+                    URLQueryItem(name: "start", value: start),+                    URLQueryItem(name: "end", value: end)+                ]+            )+        }+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/HistoryQueryTests.swift Added +211 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/HistoryQueryTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/HistoryQueryTests.swiftnew file mode 100644index 0000000..b046b7e--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/HistoryQueryTests.swift@@ -0,0 +1,211 @@+import Foundation+import Testing+@testable import FluxCore++// MARK: - HistoryQuery value semantics++@Suite struct HistoryQueryTests {+    /// Codable is required, not optional: `ChartScope` (which carries this+    /// type) is encoded for scene/expansion restoration.+    @Test+    func daysCaseRoundTripsThroughJSON() throws {+        let query = HistoryQuery.days(14)+        let data = try JSONEncoder().encode(query)+        let decoded = try JSONDecoder().decode(HistoryQuery.self, from: data)+        #expect(decoded == query)+    }++    @Test+    func dateRangeCaseRoundTripsThroughJSON() throws {+        let query = HistoryQuery.dateRange(start: "2026-05-01", end: "2026-05-31")+        let data = try JSONEncoder().encode(query)+        let decoded = try JSONDecoder().decode(HistoryQuery.self, from: data)+        #expect(decoded == query)+    }++    @Test+    func hashableDistinguishesCasesAndPayloads() {+        let queries: Set<HistoryQuery> = [+            .days(7),+            .days(14),+            .dateRange(start: "2026-05-01", end: "2026-05-31"),+            .dateRange(start: "2026-05-01", end: "2026-05-07")+        ]+        #expect(queries.count == 4)+    }+}++// MARK: - URLSessionAPIClient encoding++@MainActor @Suite(.serialized)+struct URLSessionAPIClientHistoryQueryTests {+    @Test+    func daysQueryEncodesAsDaysParameter() async throws {+        let session = makeSession()+        HistoryQueryMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 200,+                httpVersion: nil,+                headerFields: nil+            )!+            return (response, Data(#"{"days": []}"#.utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "token", session: session)+        _ = try await client.fetchHistory(query: .days(14))++        let request = try #require(HistoryQueryMockURLProtocol.lastRequest)+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/history")+        #expect(components.queryItems?.contains(URLQueryItem(name: "days", value: "14")) == true)+        #expect(components.queryItems?.contains(where: { $0.name == "start" }) == false)+        #expect(components.queryItems?.contains(where: { $0.name == "end" }) == false)+    }++    @Test+    func dateRangeQueryEncodesAsStartAndEndParameters() async throws {+        let session = makeSession()+        HistoryQueryMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 200,+                httpVersion: nil,+                headerFields: nil+            )!+            return (response, Data(#"{"days": []}"#.utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "token", session: session)+        _ = try await client.fetchHistory(query: .dateRange(start: "2026-05-01", end: "2026-05-31"))++        let request = try #require(HistoryQueryMockURLProtocol.lastRequest)+        let requestURL = try #require(request.url)+        let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+        #expect(components.path == "/history")+        #expect(components.queryItems?.contains(URLQueryItem(name: "start", value: "2026-05-01")) == true)+        #expect(components.queryItems?.contains(URLQueryItem(name: "end", value: "2026-05-31")) == true)+        #expect(components.queryItems?.contains(where: { $0.name == "days" }) == false)+    }++    private func makeSession() -> URLSession {+        let configuration = URLSessionConfiguration.ephemeral+        configuration.protocolClasses = [HistoryQueryMockURLProtocol.self]+        return URLSession(configuration: configuration)+    }+}++// MARK: - Protocol-extension default++/// The default keeps the ~30 existing mocks compiling: `.days` delegates to+/// the required `fetchHistory(days:)`; `.dateRange` throws `notConfigured`+/// (the `fetchStatus(simulateLoadWatts:)` evolution pattern).+@Suite struct FluxAPIClientHistoryQueryDefaultTests {+    @Test+    func defaultDelegatesDaysCaseToRequiredMethod() async throws {+        let client = MinimalHistoryClient()+        let response = try await client.fetchHistory(query: .days(9))++        let requested = await client.requestedDays+        #expect(requested == [9])+        #expect(response.days.isEmpty)+    }++    @Test+    func defaultThrowsNotConfiguredForDateRange() async throws {+        let client = MinimalHistoryClient()+        do {+            _ = try await client.fetchHistory(query: .dateRange(start: "2026-05-01", end: "2026-05-31"))+            Issue.record("Expected notConfigured")+        } catch let error as FluxAPIError {+            #expect(error == .notConfigured)+        }+        let requested = await client.requestedDays+        #expect(requested.isEmpty)+    }+}++/// Implements only the protocol's required methods, so `fetchHistory(query:)`+/// resolves to the protocol-extension default under test.+private actor MinimalHistoryClient: FluxAPIClient {+    private(set) var requestedDays: [Int] = []++    func fetchStatus() async throws -> StatusResponse {+        throw FluxAPIError.notConfigured+    }++    func fetchHistory(days: Int) async throws -> HistoryResponse {+        requestedDays.append(days)+        return HistoryResponse(days: [])+    }++    func fetchDay(date _: String) async throws -> DayDetailResponse {+        throw FluxAPIError.notConfigured+    }++    func saveNote(date _: String, text _: String) async throws -> NoteResponse {+        throw FluxAPIError.notConfigured+    }+}++private final class HistoryQueryMockURLProtocol: URLProtocol {+    private static let lock = NSLock()+    private static var _requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?+    private static var _lastRequest: URLRequest?++    static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? {+        get {+            lock.lock()+            defer { lock.unlock() }+            return _requestHandler+        }+        set {+            lock.lock()+            _requestHandler = newValue+            _lastRequest = nil+            lock.unlock()+        }+    }++    static var lastRequest: URLRequest? {+        get {+            lock.lock()+            defer { lock.unlock() }+            return _lastRequest+        }+        set {+            lock.lock()+            _lastRequest = newValue+            lock.unlock()+        }+    }++    // swiftlint:disable:next static_over_final_class+    override class func canInit(with request: URLRequest) -> Bool {+        true+    }++    // swiftlint:disable:next static_over_final_class+    override class func canonicalRequest(for request: URLRequest) -> URLRequest {+        request+    }++    override func startLoading() {+        guard let handler = Self.requestHandler else {+            client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))+            return+        }+        Self.lastRequest = request+        do {+            let (response, data) = try handler(request)+            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)+            client?.urlProtocol(self, didLoad: data)+            client?.urlProtocolDidFinishLoading(self)+        } catch {+            client?.urlProtocol(self, didFailWithError: error)+        }+    }++    override func stopLoading() {}+}
docs/flux-v1.md Modified +3 / -3
diff --git a/docs/flux-v1.md b/docs/flux-v1.mdindex 650b57b..4e7fa6f 100644--- a/docs/flux-v1.md+++ b/docs/flux-v1.md@@ -199,9 +199,9 @@ Returns: } ``` -**`GET /history?days=7`**+**`GET /history?days=7`** (or `?start=2026-03-01&end=2026-03-31`) -Returns daily energy summaries for the bar chart:+Returns daily energy summaries for the bar chart. The `days=N` form (1–31) covers the last N days ending Sydney today; the `start`/`end` form serves a past period from stored values only (inclusive `YYYY-MM-DD` bounds, `end` strictly before Sydney today, max 31 days):  ```json {@@ -326,7 +326,7 @@ Accessed via navigation from Dashboard. Shows daily energy totals.  ### Display -Grouped vertical bar chart showing the last 7 days (default), with a segmented control offering 7d / 14d / 30d plus two calendar-anchored to-date ranges: Wk (week to date) and Mo (month to date). The to-date ranges resolve to an inclusive day-count (1–31) against the Sydney calendar, with the week's first weekday taken from the device locale, and load over the same `/history?days=N` contract.+Grouped vertical bar chart showing the last 7 days (default), with a segmented control offering 7d / 14d / 30d plus two calendar-anchored to-date ranges: Wk (week to date) and Mo (month to date). The to-date ranges resolve to an inclusive day-count (1–31) against the Sydney calendar, with the week's first weekday taken from the device locale, and load over the same `/history?days=N` contract. The Wk and Mo ranges also allow navigating to past weeks and months, which load via the `/history` `start`/`end` date-range form (stored values only); the current period always uses `days=N`.  Each day shows 5 side-by-side bars: 
internal/api/cross_handler_test.go Modified +84 / -0
diff --git a/internal/api/cross_handler_test.go b/internal/api/cross_handler_test.goindex 3351d46..243ebe5 100644--- a/internal/api/cross_handler_test.go+++ b/internal/api/cross_handler_test.go@@ -94,3 +94,87 @@ func TestCrossHandlerEquivalence_PastDateDerivedStats(t *testing.T) { 	require.NotNil(t, hday.SocLowTime) 	assert.Equal(t, *dr.Summary.SocLowTime, *hday.SocLowTime) }++// TestCrossHandlerEquivalence_OldDateSummaryFields covers req 5.7 for the+// fields the derivedStats test above does not assert: energy totals, peak+// grid import, and the off-peak split. The date is more than 30 days before+// fixedNow, so its flux-readings rows would be TTL-expired — both handlers+// must serve every value from the same non-TTL flux-daily-energy and+// flux-offpeak rows, never recomputing from readings. /history is queried via+// the new range form, the path the History screen uses for past periods.+func TestCrossHandlerEquivalence_OldDateSummaryFields(t *testing.T) {+	// fixedNow is 2026-04-15; 2026-03-01 is 45 days earlier — readings gone.+	const date = "2026-03-01"++	peak := 2.75+	row := makePastDateRow(date)+	row.PeakGridImportKwh = &peak++	opRow := dynamo.OffpeakItem{+		SysSn: testSerial, Date: date, Status: dynamo.OffpeakStatusComplete,+		GridUsageKwh: 3.1, GridExportKwh: 0.9,+	}++	mr := &mockReader{+		// /day path+		getDailyEnergyFn: func(_ context.Context, _, _ string) (*dynamo.DailyEnergyItem, error) {+			return row, nil+		},+		getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+			return &opRow, nil+		},+		// /history path+		queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+			return []dynamo.DailyEnergyItem{*row}, nil+		},+		queryOffpeakFn: func(_ context.Context, _, _, _ string) ([]dynamo.OffpeakItem, error) {+			return []dynamo.OffpeakItem{opRow}, nil+		},+	}++	now := fixedNow()+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	dayResp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)+	require.Equal(t, 200, dayResp.StatusCode)+	dr := parseDayResponse(t, dayResp)++	historyResp, err := h.Handle(context.Background(), historyRequest(map[string]string{+		"start": date, "end": date,+	}))+	require.NoError(t, err)+	require.Equal(t, 200, historyResp.StatusCode)+	hr := parseHistoryResponse(t, historyResp)++	require.Len(t, hr.Days, 1)+	hday := hr.Days[0]+	require.Equal(t, date, hday.Date)++	// Energy totals — /day publishes them as Summary pointers, /history flat.+	require.NotNil(t, dr.Summary)+	require.NotNil(t, dr.Summary.Epv)+	assert.InDelta(t, *dr.Summary.Epv, hday.Epv, 1e-9)+	require.NotNil(t, dr.Summary.EInput)+	assert.InDelta(t, *dr.Summary.EInput, hday.EInput, 1e-9)+	require.NotNil(t, dr.Summary.EOutput)+	assert.InDelta(t, *dr.Summary.EOutput, hday.EOutput, 1e-9)+	require.NotNil(t, dr.Summary.ECharge)+	assert.InDelta(t, *dr.Summary.ECharge, hday.ECharge, 1e-9)+	require.NotNil(t, dr.Summary.EDischarge)+	assert.InDelta(t, *dr.Summary.EDischarge, hday.EDischarge, 1e-9)++	// Peak grid import — stored server-computed value on both paths.+	require.NotNil(t, dr.Summary.PeakGridImportKwh)+	require.NotNil(t, hday.PeakGridImportKwh)+	assert.InDelta(t, *dr.Summary.PeakGridImportKwh, *hday.PeakGridImportKwh, 1e-9)++	// Off-peak split — final deltas from the same complete flux-offpeak row.+	require.NotNil(t, dr.Summary.OffpeakGridImportKwh)+	require.NotNil(t, hday.OffpeakGridImportKwh)+	assert.InDelta(t, *dr.Summary.OffpeakGridImportKwh, *hday.OffpeakGridImportKwh, 1e-9)+	require.NotNil(t, dr.Summary.OffpeakGridExportKwh)+	require.NotNil(t, hday.OffpeakGridExportKwh)+	assert.InDelta(t, *dr.Summary.OffpeakGridExportKwh, *hday.OffpeakGridExportKwh, 1e-9)+}
internal/api/history.go Modified +74 / -21
diff --git a/internal/api/history.go b/internal/api/history.goindex f4803b4..336b637 100644--- a/internal/api/history.go+++ b/internal/api/history.go@@ -16,19 +16,66 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 	now := h.nowFunc().In(sydneyTZ) 	today := now.Format("2006-01-02") -	// Parse and validate days parameter (default 7). To-date ranges resolve-	// to any inclusive day-count from 1 through 31, so accept that whole-	// range; a non-numeric value still 400s via the err check.-	days := 7-	if d := req.QueryStringParameters["days"]; d != "" {-		parsed, err := strconv.Atoi(d)-		if err != nil || parsed < 1 || parsed > 31 {-			return errorResponse(400, "invalid days parameter, must be between 1 and 31")-		}-		days = parsed-	}+	// The endpoint accepts two mutually exclusive request forms (Decision 10/16):+	//   days=N      — inclusive window ending on the server's today; may+	//                 include live-computed values for today (unchanged).+	//   start/end   — explicit inclusive past range; stored values only.+	// Supplying both forms in one request is rejected rather than resolved by+	// precedence, so malformed client requests cannot be silently masked.+	daysParam := req.QueryStringParameters["days"]+	startParam := req.QueryStringParameters["start"]+	endParam := req.QueryStringParameters["end"] -	startDate := now.AddDate(0, 0, -(days - 1)).Format("2006-01-02")+	var startDate, endDate string+	includesToday := true++	switch {+	case daysParam != "" && (startParam != "" || endParam != ""):+		return errorResponse(400, "cannot combine days with start and end parameters")+	case (startParam == "") != (endParam == ""):+		return errorResponse(400, "start and end must be supplied together")+	case startParam != "":+		// Explicit range form. Past-only per Decision 15: end must be strictly+		// before the current Sydney date, so this path never performs live+		// compute and never includes a today row (req 5.3).+		start, err := time.ParseInLocation("2006-01-02", startParam, sydneyTZ)+		if err != nil {+			return errorResponse(400, "invalid start or end parameter, must be YYYY-MM-DD")+		}+		end, err := time.ParseInLocation("2006-01-02", endParam, sydneyTZ)+		if err != nil {+			return errorResponse(400, "invalid start or end parameter, must be YYYY-MM-DD")+		}+		if end.Before(start) {+			return errorResponse(400, "end must not be before start")+		}+		// String compare is safe: ParseInLocation guarantees zero-padded+		// canonical YYYY-MM-DD on both sides.+		if endParam >= today {+			return errorResponse(400, "end must be before the current date")+		}+		// Inclusive span cap of 31 days. AddDate is calendar-aware, so a DST+		// transition inside the window cannot produce an off-by-one.+		if end.After(start.AddDate(0, 0, 30)) {+			return errorResponse(400, "date range must not exceed 31 days")+		}+		startDate, endDate = startParam, endParam+		includesToday = false+	default:+		// Day-count form (default 7). To-date ranges resolve to any inclusive+		// day-count from 1 through 31, so accept that whole range; a+		// non-numeric value still 400s via the err check.+		days := 7+		if daysParam != "" {+			parsed, err := strconv.Atoi(daysParam)+			if err != nil || parsed < 1 || parsed > 31 {+				return errorResponse(400, "invalid days parameter, must be between 1 and 31")+			}+			days = parsed+		}+		startDate = now.AddDate(0, 0, -(days - 1)).Format("2006-01-02")+		endDate = today+	}  	// Fetch daily energy rows and per-day off-peak rows concurrently. The 	// today readings query (used by both energy reconciliation and live@@ -42,7 +89,7 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR  	g, gctx := errgroup.WithContext(ctx) 	g.Go(func() error {-		result, err := h.reader.QueryDailyEnergy(gctx, h.serial, startDate, today)+		result, err := h.reader.QueryDailyEnergy(gctx, h.serial, startDate, endDate) 		items = result 		return err 	})@@ -51,7 +98,7 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 		// renders a placeholder when the split is missing. A throttle on 		// the off-peak table shouldn't take down the entire history 		// response, so log and continue without the split.-		result, err := h.reader.QueryOffpeak(gctx, h.serial, startDate, today)+		result, err := h.reader.QueryOffpeak(gctx, h.serial, startDate, endDate) 		if err != nil { 			slog.Warn("history offpeak query failed; proceeding without split", "error", err) 			return nil@@ -63,17 +110,23 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 	// Today readings: read on a sibling goroutine so a failure stays 	// isolated from the gated queries above (AC 4.9). The 24-hour window in 	// Unix seconds; computeTodayEnergy filters to >= midnight Sydney, so any-	// pre-midnight readings are discarded.+	// pre-midnight readings are discarded. The range form never includes+	// today (Decision 15), so it skips the query entirely and the channel is+	// pre-filled with an empty result. 	type readingsResult struct { 		readings []dynamo.ReadingItem 		err      error 	} 	readingsCh := make(chan readingsResult, 1)-	go func() {-		nowUnix := now.Unix()-		r, err := h.reader.QueryReadings(ctx, h.serial, nowUnix-86400, nowUnix)-		readingsCh <- readingsResult{readings: r, err: err}-	}()+	if includesToday {+		go func() {+			nowUnix := now.Unix()+			r, err := h.reader.QueryReadings(ctx, h.serial, nowUnix-86400, nowUnix)+			readingsCh <- readingsResult{readings: r, err: err}+		}()+	} else {+		readingsCh <- readingsResult{}+	}  	// Notes read runs alongside the errgroup so a failure logs and leaves 	// the per-day note field nil instead of cancelling the core queries.@@ -81,7 +134,7 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 	// g.Wait returns successfully — gctx is cancelled on Wait completion, 	// which would race a still-in-flight QueryNotes and yield a spurious 	// empty map.-	waitNotes := fetchNotesAsync(ctx, h.reader, "history", h.serial, startDate, today)+	waitNotes := fetchNotesAsync(ctx, h.reader, "history", h.serial, startDate, endDate)  	if err := g.Wait(); err != nil { 		<-readingsCh // drain so the goroutine doesn't leak
internal/api/history_test.go Modified +240 / -0
diff --git a/internal/api/history_test.go b/internal/api/history_test.goindex 70dcee8..fd66f20 100644--- a/internal/api/history_test.go+++ b/internal/api/history_test.go@@ -442,6 +442,246 @@ func TestHandleHistoryBundlesNotes(t *testing.T) { 	}) } +// TestHandleHistoryRangeParamMatrix covers the two request forms of /history+// (req 5.1, 5.4, 5.6): the existing days=N form (including the no-params+// default of 7), the new explicit start/end range form, and every rejected+// combination. The range form is past-only per Decision 15: end must be+// strictly before the current Sydney date. Spans are inclusive on both ends+// and capped at 31 days; the cap must be calendar-aware so a window crossing+// the April DST fallback (Sydney, 2026-04-05) is not off by one.+func TestHandleHistoryRangeParamMatrix(t *testing.T) {+	now := fixedNow() // 2026-04-15 10:00 AEST; today = "2026-04-15"++	tests := map[string]struct {+		params     map[string]string+		wantStatus int+		// wantErr is the exact error body message when wantStatus is 400.+		wantErr string+		// wantStart/wantEnd are the inclusive bounds QueryDailyEnergy must+		// receive when wantStatus is 200.+		wantStart string+		wantEnd   string+	}{+		"no params defaults to days=7": {+			params: nil, wantStatus: 200,+			wantStart: "2026-04-09", wantEnd: "2026-04-15",+		},+		"days only unchanged": {+			params: map[string]string{"days": "14"}, wantStatus: 200,+			wantStart: "2026-04-02", wantEnd: "2026-04-15",+		},+		"days with start and end rejected": {+			params:     map[string]string{"days": "7", "start": "2026-04-01", "end": "2026-04-07"},+			wantStatus: 400, wantErr: "cannot combine days with start and end parameters",+		},+		"days with lone start rejected": {+			params:     map[string]string{"days": "7", "start": "2026-04-01"},+			wantStatus: 400, wantErr: "cannot combine days with start and end parameters",+		},+		"lone start rejected": {+			params:     map[string]string{"start": "2026-04-01"},+			wantStatus: 400, wantErr: "start and end must be supplied together",+		},+		"lone end rejected": {+			params:     map[string]string{"end": "2026-04-07"},+			wantStatus: 400, wantErr: "start and end must be supplied together",+		},+		"unparseable start rejected": {+			params:     map[string]string{"start": "01-04-2026", "end": "2026-04-07"},+			wantStatus: 400, wantErr: "invalid start or end parameter, must be YYYY-MM-DD",+		},+		"impossible end date rejected": {+			params:     map[string]string{"start": "2026-04-01", "end": "2026-04-31"},+			wantStatus: 400, wantErr: "invalid start or end parameter, must be YYYY-MM-DD",+		},+		"end before start rejected": {+			params:     map[string]string{"start": "2026-04-07", "end": "2026-04-01"},+			wantStatus: 400, wantErr: "end must not be before start",+		},+		"end equals today rejected": {+			params:     map[string]string{"start": "2026-04-09", "end": "2026-04-15"},+			wantStatus: 400, wantErr: "end must be before the current date",+		},+		"end after today rejected": {+			params:     map[string]string{"start": "2026-04-09", "end": "2026-04-20"},+			wantStatus: 400, wantErr: "end must be before the current date",+		},+		"single-day range ok": {+			params:     map[string]string{"start": "2026-04-10", "end": "2026-04-10"},+			wantStatus: 200, wantStart: "2026-04-10", wantEnd: "2026-04-10",+		},+		"31-day inclusive span ok": {+			params:     map[string]string{"start": "2026-03-01", "end": "2026-03-31"},+			wantStatus: 200, wantStart: "2026-03-01", wantEnd: "2026-03-31",+		},+		"32-day span rejected": {+			params:     map[string]string{"start": "2026-03-01", "end": "2026-04-01"},+			wantStatus: 400, wantErr: "date range must not exceed 31 days",+		},+		"31-day span crossing April DST fallback ok": {+			params:     map[string]string{"start": "2026-03-15", "end": "2026-04-14"},+			wantStatus: 200, wantStart: "2026-03-15", wantEnd: "2026-04-14",+		},+		"32-day span crossing April DST fallback rejected": {+			params:     map[string]string{"start": "2026-03-14", "end": "2026-04-14"},+			wantStatus: 400, wantErr: "date range must not exceed 31 days",+		},+	}++	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			var gotStart, gotEnd string+			mr := &mockReader{+				queryDailyEnergyFn: func(_ context.Context, _, start, end string) ([]dynamo.DailyEnergyItem, error) {+					gotStart, gotEnd = start, end+					return []dynamo.DailyEnergyItem{}, 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(tc.params))+			require.NoError(t, err)+			assert.Equal(t, tc.wantStatus, resp.StatusCode)++			if tc.wantStatus == 400 {+				var body map[string]string+				require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+				assert.Equal(t, tc.wantErr, body["error"])+				return+			}+			assert.Equal(t, tc.wantStart, gotStart, "QueryDailyEnergy inclusive start bound")+			assert.Equal(t, tc.wantEnd, gotEnd, "QueryDailyEnergy inclusive end bound")+		})+	}+}++// TestHandleHistoryRangeSkipsLiveCompute verifies the range form is+// stored-values-only (req 5.3, Decision 15): no readings query is issued, no+// energy reconciliation or live off-peak integration happens, and the+// off-peak and notes queries are bounded by the requested range rather than+// today.+func TestHandleHistoryRangeSkipsLiveCompute(t *testing.T) {+	now := fixedNow()+	const rangeStart = "2026-04-10"+	const rangeEnd = "2026-04-12"++	peak := 3.4+	tr := &trackingReader{mockReader: &mockReader{+		queryDailyEnergyFn: func(_ context.Context, _, start, end string) ([]dynamo.DailyEnergyItem, error) {+			assert.Equal(t, rangeStart, start)+			assert.Equal(t, rangeEnd, end)+			return []dynamo.DailyEnergyItem{+				{Date: "2026-04-10", Epv: 15, EInput: 4, EOutput: 1, ECharge: 8, EDischarge: 7},+				{Date: "2026-04-11", Epv: 16, EInput: 5, EOutput: 2, ECharge: 9, EDischarge: 8, PeakGridImportKwh: &peak},+				{Date: "2026-04-12", Epv: 17, EInput: 6, EOutput: 3, ECharge: 10, EDischarge: 9},+			}, nil+		},+		queryOffpeakFn: func(_ context.Context, _, start, end string) ([]dynamo.OffpeakItem, error) {+			assert.Equal(t, rangeStart, start, "offpeak query bounded by range start, not today's window")+			assert.Equal(t, rangeEnd, end, "offpeak query bounded by range end, not today")+			return []dynamo.OffpeakItem{+				// Complete record — final deltas pass through.+				{Date: "2026-04-10", Status: dynamo.OffpeakStatusComplete, GridUsageKwh: 2.4, GridExportKwh: 0.6},+				// Pending record on a past date: must NOT be live-integrated+				// (no readings exist on the range path); split stays absent.+				{Date: "2026-04-12", Status: dynamo.OffpeakStatusPending, StartEInput: 999, StartEOutput: 999},+			}, nil+		},+		queryNotesFn: func(_ context.Context, _, start, end string) ([]dynamo.NoteItem, error) {+			assert.Equal(t, rangeStart, start, "notes query bounded by range start")+			assert.Equal(t, rangeEnd, end, "notes query bounded by range end, not today")+			return []dynamo.NoteItem{{Date: "2026-04-11", Text: "Past note"}}, nil+		},+	}}++	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{+		"start": rangeStart, "end": rangeEnd,+	}))+	require.NoError(t, err)+	assert.Equal(t, 200, resp.StatusCode)++	assert.Equal(t, int32(0), tr.queryReadingsCalls.Load(),+		"range form must not issue a readings query (Decision 15)")++	hr := parseHistoryResponse(t, resp)+	require.Len(t, hr.Days, 3)++	// Stored energy totals pass through unreconciled.+	assert.Equal(t, "2026-04-10", hr.Days[0].Date)+	assert.Equal(t, 15.0, hr.Days[0].Epv)+	assert.Equal(t, 4.0, hr.Days[0].EInput)++	// Complete off-peak record surfaces its final deltas.+	require.NotNil(t, hr.Days[0].OffpeakGridImportKwh)+	assert.InDelta(t, 2.4, *hr.Days[0].OffpeakGridImportKwh, 0.001)+	require.NotNil(t, hr.Days[0].OffpeakGridExportKwh)+	assert.InDelta(t, 0.6, *hr.Days[0].OffpeakGridExportKwh, 0.001)++	// Stored peak grid import passes through; note joined onto its day.+	require.NotNil(t, hr.Days[1].PeakGridImportKwh)+	assert.InDelta(t, peak, *hr.Days[1].PeakGridImportKwh, 0.001)+	require.NotNil(t, hr.Days[1].Note)+	assert.Equal(t, "Past note", *hr.Days[1].Note)++	// Pending record on a past date: no live integration, split absent.+	assert.Nil(t, hr.Days[2].OffpeakGridImportKwh)+	assert.Nil(t, hr.Days[2].OffpeakGridExportKwh)+}++// TestHandleHistoryRangePredatesData verifies req 5.5: a range older than (or+// partially older than) stored data returns whichever days exist — possibly+// none — without error.+func TestHandleHistoryRangePredatesData(t *testing.T) {+	now := fixedNow()++	t.Run("range entirely before stored data returns empty without error", func(t *testing.T) {+		mr := &mockReader{+			queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+				return []dynamo.DailyEnergyItem{}, 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{+			"start": "2020-01-01", "end": "2020-01-31",+		}))+		require.NoError(t, err)+		assert.Equal(t, 200, resp.StatusCode)+		assert.Empty(t, parseHistoryResponse(t, resp).Days)+	})++	t.Run("range partially before stored data returns the existing subset", func(t *testing.T) {+		mr := &mockReader{+			queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+				// Storage only has the last two days of the requested month.+				return []dynamo.DailyEnergyItem{+					{Date: "2026-03-30", Epv: 12},+					{Date: "2026-03-31", Epv: 13},+				}, 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{+			"start": "2026-03-01", "end": "2026-03-31",+		}))+		require.NoError(t, err)+		assert.Equal(t, 200, resp.StatusCode)++		hr := parseHistoryResponse(t, resp)+		require.Len(t, hr.Days, 2)+		assert.Equal(t, "2026-03-30", hr.Days[0].Date)+		assert.Equal(t, "2026-03-31", hr.Days[1].Date)+	})+}+ // TestHandleHistoryOffpeakSoftFailure verifies that an off-peak query // failure does not take down the entire history response. The iOS grid // card already degrades gracefully when the split is missing, so a
specs/OVERVIEW.md Modified +10 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 57d6d08..de033ff 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -29,6 +29,7 @@ | [Off-Peak From Readings](#off-peak-from-readings) | 2026-05-25 | Done | Bug fix replacing the off-peak window's snapshot-diff computation with trapezoidal integration over the `flux-readings` table. AlphaESS's intraday `eInput` value lags physical reality by 15–30 minutes, so the 14:00 snapshot misses the tail of grid-charging; those kWh land in the EOD daily total and get misclassified as peak (~4× overcount on the History peak imports tile, confirmed on 2026-05-18: meter 0.17/20.75 vs Flux 1.74/18.95). Five integrated deltas (grid import, grid export, solar, battery charge, battery discharge) replace `endE* − startE*` in `internal/poller/offpeak.go::computeOffpeakDeltas` and the snapshot-projection branch in `internal/api/compute.go::offpeakDeltas`. New `derivedstats.IntegrateOffpeakDeltas` with a shared `integrate(readings, start, end, selector)` helper (per-sample clamping before integration; same 60s gap rule and boundary interpolation as the existing `integratePload`/`integratePpv`). Poller defers window-end finalisation until a reading at-or-after `offpeak-end` (30s budget) to avoid recreating the boundary-loss class of bug at the readings layer. Today's live `/status` and `/day` integrate readings up to `now`; past days served from the persisted row. Diagnostic snapshot fields retained on the row alongside three new provenance fields (sample count, skipped pairs, `integratedAt`). New `WriteOffpeakIfPendingOrAbsent` / `WriteOffpeakIfComplete` conditional writes guard against poller-vs-backfill races. One-shot `cmd/backfill-offpeak` CLI recomputes all retained `flux-offpeak` rows within the 30-day readings TTL. Drift logging on every write surfaces snapshot-vs-integration divergence in CloudWatch. T-1341. | | [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. |  --- @@ -288,3 +289,12 @@ Dashboard "what-if" toggle that applies a named predetermined load (watts) as a - [design.md](dashboard-simulation/design.md) - [requirements.md](dashboard-simulation/requirements.md) - [tasks.md](dashboard-simulation/tasks.md)++## History Period Navigation++Navigation to past calendar periods on the History screen's Wk/Mo ranges: chevrons step back/forward one week or month at a time (mirroring Day Detail's `DayNavigationHeader`), tapping the new period label opens a Sydney-zoned graphical `DatePicker` (capped at today) that snaps to the containing period, and a "Current" button (visible only in the past) returns to the to-date view. Backed by a new **past-only** `start`/`end` inclusive date-range form on `GET /history` (`end` strictly before Sydney today, span ≤ 31 days, mixed `days`+range requests 400) — the range path never runs live-today compute or the readings query; the existing `days=N` form is untouched and deliberately permanent (Decision 16: server-anchored "today" is the right contract for to-date requests). FluxCore gains a `HistoryQuery` enum (`.days`/`.dateRange`, Hashable+Codable) travelling view model → API client → `ChartScope.historyRange`, so enlarged charts always show the same period (data-consistency rule). `HistoryViewModel` holds `periodAnchor: Date?` (`nil` = current) mutated only by intent methods — `selectRange` resets it, navigation/jump set it, `reload()`/refresh never touch it — with a `resolvedQuery` snapshot set inside `load()` driving the chart domain, period label, day counts, and chevron state so the axis always matches rendered data. Past periods render the full calendar period from stored values (absent fields stay absent; averages divide by recorded days with an "N of M days" subtitle when partial); empty past periods keep the cards and reserved axis with a "No data for this period" notice distinct from the error state; the offline cache fallback becomes bounded at both ends. New `HistoryPeriod` struct owns the DST-safe period math (property-tested). Out of scope: navigation for fixed 7d/14d/30d ranges, persistence across restarts/range switches, comparisons, swipe gestures, Dashboard/Day Detail/widget changes, cache eviction. T-1497.++- [decision_log.md](history-period-navigation/decision_log.md)+- [design.md](history-period-navigation/design.md)+- [requirements.md](history-period-navigation/requirements.md)+- [tasks.md](history-period-navigation/tasks.md)
specs/history-period-navigation/decision_log.md Added +537 / -0
diff --git a/specs/history-period-navigation/decision_log.md b/specs/history-period-navigation/decision_log.mdnew file mode 100644index 0000000..47fb73f--- /dev/null+++ b/specs/history-period-navigation/decision_log.md@@ -0,0 +1,537 @@+# Decision Log: History Period Navigation++## Decision 1: Limit period navigation to the Wk and Mo ranges++**Date**: 2026-06-11+**Status**: accepted++### Context++The History screen offers five ranges: fixed 7d/14d/30d windows and calendar-anchored Wk/Mo views. T-1497 asks for the ability to go back a week or month at a time. The fixed ranges could in principle also shift backward by their own length.++### Decision++Previous/next-period navigation and the date picker apply only to the Wk and Mo ranges. The 7d/14d/30d ranges remain anchored to today, unchanged.++### Rationale++The ticket is framed around weeks and months, which have natural calendar boundaries to step across. Shifted fixed windows ("the 14 days before the last 14 days") have unclear semantics and no obvious labelling, for little user value.++### Alternatives Considered++- **Navigation on all ranges**: shift 7d/14d/30d windows by their own length - Rejected: ambiguous semantics, more code, not requested.++### Consequences++**Positive:**+- Smaller scope; clear period labels (week range, month name).++**Negative:**+- Fixed ranges cannot view past windows; users must use Wk/Mo for history browsing.++---++## Decision 2: Include a calendar date picker in scope++**Date**: 2026-06-11+**Status**: accepted++### Context++The ticket mentions "a calendar option might work as well". T-1361 previously excluded a calendar-popover date picker from the Wk/Mo feature. Chevron-only navigation makes reaching distant periods tedious.++### Decision++Include a date picker alongside the previous/next chevrons. Picking a date jumps to the week or month containing it.++### Rationale++The user explicitly chose to include it during requirements gathering. Unbounded backward navigation makes a direct-jump mechanism genuinely useful rather than a nice-to-have.++### Alternatives Considered++- **Chevrons only**: simpler UI - Rejected by user: stepping far back one period at a time is tedious.+- **Type-specific pickers** (month+year picker for Mo): more tailored - Rejected: two different controls for one job; a single date picker that snaps to the containing period is consistent across both ranges.++### Consequences++**Positive:**+- Direct access to any past period.++**Negative:**+- Extra UI surface on the History screen; picker must be capped at today.++---++## Decision 3: Unbounded backward navigation with empty periods++**Date**: 2026-06-11+**Status**: accepted++### Context++`flux-daily-energy` retains data indefinitely but only since the poller started collecting. The app does not currently know the earliest stored date. Navigation could either stop at the earliest data or continue into empty periods.++### Decision++Backward navigation is unbounded. Periods before data collection render with the full-period chart axis and empty values.++### Rationale++Stopping at the earliest date requires the app to learn that date (new API surface or inference), for marginal benefit in a two-user app. Empty periods are honest and cheap.++### Alternatives Considered++- **Disable previous at earliest stored day**: cleaner UX at the boundary - Rejected: needs an earliest-date API capability or response inference; not worth the surface area.++### Consequences++**Positive:**+- No new API capability needed for boundaries; simple navigation logic.++**Negative:**+- Users can scroll into stretches of empty periods with no signal that older data will never exist.++---++## Decision 4: Switching range resets to the current period++**Date**: 2026-06-11+**Status**: accepted++### Context++When viewing a past week and switching the range picker to Mo (or to a fixed range), the view could either preserve the anchor date (show the month containing that week) or reset to the current period.++### Decision++Any range change resets the view to the current period for the newly selected range.++### Rationale++The user chose the simpler mental model: the picker always lands on "now", and past periods are reached deliberately via chevrons or the date picker.++### Alternatives Considered++- **Preserve anchor date across range switches**: keeps the user's place in history - Rejected by user: more complex mental model and state handling.++### Consequences++**Positive:**+- Simple, predictable picker behaviour; no anchor-translation edge cases (e.g. week spanning two months).++**Negative:**+- Users comparing a past week against its containing month must re-navigate after switching.++---++## Decision 5: Single date picker that snaps to the containing period++**Date**: 2026-06-11+**Status**: accepted++### Context++The picker could be one control for both ranges, or tailored per range (date picker for Wk, month+year picker for Mo).++### Decision++One date picker for both ranges. The chosen date resolves to the week (Wk) or month (Mo) containing it.++### Rationale++One consistent control, one code path. Snapping to the containing period matches how the Wk/Mo ranges already define their boundaries (Sydney calendar).++### Alternatives Considered++- **Month+year picker for Mo**: avoids implying day-level precision - Rejected: introduces a second control style for marginal clarity.++### Consequences++**Positive:**+- One implementation; consistent interaction in both ranges.++**Negative:**+- In Mo, picking a specific day is slightly misleading since only the month matters.++---++## Decision 6: Quick-reset affordance in addition to the forward chevron++**Date**: 2026-06-11+**Status**: accepted++### Context++After navigating far back, returning to the current period via the forward chevron alone requires many taps.++### Decision++Provide a single-action control that returns to the current to-date period, shown or enabled only when viewing a past period.++### Rationale++Unbounded backward navigation plus a date picker means users can land far in the past; a one-tap return keeps the common case (checking current period) fast.++### Alternatives Considered++- **Forward chevron only**: minimal UI - Rejected by user: tedious after deep navigation.++### Consequences++**Positive:**+- Fast recovery to the default view.++**Negative:**+- One more control to fit into the History header/toolbar.++---++## Decision 7: Past-period averages divide by recorded days, with an "N of M days" indicator++**Date**: 2026-06-11+**Status**: accepted++### Context++Data collection started at a point in time, so the oldest periods have energy records for only some of their days. A per-day average could divide by calendar days in the period or by days that have data, and an incomplete period could silently look complete. External review split on the right divisor, making this an explicit product call.++### Decision++Per-day averages divide by the number of days in the period that have a stored daily-energy record. When recorded days are fewer than calendar days, the period overview shows an indicator such as "11 of 30 days".++### Rationale++Dividing by recorded days reflects actual daily usage rather than understating it with empty days. The indicator prevents an incomplete period from masquerading as a complete one, resolving the main objection to that divisor.++### Alternatives Considered++- **Divide by calendar days in the period**: honest about period length - Rejected: understates real per-day usage when data is missing.+- **Recorded-days divisor without an indicator**: less UI - Rejected: incomplete months silently look complete.++### Consequences++**Positive:**+- Averages stay meaningful for partially recorded periods; incompleteness is visible.++**Negative:**+- One more element in the period overview; "has data" needed a definition (a stored daily-energy row exists).++---++## Decision 8: Next-chevron disabled at current period; return-to-current control hidden++**Date**: 2026-06-11+**Status**: accepted++### Context++When the current period is displayed, the next-chevron and the return-to-current control are inapplicable. "Hidden or disabled" left untestable latitude and risked layout shift or dead chrome.++### Decision++At the current period, the next-chevron is visible but disabled (matching Day Detail's next-day button), and the return-to-current control is hidden entirely.++### Rationale++The chevron pair mirrors Day Detail's established pattern, so it follows that pattern's disabled state. A permanently visible but usually-disabled reset button would be dead chrome; it only appears when it can do something.++### Alternatives Considered++- **Both disabled**: no layout shift anywhere - Rejected: permanently visible reset control that is disabled most of the time.+- **Both hidden**: cleanest chrome - Rejected: chevron row changes shape and diverges from the Day Detail pattern.++### Consequences++**Positive:**+- Consistent with Day Detail; reset control self-explains by appearing only when useful.++**Negative:**+- Reset control appearing/disappearing causes minor layout change on navigation.++---++## Decision 9: Refresh re-fetches the displayed past period++**Date**: 2026-06-11+**Status**: accepted++### Context++Pull-to-refresh (iOS) and ⌘R (macOS) on the History screen need defined semantics while a past period is displayed: re-fetch the viewed period or reset to the current one.++### Decision++Refresh re-fetches the displayed period and keeps the user's place.++### Rationale++Refresh meaning "reload what I'm looking at" is predictable. Past data is mostly immutable, but notes and late-computed derived stats can still change, so re-fetching is not wasted.++### Alternatives Considered++- **Reset to current period on refresh**: treats refresh as "show me now" - Rejected: silently loses the user's place; the dedicated return-to-current control already covers that intent.++### Consequences++**Positive:**+- Predictable refresh semantics; picks up late-arriving notes and stats for past days.++**Negative:**+- Refreshing immutable old periods re-queries the backend for data that rarely changes.++---++## Decision 10: Explicit inclusive start/end range parameters; reject mixed request forms++**Date**: 2026-06-11+**Status**: accepted (end-bound validation refined by Decision 15: end strictly before today)++### Context++The /history endpoint only accepts days=N anchored to today and cannot express a past period. Review flagged that the new request contract was assumed but unstated, that the span limit ("one full month") was untestable, and that an inclusive/exclusive fencepost mismatch between client and server could spuriously reject 31-day months.++### Decision++History requests accept an explicit start/end date range, inclusive on both ends, validated as: end ≥ start, end ≤ current Sydney date, inclusive span ≤ 31 days. The days=N form is retained unchanged; a request supplying both forms is rejected.++### Rationale++An explicit range is the only way to express an arbitrary past period without over-fetching and client-side filtering. Inclusive-both-ends matches how the table is keyed (YYYY-MM-DD dates) and makes a 31-day month exactly 31 days, eliminating the fencepost ambiguity. Rejecting mixed requests is clearer than a precedence rule that would mask client bugs. The 31-day cap covers any calendar week or month while bounding query cost, mirroring the existing days cap.++### Alternatives Considered++- **Over-fetch days=N and filter client-side**: no API change - Rejected: brittle, re-introduces the cache-bounding leak, cannot reach periods older than 31 days.+- **Precedence rule when both forms supplied** (range wins): lenient - Rejected: silently masks malformed client requests.++### Consequences++**Positive:**+- Any past week/month is addressable; testable validation; backward compatible.++**Negative:**+- Two request forms to maintain on the endpoint.++---++## Decision 11: HistoryQuery enum with protocol-extension default++**Date**: 2026-06-11+**Status**: accepted++### Context++The `FluxAPIClient` protocol has ~30 test-mock conformers. Adding a required method breaks them all; the codebase already evolved the protocol once via a defaulted method (`fetchStatus(simulateLoadWatts:)`). The new date-range request also needs to travel through the chart-expansion scope, so the request form wants to be a value type, not two separate method signatures at every layer.++### Decision++Introduce `HistoryQuery` (`.days(Int)` / `.dateRange(start:end:)`) in FluxCore and add `fetchHistory(query:)` to the protocol with a default implementation: `.days` delegates to the existing required `fetchHistory(days:)`; `.dateRange` throws `notConfigured`.++### Rationale++One Hashable value type carries the request through view model, API client, and `ChartScope`, guaranteeing every consumer fetches the same window (the data-consistency rule). The default keeps all existing mocks compiling; only mocks that exercise navigation implement the new method.++### Alternatives Considered++- **Second required method `fetchHistory(start:end:)`**: explicit - Rejected: breaks every existing mock and leaves the expansion scope without a single value to carry.+- **Widen `fetchHistory(days:)` to take optional dates**: one method - Rejected: optional-parameter soup; invalid combinations become representable.++### Consequences++**Positive:**+- No churn in existing mocks; expansion scope reuses the same type.++**Negative:**+- A mock that forgets to implement the new method fails at runtime (notConfigured) rather than compile time.++---++## Decision 12: Period anchor as separate view-model state, not a HistoryRange case++**Date**: 2026-06-11+**Status**: accepted++### Context++The displayed past period needs representing. `HistoryRange` is the segmented picker's tag type (Hashable, five fixed cases); embedding an anchor date in its cases would break picker equality and force every range consumer to handle anchored variants.++### Decision++`HistoryViewModel` holds `periodAnchor: Date?` — nil means the current to-date period; non-nil is the Sydney-midnight start of a past period. `HistoryRange` is unchanged. Period math lives in a new `HistoryPeriod` struct.++### Rationale++The picker and the navigation are orthogonal axes (which period type × which period instance); modelling them separately keeps the picker untouched and confines anchor handling to the view model and header. nil-as-current makes "is viewing past" and the range-switch reset (Decision 4) trivial.++### Alternatives Considered++- **`HistoryRange.week(anchor: Date?)` cases**: single source - Rejected: breaks segmented-picker tags and spreads anchor handling across every range consumer.+- **Separate `displayedPeriod` enum (.current/.past(HistoryPeriod))**: more explicit - Rejected: equivalent information to `Date?` with more ceremony; the period is derivable from (range, anchor, now).++### Consequences++**Positive:**+- Picker, chart-domain, and cards keep their existing range plumbing.++**Negative:**+- The (range, anchor) pair must be threaded through the load-coalescing loop together.++---++## Decision 13: Chart-expansion scope carries the HistoryQuery++**Date**: 2026-06-11+**Status**: accepted++### Context++Enlarging a History card spawns a window/cover that fetches its own data via `ChartScope.historyRange(days: Int)` anchored to today. While viewing a past period, the expanded chart would show the current period — violating the CLAUDE.md data-consistency rule.++### Decision++`ChartScope.historyRange` carries a `HistoryQuery` instead of a day count; cards build their expansion scope from the view model's current query. The expanded window therefore fetches and renders the same period, including its 60-second polling.++### Rationale++The consistency rule leaves no room for the expanded chart to disagree with the card it came from. Carrying the query is a mechanical change across six files and reuses the type introduced in Decision 11.++### Alternatives Considered++- **Disable expansion while viewing a past period**: smaller change - Rejected by user: a feature silently disappearing on navigation is worse than the plumbing.++### Consequences++**Positive:**+- Expanded charts always match the on-screen period.++**Negative:**+- Six expansion-related files change; polling re-fetches immutable past data (harmless, matches refresh semantics).++---++## Decision 14: Shared in-content navigation header on both platforms; period label triggers the picker++**Date**: 2026-06-11+**Status**: accepted++### Context++Day Detail splits its navigation by platform: in-content chevron header on iOS, window-toolbar chevrons on macOS. The History header must also host the period label (req 4.1), the picker trigger, and the conditional "Current" button.++### Decision++One `HistoryPeriodHeader` rendered in content on both platforms, styled after `DayNavigationHeader`: chevrons flanking a centred period label; tapping the label opens a graphical `DatePicker` (popover, sheet on compact iOS) capped at Sydney today; a "Current" button appears only when viewing a past period. macOS adds `←`/`→` via `onKeyPress`, matching Day Detail's keyboard affordance.++### Rationale++The picker-trigger label has to live in content regardless, so toolbar chevrons on macOS would duplicate controls across two locations for no requirement — req 1.7 only obliges the keyboard affordance. One component keeps the two platforms identical and the change surface small. Chosen by user (header below picker; label as picker trigger).++### Alternatives Considered++- **macOS window-toolbar chevrons (full Day Detail parity)**: closest pattern match - Rejected: duplicates navigation controls; the label/picker must be in content anyway.+- **Controls inside the Period overview card**: saves vertical space - Rejected by user: navigation hides when scrolled and mixes controls into card content.+- **Separate calendar icon button**: more discoverable - Rejected by user: one more control in the row; the label affords tapping.++### Consequences++**Positive:**+- One shared component; navigation always visible at the top of the screen.++**Negative:**+- Minor divergence from Day Detail's macOS toolbar placement.++---++## Decision 15: The date-range request form is past-only (end strictly before today)++**Date**: 2026-06-11+**Status**: accepted (refines Decision 10; requirement 5.6 amended accordingly)++### Context++Decision 10 validated range requests with `end ≤ today`. A range ending today would trigger the handler's live-today compute path — a surface the app never exercises, since the current period is always requested via `days=N`. Design review flagged this as untested, unrequired surface where consistency bugs hide.++### Decision++Range requests must have `end` strictly before the current Sydney date; `end == today` is rejected with a 400. The date-range path therefore never performs live compute or the readings query.++### Rationale++Every permitted input should have a consumer and a test. Making the range form past-only gives the handler two cleanly separated paths — `days=N` (may include live today, unchanged) and `start/end` (stored values only) — instead of a hybrid that req 5.3 would otherwise have to qualify.++### Alternatives Considered++- **Permit `end == today` and test the live path**: more general API - Rejected: tests a code path with no consumer; the two-user app gains nothing.++### Consequences++**Positive:**+- Simpler handler (range path skips two DynamoDB queries unconditionally); req 5.3 becomes unconditional.++**Negative:**+- A hypothetical future consumer wanting "this week including today" as a range must use `days=N` or relax the validation.++---++## Decision 16: Keep days=N and start/end as permanently distinct request forms++**Date**: 2026-06-11+**Status**: accepted++### Context++With the range form added, the question arose whether the app should migrate to start/end for all requests so days=N could eventually be removed from the backend, leaving one request form.++### Decision++Both forms stay, with distinct semantics: `days=N` means "window ending on the server's today" (mutable, may include live data); `start/end` means "this exact immutable past window". No migration or removal is planned from this spec; a separate ticket exists to revisit the idea later.++### Rationale++`days=N` anchors "today" server-side, so a request built just before midnight and processed just after stays internally consistent — with client-computed dates the same request would either silently render a current view missing today (window now ends "yesterday", a valid past range) or 400 on `end == today`. Migrating the current period to the range form would also force `end == today` to be permitted again, reintroducing the hybrid live-compute path Decision 15 removed. The eventual saving (~10 lines of param parsing) does not cover the migration's change surface.++### Alternatives Considered++- **Migrate app to start/end now, remove days=N later**: one request form eventually - Rejected: reverses Decision 15, reintroduces live compute on the range path, and creates a midnight clock-skew failure mode the current form cannot have.++### Consequences++**Positive:**+- Current-period requests remain self-correcting at the midnight boundary; the range path stays stored-values-only.++**Negative:**+- Two request forms to maintain and document on one endpoint.++---++## Decision 17: navigateNext clamps in the view model, not only via the disabled chevron++**Date**: 2026-06-12+**Status**: accepted++### Context++The design keyed the next-chevron's disabled state off the resolved snapshot (`isViewingCurrentPeriod`), which by design lags rendered data while a load is in flight. Post-implementation review showed that a second `navigateNext` arriving mid-load — a rapid double-tap, or macOS arrow-key repeat, which fires faster than a network round trip — could step past the current period and issue a future-dated range request. The server correctly 400s it, but the user lands in an error state whose Retry repeats the same bad request.++### Decision++`navigateNext()` guards `periodAnchor != nil` and is a no-op at the current period, mirroring `DayDetailViewModel`'s `isToday` clamp. The chevron's resolved-snapshot disabled state is presentation only.++### Rationale++UI disabled states are advisory under async load; the view model is the only place that can enforce the invariant against event timing. The sibling Day Detail pattern already clamps in the view model for the same reason.++### Alternatives Considered++- **Key the disabled state off requested state instead of resolved**: closes the race - Rejected: breaks the resolved-snapshot rule that the header reflects rendered data, and still leaves programmatic callers unguarded.+- **Have load() reject future ranges client-side**: catches the symptom - Rejected: the navigation intent is the wrong layer to let produce an invalid period in the first place.++### Consequences++**Positive:**+- Future-dated range requests are unreachable from the UI regardless of event timing; covered by no-op and mid-load double-tap tests.++**Negative:**+- The clamp duplicates, at the intent layer, a constraint the disabled chevron also expresses.++---
specs/history-period-navigation/design.md Added +167 / -0
diff --git a/specs/history-period-navigation/design.md b/specs/history-period-navigation/design.mdnew file mode 100644index 0000000..3e43721--- /dev/null+++ b/specs/history-period-navigation/design.md@@ -0,0 +1,167 @@+# Design: History Period Navigation++**Ticket:** T-1497 · **Requirements:** [requirements.md](requirements.md)++## Overview++Adds previous/next stepping, a date-picker jump, and a return-to-current action for the Wk/Mo History ranges, backed by a new explicit date-range form of the `/history` request. Three layers change: the Go handler accepts `start`/`end`, FluxCore grows a query type plus period date-math, and the History screen gains an anchor state and a navigation header.++## Architecture++### Request flow++```+HistoryView (range picker, period header)+  → HistoryViewModel (range + periodAnchor → HistoryQuery)+    → FluxAPIClient.fetchHistory(query:)        [FluxCore]+      → GET /history?days=N                      (current period — unchanged)+      → GET /history?start=YYYY-MM-DD&end=YYYY-MM-DD   (past period — new)+        → handleHistory: range form → no readings query, no live compute (past-only)+```++The response shape (`HistoryResponse`) is unchanged; past periods simply never contain a live today row (req 5.3).++### New shared types++**`HistoryQuery`** (FluxCore, `Networking/HistoryQuery.swift`) — the one type that travels from view model to API client to chart-expansion scope, so every consumer fetches the same window:++```swift+public enum HistoryQuery: Hashable, Sendable, Codable {+    case days(Int)                              // existing anchored-to-today form+    case dateRange(start: String, end: String)  // inclusive YYYY-MM-DD bounds+}+```++`Codable` is required, not optional: `ChartScope` (which will carry this type) is `Hashable, Codable` (`ChartKind.swift:46`) and is encoded for scene/expansion restoration — both cases synthesize trivially.++**`HistoryPeriod`** (app, `Flux/Flux/History/HistoryPeriod.swift`) — a resolved calendar period with all date math in one place, built on the existing `DateFormatting` helpers (Sydney calendar, locale first weekday per the T-1361 rules):++```swift+struct HistoryPeriod: Equatable {+    let start: Date          // Sydney midnight+    let endExclusive: Date   // Sydney midnight after the last day++    static func week(containing: Date, firstWeekday: Int) -> HistoryPeriod+    static func month(containing: Date) -> HistoryPeriod+    func previous(range: HistoryRange, firstWeekday: Int) -> HistoryPeriod+    func next(range: HistoryRange, firstWeekday: Int) -> HistoryPeriod+    // previous/next assert on `.days` ranges — navigation is undefined there+    // and the controls are never shown (req 1.5); a silent self-return would+    // hide a wiring bug.+    func contains(_ date: Date) -> Bool+    var dayCount: Int        // M in "N of M days"+    var startDateString: String  // YYYY-MM-DD+    var endDateString: String    // inclusive+}+```++`previous`/`next` shift by `date(byAdding: .day, value: ∓7)` for weeks and `.month, ∓1` for months on `start` — calendar arithmetic, never interval division, so DST days don't drift (same rule as `HistoryChartDomain.build`).++### View-model state++`HistoryViewModel` gains one piece of state: `periodAnchor: Date?` — `nil` means current (to-date) period; non-nil is the Sydney-midnight start of a past period. The picker's `HistoryRange` enum is untouched, so picker tags and equality keep working.++The anchor is mutated ONLY by intent methods — the load path itself is anchor-agnostic, so refresh can never reset the user's place (req 1.8). `reload()` keeps its current body (`loadHistory(range: lastRequestedRange)`) and stays correct precisely because `loadHistory` no longer touches the anchor:++| Intent method | Anchor effect | Caller |+|---|---|---|+| `selectRange(_ range:)` — sets anchor `nil`, then loads | reset (req 2.3) | picker `onChange`, view `.task` |+| `navigatePrevious()` / `navigateNext()` | shift via `HistoryPeriod`; `next` landing on the period containing Sydney-today → `nil` | header chevrons, macOS arrow keys |+| `jumpTo(date: Date)` | period containing date; contains today → `nil` (req 3.3) | date picker |+| `returnToCurrent()` | `nil` | "Current" button |+| `loadHistory(range:)` / `reload()` | none — anchor untouched | refresh paths |++Req 1.8 has two existing violations to fix in `HistoryView`: `.refreshable` (`HistoryView.swift:121`) and the error-state Retry button (`:295`) both call `loadHistory(range: selectedRange)`; under the new split that is harmless to the anchor, but both still switch to `reload()` so a stale `selectedRange` capture can never diverge from `lastRequestedRange`. The picker `onChange` and the `.task` switch to `selectRange(_:)`.++Load resolution: anchor `nil` → existing `resolvedDays` path → `.days(N)`. Anchor set → `.dateRange(start:end:)` from `HistoryPeriod`. The in-flight coalescing loop currently keyed on `lastRequestedRange` is re-keyed on a `RequestedPeriod` pair (range + anchor) so a navigation during a load is honoured the same way a range change is.++### Resolved snapshot — requested vs rendered++The existing code deliberately separates requested from resolved state: `chartDomain` reads `resolvedRange`, not `lastRequestedRange`, "so the charts' x-axis reservation matches the rendered data, not an in-flight selection" (`HistoryViewModel.swift:17-20`). The anchor follows the same rule via one atomic snapshot set inside `load()` alongside `resolvedRange`:++- `resolvedQuery: HistoryQuery` — the query whose data is in `days`.++Everything user-visible derives from the resolved snapshot, never from the live `periodAnchor`: `chartDomain` (reference date = the resolved period's start, falling back to `now` for `.days`), `resolvedRangeDays` (`.days(n)` → n; `.dateRange` → `HistoryPeriod.dayCount`), the period label, the cards' expansion scope, and the next-chevron disabled state (`resolvedQuery` is `.days` — i.e. the rendered period contains today). Keying the chevron off the resolved query rather than `periodAnchor == nil` also keeps it correct across a midnight period-rollover until the next load re-resolves (requirements Definitions). `HistoryChartDomain.make`'s `now:` parameter becomes `referenceDate:` (req 1.4: past periods always reserve the full week/month containing it).++### "Today" and current-period checks++All checks (anchor-collapse-to-nil in `navigateNext`/`jumpTo`, picker max) use `DateFormatting`'s Sydney calendar against `nowProvider()`, re-evaluated on each load trigger (requirements Definitions).++### Pattern parity audit — `fetchHistory(days:)` and `ChartScope.historyRange`++| Site | Needs equivalent | Change |+|---|---|---|+| `HistoryViewModel.load` (`HistoryViewModel.swift:88`) | yes | call `fetchHistory(query:)` |+| `ChartKind.swift:47` `case historyRange(days: Int)` | yes | → `case historyRange(HistoryQuery)` |+| `HistorySolarCard/GridUsage/DailyUsage.expansionScope` | yes | build from a new `periodQuery: HistoryQuery` param passed by `HistoryView` (replaces deriving from `rangeDays`) |+| `ChartSceneObserver.refresh` (`ChartSceneObserver.swift:83`) | yes | fetch via the scope's query; 60 s polling of an immutable past period is harmless and matches refresh semantics |+| `ChartExpansionContent.historyRangeDays(from:)` | yes | day count: `.days(n)` → n; `.dateRange` → `inclusiveDayCount` |+| `ExpandedChartView` default scope (`:91`) | yes | `.historyRange(.days(defaultHistoryRangeDays))` |+| `MockFluxAPIClient` (`Flux/Flux/Services/MockFluxAPIClient.swift:258`) | yes | implement `fetchHistory(query:)` — it backs every preview; under the protocol default a `.dateRange` would throw and previews navigating to a past period would show the error state |+| `URLSessionAPIClient+Simulation` | no | has no history path |+| Widget targets | no | no history usage |++Expanded windows do not currently use `HistoryChartDomain` (no full-period reservation); that pre-existing gap is unchanged by this feature — the expanded chart shows the same days, auto-fit.++## Components and Interfaces++### Backend — `internal/api/history.go`++Parameter parsing replaces the current `days` block. The mixed-form check distinguishes a *present* `days` parameter from the defaulted `days = 7` (`history.go:22`):++- No parameters → `days = 7`, existing behaviour (the easiest regression to introduce in this rewrite — it gets its own test row).+- `days` alone → existing behaviour, untouched (req 5.4).+- `start` and `end` alone → explicit range. Parse with `time.ParseInLocation("2006-01-02", v, sydneyTZ)`; 400 on parse failure.+- `days` together with `start`/`end`, or only one of `start`/`end` → 400 (req 5.1).+- Validation (req 5.6): `end < start` → 400; `end >= today` → 400 (string compare on zero-padded dates is safe); span: `end.After(start.AddDate(0, 0, 30))` → 400. `AddDate` is calendar-aware, so a DST transition inside the window cannot produce an off-by-one.++The range form is **past-only** (`end` strictly before Sydney today). The app never requests a range ending today — the current period always goes through `days=N` — so permitting it would create an untested live-compute surface for no consumer. Consequence: the date-range path never touches live compute at all — the readings goroutine is not started, `todayComputed`/`todayReadings` stay nil, and the per-day loop's `isItemToday` is false for every row (req 5.3 — also skips two DynamoDB queries per past-period request). The `days` path keeps its `includesToday` behaviour unchanged. `QueryDailyEnergy`, `QueryOffpeak`, and `fetchNotesAsync` take `(startDate, endDate)` instead of `(startDate, today)`. Days absent from storage are simply absent from the result — already today's behaviour (req 5.5); never-stored fields are already omitted per row (req 5.2).++**Day Detail parity (req 5.7):** for any non-today date, `/day` and `/history` both serve every shared summary field (energy totals, peak grid import, off-peak split, socLow, dailyUsage, peakPeriods) from the same non-TTL `flux-daily-energy` row, gated identically by `isToday`/`isItemToday` (`day.go:175-216` vs `history.go:130-198`) — neither recomputes from the TTL-expired `flux-readings`, so parity holds structurally even for dates older than 30 days, which this feature makes reachable in Day Detail for the first time. The existing `cross_handler_test.go` parity test asserts only derivedStats; it is extended to also assert energy totals, peak grid import, and off-peak split for an old date.++### FluxCore++- `FluxAPIClient` gains `func fetchHistory(query: HistoryQuery) async throws -> HistoryResponse`, with a protocol-extension default that delegates `.days(n)` to the existing required `fetchHistory(days:)` and throws `FluxAPIError.notConfigured` for `.dateRange` — the established evolution pattern (`fetchStatus(simulateLoadWatts:)`), so the ~30 existing test mocks keep compiling.+- `URLSessionAPIClient` implements it: `.days` → `?days=N`; `.dateRange` → `?start=…&end=…`.+- `DateFormatting`: no new functions — `HistoryPeriod` composes the existing helpers (`startOfWeek`, `startOfMonth`, `sydneyCalendar`, `inclusiveDayCount`, `dayDateString`); the month interval comes from `sydneyCalendar.dateInterval(of: .month, for:)` as in `HistoryChartDomain`. The period-label formatters ("MMM d", day-only "d", and month-year "MMM yyyy" — none of which exist today) live file-private in `HistoryPeriodHeader`, Sydney-zoned, mirroring the `HistorySummaryDateFormatter` precedent (`HistoryView.swift:326`).++### History screen++**`HistoryPeriodHeader`** (new, `Flux/Flux/History/HistoryPeriodHeader.swift`) — shown between the range picker and the note row, only when `selectedRange` is `.weekToDate`/`.monthToDate` (req 1.5). Layout and button styling match `DayNavigationHeader` (`DayDetailViewSupport.swift:50`): `chevron.left` / centred label / `chevron.right`, same `navButton` treatment and disabled style.++- Label (req 4.1): week → `"Jun 2 – 8"` (cross-month `"May 29 – Jun 4"`) via `shortMonthDay`; month → `"May 2026"`. The label is a button that presents the date picker (popover; compact iOS presents as sheet) — `DatePicker(.graphical)` with `in: ...sydneyTodayEnd` (req 3.2). The picker view gets `.environment(\.calendar, …)` / `.environment(\.timeZone, …)` set to Sydney — `DatePicker` otherwise renders and caps in the device calendar, so on a non-Sydney device "today" and the snapped period could be off by a day.+- A compact "Current" button appears trailing the header only when `periodAnchor != nil` (req 2.1/2.2, Decision 8).+- Deviation from Day Detail: macOS gets the same in-content header rather than window-toolbar chevrons — the picker-trigger label must be in content anyway, and req 1.7 only requires the keyboard affordance, added via `.focusable()` followed by `.onKeyPress(.leftArrow/.rightArrow)` on `HistoryView` (guarded to Wk/Mo), mirroring `DayDetailView.swift:104-111` — without `.focusable()` the key handler never receives events.++**`HistoryView`** — passes `viewModel.periodQuery` to the three cards for their expansion scope. Empty handling splits on `periodAnchor`: for a past period with no error and zero days, the cards stay rendered (the `HistoryChartDomain` scaffold reserves the full-period axis, satisfying req 1.6's "full-period chart axis") with a compact "No data for this period" notice in place of the note row — NOT the existing replace-everything `emptyState`, which would remove the charts and the axis with them. The current-period empty state and `errorState` are unchanged (req 7.3 distinguishes them).++**`HistoryStatsOverviewCard`** — `HistoryCardChrome`'s unused `subtitle` slot shows `"N of M days"` when viewing a past period with `summary.dayCount < periodDays` (req 6.2). N is `summary.dayCount` (days with a stored record), M is `resolvedRangeDays`. No changes to `DerivedState`/`PeriodSummary` math: for past periods no row is today, so every existing "complete days" average naturally divides by days-with-data (req 6.1) and current-period behaviour is untouched (req 6.3). Accepted wording overlap: the costs card's caption counts *recorded* days ("N of N days priced", `PeriodCosts`), so a partial past month can show "11 of 30 days" and "11 of 11 days priced" together — different questions (period coverage vs pricing coverage of recorded days); the costs card is unchanged.++### Cache++`loadCachedDays(onOrAfter:)` becomes `loadCachedDays(from:through:)` with both bounds in the predicate (`date >= lower && date <= upper`); the current-period call passes `(windowStart, todayString)` — same result set as before since nothing later than today is ever cached — and the past-period call passes the period bounds (req 7.2). `cacheHistoricalDays` works unchanged for past days (it upserts by date and only skips today's row) (req 7.1).++## Error Handling++| Condition | Behaviour |+|---|---|+| Mixed/incomplete/invalid params on `/history` | 400 with message naming the rule violated (mirrors existing `days` 400) |+| Past-period fetch fails, cache has period days | bounded cached days shown, existing offline presentation (req 7.2) |+| Past-period fetch fails, no cached days | existing `errorState` with Retry (req 7.3) |+| Past-period fetch succeeds, zero days | "No data for this period" state, full-period chart axis (req 1.6) |++## Testing Strategy++**Go (`internal/api/history_test.go` additions)** — table-driven: param matrix (no params → days=7 default unchanged, days only, range only, both → 400, lone start/end → 400, bad date → 400, `end < start` → 400, `end == today` → 400, `end > today` → 400, 31-day span OK, 32-day span → 400, span crossing the April DST fallback); past-range response has no live compute and no readings query (record calls on the reader stub); range predating data returns the existing subset without error. **`cross_handler_test.go`** — extend the `/day`-vs-`/history` parity assertion beyond derivedStats to energy totals, peak grid import, and off-peak split, for a date old enough that its readings would be TTL-expired (req 5.7).++**FluxCore (Swift Testing)** — `URLSessionAPIClient` encodes both query forms; protocol default for `.dateRange` throws.++**App — `HistoryPeriod` property-based tests** (the PBT candidate: round-trip and containment invariants over seeded random dates spanning DST transitions and month-length edges, in the style of the T-1361 suite):+- `p.next(...).previous(...) == p` and vice versa+- `p.contains(p.start)` and `!p.contains(p.endExclusive)`+- week periods are always 7 slot days; month periods 28–31+- `week(containing: d)` is identical for every `d` in the same week (likewise month)++**App — `HistoryViewModel`** — navigation sets the expected `HistoryQuery` on a recording mock; anchor resets on `selectRange` but not on `reload()`/`loadHistory`; next from "previous week" lands on current (anchor nil, `.days` query); jumpTo a date in the current month → anchor nil; coalescing honours a navigation issued mid-load; `resolvedQuery`/`chartDomain` reflect rendered data, not an in-flight navigation; cache fallback is bounded both ends; empty-vs-error state selection.++**App — snapshot-free view checks** — header hidden for `.days` ranges; "Current" button visibility; stats card subtitle for partial periods; an empty past period keeps the chart cards rendered (scaffold axis present) with the no-data notice, rather than falling into the replace-everything empty state.
specs/history-period-navigation/requirements.md Added +103 / -0
diff --git a/specs/history-period-navigation/requirements.md b/specs/history-period-navigation/requirements.mdnew file mode 100644index 0000000..60e449d--- /dev/null+++ b/specs/history-period-navigation/requirements.md@@ -0,0 +1,103 @@+# Requirements: History Period Navigation++**Ticket:** T-1497++## Introduction++The History screen's Wk and Mo ranges currently show only the current (to-date) week or month. This feature adds navigation to past periods: stepping back and forward one calendar week or month at a time, jumping to any past period via a date picker, and returning to the current period in one action. The backend must serve stored daily data for explicitly requested past date ranges, which it currently cannot (requests are always anchored to today).++## Definitions++- **Calendar week / calendar month** boundaries follow the rules established in `specs/history-month-week-to-date/` (Sydney calendar, locale-derived first weekday).+- **Today** in all checks (next-period disabling, picker maximum, server-side validation) is the current Sydney calendar date, not the device-timezone date.+- **Current period** checks are re-evaluated on each load trigger (range selection, navigation, refresh, screen appearance), per the prior spec's recompute rule.+- Day counts and date ranges are **inclusive on both ends**: a range covering one 31-day month spans 31 days.++## Out of Scope++- Period navigation for the fixed 7d/14d/30d ranges — they remain anchored to today.+- Persisting the viewed period across app restarts or range switches.+- Period-over-period comparison views (e.g. this week overlaid on last week).+- Swipe-gesture navigation; controls are chevrons, a return-to-current action, and the date picker.+- Changes to Dashboard, Day Detail, or widgets.+- Backfilling data for periods before collection started; such periods show as empty. Derived stats for old days can never be recomputed (source readings expire after 30 days).+- Cache eviction for accumulated past-period data.++## Requirements++### 1. Previous/Next Period Navigation++**User Story:** As a Flux user, I want to step back and forward through past weeks and months on the History screen, so that I can review earlier energy data.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHERE the selected range is Wk or Mo, the History screen SHALL provide previous/next-period controls matching the existing Day Detail previous/next-day affordance.+2. <a name="1.2"></a>WHEN the previous-period control is activated, the screen SHALL display the calendar week (Wk) or calendar month (Mo) immediately before the period currently shown.+3. <a name="1.3"></a>WHEN the next-period control is activated, the screen SHALL display the period immediately after the one shown; WHERE the displayed period contains today, the next-period control SHALL be visible but disabled.+4. <a name="1.4"></a>WHERE the displayed period is the current week/month, the displayed data, chart domain, and statistics SHALL match the existing to-date behaviour; WHERE the displayed period is in the past, the screen SHALL show the full calendar period.+5. <a name="1.5"></a>WHERE the selected range is 7d, 14d, or 30d, period-navigation controls SHALL NOT be shown and existing behaviour SHALL be unchanged.+6. <a name="1.6"></a>Backward navigation SHALL NOT be bounded: WHEN a successfully fetched period contains no data, the screen SHALL render the full-period chart axis with an explicit no-data-for-this-period state, visually distinct from the fetch-error state.+7. <a name="1.7"></a>On macOS, the previous/next-period controls SHALL be operable via the same keyboard affordance Day Detail uses for previous/next day.+8. <a name="1.8"></a>WHEN the user refreshes (pull-to-refresh or the macOS refresh action) while a past period is displayed, the screen SHALL re-fetch the displayed period, not reset to the current one.++### 2. Returning to the Current Period++**User Story:** As a Flux user, I want a one-tap way back to the current period, so that I do not have to step forward repeatedly after browsing far into history.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHERE a past period is displayed, the screen SHALL offer a single-action control that returns to the current to-date week/month.+2. <a name="2.2"></a>WHERE the current period is displayed, that control SHALL be hidden.+3. <a name="2.3"></a>WHEN the user changes the range selection (Wk↔Mo or to any fixed range), the screen SHALL reset to the current period for the newly selected range.++### 3. Calendar Date Picker++**User Story:** As a Flux user, I want to pick a date from a calendar, so that I can jump directly to the week or month containing it without repeated stepping.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHERE the selected range is Wk or Mo, the screen SHALL offer a date picker; WHEN a date is picked, the screen SHALL display the week (Wk) or month (Mo) containing that date.+2. <a name="3.2"></a>The date picker SHALL NOT allow selection of dates after the current Sydney calendar date.+3. <a name="3.3"></a>WHEN the picked date falls within the current week/month, the screen SHALL show the current to-date view.++### 4. Period Identification++**User Story:** As a Flux user, I want to see which period I am viewing, so that I do not mistake past data for current data.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHERE Wk or Mo is selected, the screen SHALL display a label identifying the displayed period (week date range, or month and year).++### 5. Historical Data Service++**User Story:** As a Flux user, I want past weeks and months to show the same stored energy data as the rest of the app, so that history beyond the current period is accurate and consistent.++**Acceptance Criteria:**++1. <a name="5.1"></a>The system SHALL accept history requests for an explicit inclusive start/end date range, alongside the existing day-count request form; WHEN both forms are supplied in one request, the system SHALL reject it as invalid.+2. <a name="5.2"></a>For a requested range, the system SHALL serve stored daily energy data, off-peak splits, derived stats, and notes where stored; fields that were never stored for a day SHALL be served as absent, not as an error.+3. <a name="5.3"></a>Date-range responses SHALL contain only stored values and no live-computed today entry (the range form is past-only; see 5.6).+4. <a name="5.4"></a>Existing day-count-based history requests SHALL continue to work unchanged.+5. <a name="5.5"></a>WHEN a requested range predates stored data, the system SHALL return whichever days exist (possibly none) without error.+6. <a name="5.6"></a>The system SHALL reject a range request with a client error WHEN end is before start, end is not before the current Sydney date (the range form is past-only; the current period uses the day-count form), or the inclusive span exceeds 31 days.+7. <a name="5.7"></a>For fields displayed by both screens, values shown for a day in a past period SHALL equal the values Day Detail shows for that same day.++### 6. Statistics over Past Periods++**User Story:** As a Flux user, I want totals and averages for a past period computed over its recorded days, so that the stats are meaningful for completed periods.++**Acceptance Criteria:**++1. <a name="6.1"></a>WHERE a past period is displayed, per-day averages SHALL divide by the number of days in the period that have a stored daily-energy record, with no partial-day exclusion; totals SHALL sum those same days.+2. <a name="6.2"></a>WHERE a past period has fewer recorded days than calendar days, the period overview SHALL indicate this (e.g. "11 of 30 days").+3. <a name="6.3"></a>WHERE the current period is displayed, statistics behaviour SHALL be unchanged.++### 7. Offline and Cache Behaviour++**User Story:** As a Flux user, I want previously viewed past periods to be available offline, so that connectivity loss does not blank the History screen.++**Acceptance Criteria:**++1. <a name="7.1"></a>Fetched past-period days SHALL be cached the same way as current-history days.+2. <a name="7.2"></a>WHEN a past-period fetch fails and cached data exists for dates in that period, the screen SHALL show only the cached days bounded by both the period start and end, using the existing offline-fallback presentation.+3. <a name="7.3"></a>WHEN a past-period fetch fails and no cached data exists for that period, the screen SHALL show the fetch-error state, not the no-data-for-this-period state.
specs/history-period-navigation/tasks.md Added +129 / -0
diff --git a/specs/history-period-navigation/tasks.md b/specs/history-period-navigation/tasks.mdnew file mode 100644index 0000000..6413be6--- /dev/null+++ b/specs/history-period-navigation/tasks.md@@ -0,0 +1,129 @@+---+references:+    - specs/history-period-navigation/requirements.md+    - specs/history-period-navigation/design.md+    - specs/history-period-navigation/decision_log.md+---+# History Period Navigation++- [x] 1. Write failing /history handler tests for the param matrix and past-only range form <!-- id:lftd8w4 -->+  - Table-driven in internal/api/history_test.go+  - Matrix: no params (days=7 default unchanged); days only unchanged; days+start/end mixed form 400; lone start or end 400; unparseable date 400; end<start 400; end==today 400 (range form is past-only, Decision 15); end>today 400; 31-day inclusive span OK; 32-day 400; span crossing the April DST fallback+  - Past range issues NO readings query and no live compute (recording reader stub)+  - Range predating stored data returns the existing subset (possibly empty) without error+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6)+  - References: internal/api/history.go, internal/api/history_test.go, specs/history-period-navigation/design.md++- [x] 2. Implement start/end range form in handleHistory <!-- id:lftd8w5 -->+  - Distinguish days-present from days-defaulted for the mixed-form 400+  - Parse with time.ParseInLocation("2006-01-02", v, sydneyTZ)+  - Validation per Decisions 10/15: end strictly before Sydney today; span via end.After(start.AddDate(0,0,30))+  - Range path skips the readings goroutine and all live compute (todayComputed/todayReadings stay nil)+  - QueryDailyEnergy/QueryOffpeak/fetchNotesAsync take (startDate, endDate) instead of (startDate, today)+  - The days path behaviour stays byte-for-byte unchanged+  - Blocked-by: lftd8w4 (Write failing /history handler tests for the param matrix and past-only range form)+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6)+  - References: internal/api/history.go++- [x] 3. Extend cross-handler parity test to energy totals, peak grid import, and off-peak split <!-- id:lftd8w6 -->+  - cross_handler_test.go currently asserts derivedStats parity only+  - Add /day vs /history (range form) equality for a date more than 30 days old (readings TTL-expired): energy totals, peak grid import, off-peak split+  - Fixture must set PeakGridImportKwh and an offpeak row, which the current fixture leaves nil/absent+  - Blocked-by: lftd8w5 (Implement start/end range form in handleHistory)+  - Stream: 1+  - Requirements: [5.7](requirements.md#5.7)+  - References: internal/api/cross_handler_test.go, internal/api/day.go++- [x] 4. Write failing FluxCore tests for HistoryQuery and fetchHistory(query:) <!-- id:lftd8w7 -->+  - URLSessionAPIClient encodes .days(n) as ?days=N and .dateRange as ?start=...&end=...+  - Protocol-extension default delegates .days to the required fetchHistory(days:) and throws FluxAPIError.notConfigured for .dateRange+  - HistoryQuery Hashable + Codable round-trip (Codable required: ChartScope is Codable)+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift, Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift++- [x] 5. Implement HistoryQuery and the fetchHistory(query:) client path <!-- id:lftd8w8 -->+  - New Networking/HistoryQuery.swift: enum HistoryQuery: Hashable, Sendable, Codable { case days(Int); case dateRange(start: String, end: String) }+  - Protocol method + default per the fetchStatus(simulateLoadWatts:) evolution pattern so ~30 existing mocks keep compiling+  - URLSessionAPIClient implements the real encoding+  - Blocked-by: lftd8w7 (Write failing FluxCore tests for HistoryQuery and fetchHistory(query:))+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Networking/HistoryQuery.swift, Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift, Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift++- [x] 6. Write HistoryPeriod property-based and unit tests <!-- id:lftd8w9 -->+  - Seeded random dates across DST transitions and month-length edges, T-1361 test style+  - Invariants: next(previous(p)) == p and inverse; contains(start) true and contains(endExclusive) false; week periods always 7 slot days, months 28-31; week(containing: d) identical for every d in the same week (likewise month)+  - dayCount and start/end date strings correct+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [3.1](requirements.md#3.1)+  - References: Flux/Flux/History/HistoryPeriod.swift, Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift++- [x] 7. Implement HistoryPeriod <!-- id:lftd8wa -->+  - New Flux/Flux/History/HistoryPeriod.swift: Sydney-midnight start + endExclusive; week/month factories composing existing DateFormatting helpers+  - previous/next via byAdding .day -7/+7 and .month -1/+1 on start; assert on .days ranges (controls never shown there, silent self-return would hide a wiring bug)+  - contains, dayCount, startDateString/endDateString+  - Blocked-by: lftd8w9 (Write HistoryPeriod property-based and unit tests)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [3.1](requirements.md#3.1)+  - References: Flux/Flux/History/HistoryPeriod.swift, specs/history-period-navigation/design.md++- [x] 8. Write failing HistoryViewModel tests for navigation, resolved snapshot, and cache bounds <!-- id:lftd8wb -->+  - Recording mock asserts the expected HistoryQuery per intent+  - selectRange resets the anchor; reload()/loadHistory never touch it (req 1.8)+  - navigateNext from previous week collapses to anchor nil + .days query; jumpTo a date in the current month gives anchor nil+  - Coalescing honours a navigation issued mid-load (RequestedPeriod key)+  - resolvedQuery/chartDomain reflect rendered data, not an in-flight request+  - loadCachedDays bounded both ends+  - Fetch fail + no cache shows error state; fetch success + zero days + anchor set shows the no-data state (distinct flags)+  - Blocked-by: lftd8w8 (Implement HistoryQuery and the fetchHistory(query:) client path), lftd8wa (Implement HistoryPeriod)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [3.3](requirements.md#3.3), [6.1](requirements.md#6.1), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)+  - References: Flux/Flux/History/HistoryViewModel.swift++- [x] 9. Implement HistoryViewModel intent methods, resolvedQuery, and bounded cache <!-- id:lftd8wc -->+  - periodAnchor: Date? (nil = current); intent methods selectRange/navigatePrevious/navigateNext/jumpTo/returnToCurrent; the load path is anchor-agnostic+  - Coalescing re-keyed on RequestedPeriod (range + anchor)+  - resolvedQuery set inside load() drives chartDomain, resolvedRangeDays, the periodQuery accessor, and the next-chevron disabled state+  - HistoryChartDomain.make parameter now: renamed referenceDate:+  - loadCachedDays(from:through:) with both-ends predicate; current path passes windowStart...today+  - Blocked-by: lftd8wb (Write failing HistoryViewModel tests for navigation, resolved snapshot, and cache bounds)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.6](requirements.md#1.6), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [3.3](requirements.md#3.3), [6.1](requirements.md#6.1), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)+  - References: Flux/Flux/History/HistoryViewModel.swift, Flux/Flux/History/HistoryChartDomain.swift++- [x] 10. Implement HistoryPeriodHeader <!-- id:lftd8wd -->+  - New Flux/Flux/History/HistoryPeriodHeader.swift styled after DayNavigationHeader (DayDetailViewSupport.swift:50): chevrons + centred tappable label+  - File-private Sydney formatters (MMM d, day-only d, MMM yyyy - none exist today)+  - Label opens graphical DatePicker (popover; sheet on compact iOS) with in: ...sydneyTodayEnd and .environment calendar/timeZone set to Sydney (device-calendar rendering would be off by a day on non-Sydney devices)+  - Current button only when viewing past; next chevron visible-but-disabled at the current period+  - Blocked-by: lftd8wc (Implement HistoryViewModel intent methods, resolvedQuery, and bounded cache)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [4.1](requirements.md#4.1)+  - References: Flux/Flux/History/HistoryPeriodHeader.swift, Flux/Flux/DayDetail/DayDetailViewSupport.swift++- [x] 11. Wire HistoryView: header, key handling, reload switches, empty-period notice, stats subtitle <!-- id:lftd8we -->+  - Header between range picker and note row, Wk/Mo only+  - Picker onChange and .task call selectRange; .refreshable and error-state Retry switch to reload()+  - macOS: .focusable() then .onKeyPress(left/right), guarded to Wk/Mo+  - Past period + zero days + no error keeps cards rendered (scaffold reserves the axis) with a compact No-data-for-this-period notice replacing the note row - NOT the replace-everything emptyState+  - HistoryStatsOverviewCard subtitle "N of M days" when summary.dayCount < resolvedRangeDays on a past period (costs-card caption intentionally unchanged)+  - View checks: header hidden for .days ranges; Current button visibility; subtitle; empty-past-period card rendering+  - Blocked-by: lftd8wc (Implement HistoryViewModel intent methods, resolvedQuery, and bounded cache), lftd8wd (Implement HistoryPeriodHeader)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3)+  - References: Flux/Flux/History/HistoryView.swift, Flux/Flux/History/HistoryStatsOverviewCard.swift++- [x] 12. Carry HistoryQuery through the chart-expansion scope and MockFluxAPIClient <!-- id:lftd8wf -->+  - ChartScope.historyRange(days:) becomes historyRange(HistoryQuery) (Codable preserved)+  - ChartSceneObserver fetches via the scope's query; ChartExpansionContent.historyRangeDays derives the day count (.dateRange via inclusiveDayCount)+  - ExpandedChartView default scope .historyRange(.days(defaultHistoryRangeDays))+  - The three History cards take periodQuery from HistoryView instead of deriving the scope from rangeDays+  - MockFluxAPIClient implements fetchHistory(query:) so previews handle .dateRange+  - Update existing expansion tests+  - Blocked-by: lftd8w8 (Implement HistoryQuery and the fetchHistory(query:) client path), lftd8wc (Implement HistoryViewModel intent methods, resolvedQuery, and bounded cache)+  - Stream: 2+  - Requirements: [5.7](requirements.md#5.7)+  - References: Flux/Flux/Charts/Expansion/ChartKind.swift, Flux/Flux/Charts/Expansion/macOS/ChartSceneObserver.swift, Flux/Flux/Charts/Expansion/ChartExpansionContent.swift, Flux/Flux/Charts/Expansion/ExpandedChartView.swift, Flux/Flux/Services/MockFluxAPIClient.swift

Things to double-check

Header shows resolved state while chevrons step requested state.

On a slow connection, two quick prev-taps step two periods back while the label still shows the first. Deliberate consequence of the resolved-snapshot rule and safe (coalescing keys on the requested period), but if it confuses in practice, the fix is a loading affordance on the header — dim the chevrons while isLoading — not a state-model change.

Sydney-midnight clock skew.

If the app's Sydney-today is ahead of the server's at the midnight boundary, a just-computed past range can have end == server-today and 400. The app shows the error state with Retry, which self-heals once either clock ticks over. Worth remembering if a midnight-adjacent error report ever comes in.

ChartScope Codable shape change.

historyRange(days:)historyRange(HistoryQuery) is encoding-incompatible. Verified safe today — scopes live only in the in-memory registry and are never persisted. If a future feature starts persisting scopes (e.g. window restoration), old payloads will fail to decode; the fallback to default scope in ExpandedChartView.resolvedScope covers it, but keep it in mind.

Empty-vs-error state distinction.

An empty past period (success, zero days) renders cards with a compact notice; a failed fetch with no cache renders the error state. Both flags are tested separately — if a future change merges these paths, the tests will object. Verify visually once on device: the notice replaces only the note row, with the axis scaffold intact.