flux branch T-1361/… commits 4 + worktree files 20 touched lines +1539 / -93

Pre-push review: T-1361 history-month-week-to-date

Adds week-to-date and month-to-date ranges to the History screen. Backend bounds widened to 1–31; FluxCore gained Sydney-calendar boundary helpers; the app resolves a new HistoryRange to a day-count at load time.

At a glance

  • Backend: /history?days=N validation moved from an allowlist {7,14,30} to a numeric 1≤N≤31 bounds check; ?days=x still 400s.
  • FluxCore: pure Sydney-calendar helpers — startOfMonth, startOfWeek(firstWeekday:), inclusiveDayCount, and (added in review) windowStartDateString.
  • App: HistoryRange enum resolves to an inclusive day-count at load time; the wire contract, chart axes, and derived stats are untouched.
  • Offline cache fallback is now date-bounded (date ≥ windowStart, ascending) and applied uniformly to all ranges — fixes a latent oldest-vs-newest auto-select bug.
  • Mid-load range switches coalesce to the latest selection, so the picker segment and rendered data never disagree.

Verdict

Ready to push

No correctness defects found across four parallel review agents (reuse, quality, efficiency, spec) plus a design-critic pass. All acceptance criteria are met. Three minor findings were fixed in the working tree: the window-start arithmetic was single-sourced into a FluxCore helper, and tests were added locking the picker order (AC 6.1) and the default 7d range (AC 6.4). FluxCore (274 tests) and the History app suites pass; SwiftLint is clean. The only open item is test depth on AC 5.3's expanded-chart axis scaling, which relies on pre-existing data-adaptive rendering the design scoped as unchanged.

Review findings

6 raised · 3 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What this does

The History screen had three fixed ranges — 7, 14, and 30 days. This adds two more: Wk (“this week so far”) and Mo (“this month so far”). Wk shows the start of the current week through today; Mo shows the 1st of the month through today.

Why it matters

Late in a 31-day month, “30 days” and “this month” are genuinely different day sets. The user can now pick exactly the period they mean instead of mapping it onto a fixed window.

Key concepts

  • To-date range: ends today, starts at the beginning of the week/month, so its length changes day by day (1–31 days).
  • Sydney calendar: data is stored per Sydney calendar day, so the range maths is done in that timezone to stay aligned.
  • First weekday: which day a week begins on — taken from the device locale, not hardcoded.

Changes overview

The selection model went from a bare Int to a HistoryRange enum (.days(Int), .weekToDate, .monthToDate) that resolves to an Int day-count at load time. Everything downstream — chart axes, DerivedState/PeriodSummary, ChartScope.historyRange(days:) — was already data-adaptive and needed no change. The app keeps sending ?days=N; only the backend's accepted range widened.

Implementation approach

Resolution reads two injected providers — nowProvider and a new firstWeekdayProvider (default Calendar.current.firstWeekday) — on every load trigger (.task, .onChange, .refreshable, the macOS refresh action, Retry), so the window is recomputed against the current Sydney “today” each time.

Trade-offs

  • App resolves N (Decision 5) rather than teaching the backend a range vocabulary — smallest change; cost is a both-sides window computation that can be off-by-one across Sydney midnight, the same race fixed ranges already tolerate.
  • Date-bounded offline fallback for all ranges (Decision 6) — one code path; also corrected a latent oldest-vs-newest auto-select bug.

Technical deep dive

  • Calendar isolation: startOfWeek mutates a copy of sydneyCalendar to set firstWeekday, never the shared static let. The weekday is evaluated against the Sydney date; only the firstWeekday value is locale-derived (AC 3.2).
  • DST-safe counting: inclusiveDayCount normalises both ends to Sydney midnight and uses dateComponents([.day]), never elapsed-interval division, so 23/25-hour DST days don't shift the count.
  • Window-start single-sourcing: windowStartDateString(inclusiveDays:now:) is the exact inverse of inclusiveDayCount (a property test asserts the round-trip for N∈{1,2,7,14,28,30,31}) and mirrors the backend's startDate formula, so the offline cache bound provably equals the period start (AC 4.3).
  • Mid-load coalescing: lastRequestedRange is set before the isLoading guard; the loop re-checks it after each fetch. On @MainActor it can't spin and the latest selection always wins.

Architecture impact

The wire contract, ChartScope, chart axes, and the derived-stat path are untouched. resolvedRangeDays is computed once at fetch time and read by every card (data-consistency rule honoured). The expanded chart carries only N, so a chart left expanded across Sydney midnight keeps fetching N days ending on the new today — an explicitly out-of-scope case that self-corrects on reopen.

Potential issues

  • Five-segment picker at large Dynamic Type can truncate on the narrowest iPhones (accepted, AC 6.3).
  • First-frame/mid-load resolvedRangeDays briefly reads the previous count — harmless; only the expansion-scope N is affected for that window.

Important changes — detailed

history.go: allowlist → 1–31 bounds check

diff-history-go.txt

Why it matters. Removes the {7,14,30} ceiling that would clip a 31-day month-to-date; this is the entire backend surface of the feature.

What to look at. internal/api/history.go validation block

Takeaway. Widening an allowlist to a numeric bound keeps the non-numeric guard (err != nil) so malformed input still 400s.
Rationale. Decision 5: the app sends a resolved day-count over the existing ?days=N contract, so the backend only needs to accept the wider range.

HistoryRange: range → day-count resolution

diff-historyrange.txt

Why it matters. The keystone of the design — collapsing the range to an Int N lets all downstream chart/stat code stay unchanged.

What to look at. HistoryRange.resolvedDays(now:firstWeekday:)

Takeaway. Resolving a richer selection to the existing primitive at the boundary avoids touching every downstream consumer.
Rationale. Decision 5: chart axes and DerivedState already adapt to the actual data, so only the count must flow through.

Sydney boundary helpers in FluxCore

diff-dateformatting.txt

Why it matters. Pure, testable date maths kept out of the view model; DST-safe and calendar-isolation-safe.

What to look at. startOfMonth / startOfWeek / inclusiveDayCount / windowStartDateString

Takeaway. Mutate a COPY of a shared Calendar to change firstWeekday; count days via dateComponents, never interval division.
Rationale. Decision 2: boundaries computed on the Sydney calendar to match the stored data; locale supplies only the first weekday.

Date-bounded offline cache fallback

diff-historyviewmodel.txt

Why it matters. Guarantees no pre-period-start day appears offline and fixes the oldest-vs-newest auto-select mismatch with the online path.

What to look at. HistoryViewModel.loadCachedDays(onOrAfter:) + windowStartDateString

Takeaway. A lexicographic >= on zero-padded YYYY-MM-DD equals chronological order, so the bound can live in a #Predicate.
Rationale. Decision 6: one date-bounded path for fixed and to-date ranges; a strict correctness improvement for the fixed ranges too.

Mid-load coalescing loop

diff-historyviewmodel.txt

Why it matters. Prevents a range chosen during an in-flight load from being dropped, keeping the picker and data in sync.

What to look at. HistoryViewModel.loadHistory(range:) guard + while loop

Takeaway. Record the latest request before the isLoading guard, then loop on the in-flight task — latest selection wins without re-entrancy.
Rationale. inferred from the diff and commit body — not a separate decision-log entry. (inferred — not stated by the author)

Key decisions

App resolves the range; backend widens its allowlist (Decision 5)

The app computes the inclusive day-count and reuses ?days=N rather than teaching the backend a range vocabulary. Both sides compute the window, accepting the same seconds-wide Sydney-midnight off-by-one the fixed ranges already tolerate. Smallest change; downstream app code largely untouched.

Sydney calendar for boundaries, device locale for week start (Decision 2)

Day boundaries use the Sydney calendar so the day set matches the Sydney-keyed data; only the week's first weekday is locale-derived (Calendar.current.firstWeekday), evaluated against the Sydney date.

Date-bound the offline cache fallback for all ranges (Decision 6)

Offline reads switched from newest-N-by-count to date ≥ windowStart ascending, uniformly for fixed and to-date ranges. Single path; also corrects a latent bug where the offline auto-selected day was the oldest rather than today.

windowStartDateString hoisted to FluxCore (review)

The today-(N-1) window-start arithmetic, originally inline in HistoryViewModel, was moved to a FluxCore helper during the pre-push review so the formula the app and backend must agree on lives in one tested place. Additive; no behavioural change.

Review findings

SeverityAreaFindingResolution
minorHistoryViewModel.startDateStringWindow-start Sydney arithmetic was hand-rolled in the view model (leaky abstraction), re-deriving the backend's today-(N-1) formula in a second place. Flagged by the reuse, quality, and spec agents.Hoisted into DateFormatting.windowStartDateString(inclusiveDays:now:) with unit + inverse-of-inclusiveDayCount property tests; view model now calls the helper.
minorAC 6.1 / 6.4 coveragePicker order (7d/14d/30d/Wk/Mo) and the default 7d range were only verifiable by reading source — no test locked them.Added rangeOptionsMatchSpecOrder + defaultRangeOptionIsSevenDays (HistoryRangeTests) and defaultRangeResolvesToSevenDays (HistoryViewModelRangeTests).
minordocs/flux-v1.md History sectionThe authoritative product doc still described only the 7/14/30 fixed ranges.Updated the History Layout and Screen 2 sections to describe Wk/Mo and the 1–31 day resolution over the same ?days=N contract.
minorAC 5.3 expanded-chart axis scalingThe resolved-N passthrough into ChartScope.historyRange is tested, but no test renders the expanded chart for a non-{7,14,30} length; relies on pre-existing entries.count-derived axis stride.Left as-is — the rendering code is unchanged by this branch and the design scoped it as already adaptive; noted as a known test-depth limitation.
nitresolvedRangeDays vs lastRequestedRangeSuspected redundant state.Confirmed false positive — resolvedRangeDays captures the now-snapshot used by the fetch; recomputing in the View could disagree with the fetched data, violating the data-consistency rule.
nitloadHistory coalescing loopSuspected spin / missed-update risk.Confirmed false positive — @MainActor serialises mutation to suspension points; each iteration does a real fetch and the latest selection wins. Covered by midLoadRangeSwitchCoalescesToLatest.

Per-file diffs

Click to expand.

internal/api/history.go (+ test) Modified +11 / -8 src
diff --git a/internal/api/history.go b/internal/api/history.goindex a98e5d4..f4803b4 100644--- a/internal/api/history.go+++ b/internal/api/history.go@@ -12,19 +12,18 @@ import ( 	"golang.org/x/sync/errgroup" ) -// validDays is the set of accepted values for the days query parameter.-var validDays = map[int]bool{7: true, 14: true, 30: true}- func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse { 	now := h.nowFunc().In(sydneyTZ) 	today := now.Format("2006-01-02") -	// Parse and validate days parameter (default 7).+	// 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 || !validDays[parsed] {-			return errorResponse(400, "invalid days parameter, must be 7, 14, or 30")+		if err != nil || parsed < 1 || parsed > 31 {+			return errorResponse(400, "invalid days parameter, must be between 1 and 31") 		} 		days = parsed 	}diff --git a/internal/api/history_test.go b/internal/api/history_test.goindex 2c41b34..70dcee8 100644--- a/internal/api/history_test.go+++ b/internal/api/history_test.go@@ -61,23 +61,38 @@ func TestHandleHistoryDefaultDays(t *testing.T) { 	assert.Equal(t, derivedstats.RoundEnergy(10.123), hr.Days[0].Epv) } -func TestHandleHistoryExplicitDays(t *testing.T) {+// TestHandleHistoryDaysValidation covers the widened days bounds (1-31). The+// to-date ranges resolve to any inclusive day-count from 1 through 31, so the+// backend accepts that whole range and rejects anything outside it (or+// non-numeric) with the bounds error message. An absent param defaults to 7.+func TestHandleHistoryDaysValidation(t *testing.T) { 	now := fixedNow()  	tests := map[string]struct {-		days         string-		expectedDays int+		days       string // query value; empty string means the param is absent+		wantStatus int+		// wantDays is the expected inclusive window length when wantStatus is+		// 200; the startDate is now-(wantDays-1).+		wantDays int 	}{-		"14 days": {days: "14", expectedDays: 13},-		"30 days": {days: "30", expectedDays: 29},+		"absent defaults to 7": {days: "", wantStatus: 200, wantDays: 7},+		"one day":              {days: "1", wantStatus: 200, wantDays: 1},+		"seven days":           {days: "7", wantStatus: 200, wantDays: 7},+		"fourteen days":        {days: "14", wantStatus: 200, wantDays: 14},+		"thirty days":          {days: "30", wantStatus: 200, wantDays: 30},+		"thirty-one days":      {days: "31", wantStatus: 200, wantDays: 31},+		"zero rejected":        {days: "0", wantStatus: 400},+		"thirty-two rejected":  {days: "32", wantStatus: 400},+		"negative rejected":    {days: "-1", wantStatus: 400},+		"non-numeric rejected": {days: "x", wantStatus: 400}, 	}  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) { 			mr := &mockReader{ 				queryDailyEnergyFn: func(_ context.Context, _, start, end string) ([]dynamo.DailyEnergyItem, error) {-					expectedStart := now.AddDate(0, 0, -tc.expectedDays).Format("2006-01-02")-					assert.Equal(t, expectedStart, start)+					expectedStart := now.AddDate(0, 0, -(tc.wantDays - 1)).Format("2006-01-02")+					assert.Equal(t, expectedStart, start, "window start should be now-(days-1)") 					assert.Equal(t, now.Format("2006-01-02"), end) 					return []dynamo.DailyEnergyItem{}, nil 				},@@ -86,38 +101,20 @@ func TestHandleHistoryExplicitDays(t *testing.T) { 			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00") 			h.nowFunc = func() time.Time { return now } -			resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": tc.days}))-			require.NoError(t, err)-			assert.Equal(t, 200, resp.StatusCode)-		})-	}-}--func TestHandleHistoryInvalidDays(t *testing.T) {-	now := fixedNow()--	tests := map[string]struct {-		days string-	}{-		"invalid number": {days: "5"},-		"zero":           {days: "0"},-		"negative":       {days: "-1"},-		"non-numeric":    {days: "abc"},-		"too large":      {days: "60"},-	}--	for name, tc := range tests {-		t.Run(name, func(t *testing.T) {-			h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")-			h.nowFunc = func() time.Time { return now }+			var params map[string]string+			if tc.days != "" {+				params = map[string]string{"days": tc.days}+			} -			resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": tc.days}))+			resp, err := h.Handle(context.Background(), historyRequest(params)) 			require.NoError(t, err)-			assert.Equal(t, 400, resp.StatusCode)+			assert.Equal(t, tc.wantStatus, resp.StatusCode) -			var body map[string]string-			require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))-			assert.Equal(t, "invalid days parameter, must be 7, 14, or 30", body["error"])+			if tc.wantStatus == 400 {+				var body map[string]string+				require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+				assert.Equal(t, "invalid days parameter, must be between 1 and 31", body["error"])+			} 		}) 	} }
FluxCore/Helpers/DateFormatting.swift Modified +40
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swiftindex 109227c..5879dfa 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift@@ -103,4 +103,42 @@ public enum DateFormatting {     public static func isToday(_ dateString: String, now: Date = .now) -> Bool {         dateString == todayDateString(now: now)     }++    /// Sydney-calendar 00:00 on the 1st of the month containing `now`.+    public static func startOfMonth(now: Date) -> Date {+        sydneyCalendar.dateInterval(of: .month, for: now)?.start ?? now+    }++    /// Sydney-calendar 00:00 on the week start containing `now`.+    /// `firstWeekday` follows Calendar's convention (1 = Sunday … 7 = Saturday);+    /// only the weekday is locale-driven — the date arithmetic is Sydney.+    ///+    /// Mutates a copy of `sydneyCalendar`, never the shared `static let`, which+    /// would corrupt every other date computation.+    public static func startOfWeek(now: Date, firstWeekday: Int) -> Date {+        var calendar = sydneyCalendar+        calendar.firstWeekday = firstWeekday+        return calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now+    }++    /// Inclusive count of Sydney calendar days from `start` through `end`.+    /// Computed by calendar-day difference (both normalised to Sydney midnight),+    /// never by dividing an elapsed interval, so 23/25-hour DST days don't shift it.+    public static func inclusiveDayCount(from start: Date, through end: Date) -> Int {+        let startMidnight = sydneyCalendar.startOfDay(for: start)+        let endMidnight = sydneyCalendar.startOfDay(for: end)+        let days = sydneyCalendar.dateComponents([.day], from: startMidnight, to: endMidnight).day ?? 0+        return days + 1+    }++    /// Sydney-calendar `YYYY-MM-DD` for the inclusive window start `count` days+    /// back from `now` — i.e. `today-(count-1)`. Single-sources the window-start+    /// formula so the app's offline cache bound matches the backend's+    /// `startDate = now.AddDate(0, 0, -(days-1))`. `count` is an inclusive+    /// day-count (1…31).+    public static func windowStartDateString(inclusiveDays count: Int, now: Date) -> String {+        let today = sydneyCalendar.startOfDay(for: now)+        let start = sydneyCalendar.date(byAdding: .day, value: -(count - 1), to: today) ?? today+        return dayDateString(from: start)+    } }
FluxCore/Tests/DateBoundaryTests.swift Added +308
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateBoundaryTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateBoundaryTests.swiftnew file mode 100644index 0000000..1a19455--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateBoundaryTests.swift@@ -0,0 +1,308 @@+import Foundation+import Testing+@testable import FluxCore++/// Tests for the Sydney-calendar boundary helpers: `startOfMonth`, `startOfWeek`,+/// and `inclusiveDayCount`. All inputs are seeded (never `Date.now`) so results are+/// deterministic regardless of when or where the suite runs.+@Suite struct DateBoundaryTests {+    // MARK: - startOfMonth++    @Test+    func startOfMonthReturnsFirstOfMonthAtSydneyMidnight() {+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 13, minute: 37)+        let start = DateFormatting.startOfMonth(now: now)+        let components = sydneyCalendar.dateComponents(+            [.year, .month, .day, .hour, .minute, .second], from: start+        )++        #expect(components.year == 2026)+        #expect(components.month == 4)+        #expect(components.day == 1)+        #expect(components.hour == 0)+        #expect(components.minute == 0)+        #expect(components.second == 0)+    }++    @Test+    func startOfMonthOnTheFirstReturnsTheSameDay() {+        let now = makeSydneyDate(year: 2026, month: 7, day: 1, hour: 9, minute: 0)+        let start = DateFormatting.startOfMonth(now: now)+        let components = sydneyCalendar.dateComponents([.year, .month, .day], from: start)++        #expect(components.year == 2026)+        #expect(components.month == 7)+        #expect(components.day == 1)+    }++    // MARK: - inclusiveDayCount: month lengths++    /// 28-day month (Feb non-leap), 29-day (Feb leap), 30-day (Apr), 31-day (Jul):+    /// month-to-date on the last day of each month yields the month's length.+    @Test(arguments: [+        (2026, 2, 28, 28),  // Feb 2026 non-leap+        (2024, 2, 29, 29),  // Feb 2024 leap+        (2026, 4, 30, 30),  // Apr 30 days+        (2026, 7, 31, 31)   // Jul 31 days+    ])+    func monthToDateCountOnLastDayEqualsMonthLength(+        year: Int, month: Int, lastDay: Int, expectedCount: Int+    ) {+        let now = makeSydneyDate(year: year, month: month, day: lastDay, hour: 23, minute: 30)+        let start = DateFormatting.startOfMonth(now: now)+        let count = DateFormatting.inclusiveDayCount(from: start, through: now)++        #expect(count == expectedCount)+    }++    @Test+    func monthToDateCountOnTheFirstIsOne() {+        let now = makeSydneyDate(year: 2026, month: 6, day: 1, hour: 0, minute: 1)+        let start = DateFormatting.startOfMonth(now: now)+        let count = DateFormatting.inclusiveDayCount(from: start, through: now)++        #expect(count == 1)+    }++    @Test+    func monthToDateCountMidMonth() {+        let now = makeSydneyDate(year: 2026, month: 6, day: 15, hour: 8, minute: 0)+        let start = DateFormatting.startOfMonth(now: now)+        let count = DateFormatting.inclusiveDayCount(from: start, through: now)++        #expect(count == 15)+    }++    // MARK: - startOfWeek++    /// `firstWeekday` 1 (Sunday), 2 (Monday), 7 (Saturday): the resolved start's+    /// weekday must equal the requested first weekday.+    @Test(arguments: [1, 2, 7])+    func startOfWeekWeekdayMatchesFirstWeekday(firstWeekday: Int) {+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: firstWeekday)+        let weekday = sydneyCalendar.component(.weekday, from: start)++        #expect(weekday == firstWeekday)+    }++    /// 2026-04-15 is a Wednesday (weekday 4). With Monday start (firstWeekday 2)+    /// the week begins on Monday 2026-04-13; the count through Wed is 3.+    @Test+    func weekToDateCountMondayStartMidWeek() {+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: 2)+        let components = sydneyCalendar.dateComponents([.year, .month, .day], from: start)++        #expect(components.year == 2026)+        #expect(components.month == 4)+        #expect(components.day == 13)+        #expect(DateFormatting.inclusiveDayCount(from: start, through: now) == 3)+    }++    /// With Sunday start (firstWeekday 1) the week containing Wed 2026-04-15+    /// begins on Sunday 2026-04-12; the count through Wed is 4.+    @Test+    func weekToDateCountSundayStartMidWeek() {+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: 1)+        let components = sydneyCalendar.dateComponents([.year, .month, .day], from: start)++        #expect(components.day == 12)+        #expect(DateFormatting.inclusiveDayCount(from: start, through: now) == 4)+    }++    /// On the week-start day itself the count is 1. 2026-04-13 is a Monday.+    @Test+    func weekToDateCountOnWeekStartDayIsOne() {+        let now = makeSydneyDate(year: 2026, month: 4, day: 13, hour: 6, minute: 0)+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: 2)+        let count = DateFormatting.inclusiveDayCount(from: start, through: now)++        #expect(count == 1)+    }++    @Test+    func startOfWeekReturnsSydneyMidnight() {+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: 2)+        let components = sydneyCalendar.dateComponents([.hour, .minute, .second], from: start)++        #expect(components.hour == 0)+        #expect(components.minute == 0)+        #expect(components.second == 0)+    }++    /// Mutating a copy of the calendar for the week start must NOT corrupt the+    /// shared `sydneyCalendar.firstWeekday` used by every other computation.+    @Test+    func startOfWeekDoesNotMutateSharedCalendar() {+        let originalFirstWeekday = DateFormatting.sydneyCalendar.firstWeekday+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 12, minute: 0)+        _ = DateFormatting.startOfWeek(now: now, firstWeekday: 7)++        #expect(DateFormatting.sydneyCalendar.firstWeekday == originalFirstWeekday)+    }++    // MARK: - inclusiveDayCount: independent of time-of-day++    /// Same calendar day, different times of day, still counts as 1.+    @Test+    func inclusiveDayCountSameDayIsOne() {+        let start = makeSydneyDate(year: 2026, month: 6, day: 10, hour: 0, minute: 5)+        let end = makeSydneyDate(year: 2026, month: 6, day: 10, hour: 23, minute: 55)++        #expect(DateFormatting.inclusiveDayCount(from: start, through: end) == 1)+    }++    /// Count is driven by calendar day, not by the elapsed interval: an end time+    /// earlier in the day than the start time still spans 2 calendar days.+    @Test+    func inclusiveDayCountIgnoresTimeOfDay() {+        let start = makeSydneyDate(year: 2026, month: 6, day: 10, hour: 23, minute: 0)+        let end = makeSydneyDate(year: 2026, month: 6, day: 11, hour: 1, minute: 0)++        #expect(DateFormatting.inclusiveDayCount(from: start, through: end) == 2)+    }++    // MARK: - DST: 23/25-hour days must not shift the count++    /// Sydney DST ends in early April (clocks back, 25-hour day). A month-to-date+    /// window spanning the transition must yield the same per-day count as any+    /// other day — guards against dividing an elapsed interval.+    @Test+    func aprilDstTransitionDoesNotShiftMonthCount() {+        // 2026: DST ends Sun 2026-04-05 (clocks back, 25-hour day).+        let now = makeSydneyDate(year: 2026, month: 4, day: 6, hour: 12, minute: 0)+        let start = DateFormatting.startOfMonth(now: now)+        // Apr 1 through Apr 6 inclusive = 6 days, despite the 25-hour Apr 5.+        #expect(DateFormatting.inclusiveDayCount(from: start, through: now) == 6)+    }++    /// Sydney DST starts in early October (clocks forward, 23-hour day).+    @Test+    func octoberDstTransitionDoesNotShiftMonthCount() {+        // 2026: DST starts Sun 2026-10-04 (clocks forward, 23-hour day).+        let now = makeSydneyDate(year: 2026, month: 10, day: 7, hour: 12, minute: 0)+        let start = DateFormatting.startOfMonth(now: now)+        // Oct 1 through Oct 7 inclusive = 7 days, despite the 23-hour Oct 4.+        #expect(DateFormatting.inclusiveDayCount(from: start, through: now) == 7)+    }++    /// A week window crossing the April DST end yields the same count as a normal+    /// week. Sun 2026-04-05 is the DST-end day; with Sunday start the week begins+    /// on 2026-04-05 and through Sat 2026-04-11 is 7 days.+    @Test+    func dstWeekCountMatchesNormalWeek() {+        let now = makeSydneyDate(year: 2026, month: 4, day: 11, hour: 12, minute: 0)+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: 1)+        #expect(DateFormatting.inclusiveDayCount(from: start, through: now) == 7)+    }++    // MARK: - Property tests++    /// Seeded dates spanning leap/non-leap, month edges, and both DST transitions.+    /// Never `Date.now` — the suite must be deterministic.+    static let seedDates: [Date] = {+        let calendar = makeSydneyCalendar()+        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+            (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+            (2026, 10, 31, 18, 0),  // Oct 31 (31-day month last day)+            (2026, 12, 31, 23, 0),  // Dec 31 (year edge)+            (2027, 7, 14, 9, 0)     // arbitrary future day+        ]+        return seeds.map { year, month, day, hour, minute in+            calendar.date(from: DateComponents(+                timeZone: DateFormatting.sydneyTimeZone,+                year: year, month: month, day: day, hour: hour, minute: minute+            ))!+        }+    }()++    @Test(arguments: seedDates, 1 ... 7)+    func weekToDateInvariantsHold(now: Date, firstWeekday: Int) {+        let start = DateFormatting.startOfWeek(now: now, firstWeekday: firstWeekday)+        let count = DateFormatting.inclusiveDayCount(from: start, through: now)+        let weekday = sydneyCalendar.component(.weekday, from: start)++        #expect(weekday == firstWeekday)+        #expect(start <= now)+        #expect(count >= 1)+        #expect(count <= 7)+    }++    @Test(arguments: seedDates)+    func monthToDateInvariantsHold(now: Date) {+        let start = DateFormatting.startOfMonth(now: now)+        let count = DateFormatting.inclusiveDayCount(from: start, through: now)+        let day = sydneyCalendar.component(.day, from: start)++        #expect(day == 1)+        #expect(start <= now)+        #expect(count >= 1)+        #expect(count <= 31)+    }++    // MARK: - windowStartDateString++    @Test+    func windowStartDateStringMatchesInclusiveWindow() {+        // 7 inclusive days ending 2026-04-15 → today-(7-1) = 2026-04-09.+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 13, minute: 37)+        #expect(DateFormatting.windowStartDateString(inclusiveDays: 7, now: now) == "2026-04-09")+    }++    @Test+    func windowStartDateStringForSingleDayIsToday() {+        // A 1-day window is today itself, even just after Sydney midnight.+        let now = makeSydneyDate(year: 2026, month: 4, day: 1, hour: 0, minute: 5)+        #expect(DateFormatting.windowStartDateString(inclusiveDays: 1, now: now) == "2026-04-01")+    }++    @Test+    func windowStartDateStringCrossesMonthBoundary() {+        // 5 inclusive days ending 2026-03-03 → 2026-02-27.+        let now = makeSydneyDate(year: 2026, month: 3, day: 3, hour: 9, minute: 0)+        #expect(DateFormatting.windowStartDateString(inclusiveDays: 5, now: now) == "2026-02-27")+    }++    /// The window-start string is the exact inverse of `inclusiveDayCount`:+    /// for any resolved count N, counting from the window start back to `now`+    /// must return N again. Guards the app/backend window-agreement contract.+    @Test(arguments: [1, 2, 7, 14, 28, 30, 31])+    func windowStartDateStringInvertsInclusiveDayCount(count: Int) {+        let now = makeSydneyDate(year: 2026, month: 5, day: 20, hour: 8, minute: 0)+        let startString = DateFormatting.windowStartDateString(inclusiveDays: count, now: now)+        let start = DateFormatting.parseDayDate(startString)!+        #expect(DateFormatting.inclusiveDayCount(from: start, through: now) == count)+    }++    // MARK: - Helpers++    private var sydneyCalendar: Calendar { Self.makeSydneyCalendar() }++    private static func makeSydneyCalendar() -> Calendar {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = DateFormatting.sydneyTimeZone+        return calendar+    }++    private func makeSydneyDate(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {+        sydneyCalendar.date(from: DateComponents(+            timeZone: DateFormatting.sydneyTimeZone,+            year: year,+            month: month,+            day: day,+            hour: hour,+            minute: minute+        ))!+    }+}
Flux/History/HistoryRange.swift Added +41
diff --git a/Flux/Flux/History/HistoryRange.swift b/Flux/Flux/History/HistoryRange.swiftnew file mode 100644index 0000000..543fce1--- /dev/null+++ b/Flux/Flux/History/HistoryRange.swift@@ -0,0 +1,41 @@+import FluxCore+import Foundation++/// A History range selection. Fixed `.days` cases carry their length directly;+/// the to-date cases resolve to an inclusive day-count ending on `now`,+/// computed against the Sydney calendar with a locale-derived first weekday.+enum HistoryRange: Hashable {+    case days(Int) // 7, 14, 30+    case weekToDate+    case monthToDate++    /// The only UI string for this range. The boundary math is delegated to+    /// the FluxCore helpers.+    var pickerLabel: String {+        switch self {+        case let .days(count):+            return "\(count)d"+        case .weekToDate:+            return "Wk"+        case .monthToDate:+            return "Mo"+        }+    }++    /// Inclusive day-count ending on `now` (1…31). Fixed cases return their N;+    /// to-date cases resolve via the Sydney-calendar FluxCore helpers.+    /// `firstWeekday` follows Calendar's convention (1 = Sunday … 7 = Saturday)+    /// and is consulted only for `weekToDate`.+    func resolvedDays(now: Date, firstWeekday: Int) -> Int {+        switch self {+        case let .days(count):+            return count+        case .weekToDate:+            let start = DateFormatting.startOfWeek(now: now, firstWeekday: firstWeekday)+            return DateFormatting.inclusiveDayCount(from: start, through: now)+        case .monthToDate:+            let start = DateFormatting.startOfMonth(now: now)+            return DateFormatting.inclusiveDayCount(from: start, through: now)+        }+    }+}
Flux/History/HistoryViewModel.swift Modified +57 / -8
diff --git a/Flux/Flux/History/HistoryViewModel.swift b/Flux/Flux/History/HistoryViewModel.swiftindex 069de6a..af9ad19 100644--- a/Flux/Flux/History/HistoryViewModel.swift+++ b/Flux/Flux/History/HistoryViewModel.swift@@ -9,12 +9,17 @@ final class HistoryViewModel {     private(set) var selectedDay: DayEnergy?     private(set) var isLoading = false     private(set) var error: FluxAPIError?-    private(set) var lastRequestedDays: Int = 7+    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      private let apiClient: any FluxAPIClient     private let modelContext: ModelContext     private let pricingService: PricingService     private let nowProvider: @Sendable () -> Date+    private let firstWeekdayProvider: @Sendable () -> Int     private let warn: (String) -> Void      init(@@ -22,12 +27,14 @@ final class HistoryViewModel {         modelContext: ModelContext,         pricingService: PricingService = .shared,         nowProvider: @escaping @Sendable () -> Date = { .now },+        firstWeekdayProvider: @escaping @Sendable () -> Int = { Calendar.current.firstWeekday },         warn: @escaping (String) -> Void = HistoryCacheLog.defaultWarn     ) {         self.apiClient = apiClient         self.modelContext = modelContext         self.pricingService = pricingService         self.nowProvider = nowProvider+        self.firstWeekdayProvider = firstWeekdayProvider         self.warn = warn     } @@ -44,21 +51,40 @@ final class HistoryViewModel {         try? await pricingService.refresh()     } -    func loadHistory(days requestedDays: Int) async {+    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.+        lastRequestedRange = range         guard !isLoading else { return } -        lastRequestedDays = requestedDays         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.+        var loadedRange = range+        while true {+            await load(loadedRange)+            if lastRequestedRange == loadedRange { break }+            loadedRange = lastRequestedRange+        }+    }++    private func load(_ range: HistoryRange) async {+        let now = nowProvider()+        let resolvedDays = range.resolvedDays(now: now, firstWeekday: firstWeekdayProvider())+        resolvedRangeDays = resolvedDays+         do {-            let response = try await apiClient.fetchHistory(days: requestedDays)+            let response = try await apiClient.fetchHistory(days: resolvedDays)             days = response.days             error = nil             selectDefaultDayIfNeeded()             try cacheHistoricalDays(response.days)         } catch {-            let fallbackDays = loadCachedDays(limit: requestedDays)+            let startDate = DateFormatting.windowStartDateString(inclusiveDays: resolvedDays, now: now)+            let fallbackDays = loadCachedDays(onOrAfter: startDate)             if fallbackDays.isEmpty {                 self.error = FluxAPIError.from(error)                 days = []@@ -76,7 +102,7 @@ final class HistoryViewModel {     }      func reload() async {-        await loadHistory(days: lastRequestedDays)+        await loadHistory(range: lastRequestedRange)     }      private func cacheHistoricalDays(_ dayEnergies: [DayEnergy]) throws {@@ -136,11 +162,20 @@ final class HistoryViewModel {         }     } -    private func loadCachedDays(limit: Int) -> [DayEnergy] {-        var descriptor = FetchDescriptor<CachedDayEnergy>(-            sortBy: [SortDescriptor(\CachedDayEnergy.date, order: .reverse)]+    /// 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.+        let lowerBound = startDate+        let descriptor = FetchDescriptor<CachedDayEnergy>(+            predicate: #Predicate<CachedDayEnergy> { cached in+                cached.date >= lowerBound+            },+            sortBy: [SortDescriptor(\CachedDayEnergy.date, order: .forward)]         )-        descriptor.fetchLimit = limit          guard let cachedDays = try? modelContext.fetch(descriptor), !cachedDays.isEmpty else {             return []
Flux/History/HistoryView.swift Modified +14 / -11
diff --git a/Flux/Flux/History/HistoryView.swift b/Flux/Flux/History/HistoryView.swiftindex ebb4a94..0aad75d 100644--- a/Flux/Flux/History/HistoryView.swift+++ b/Flux/Flux/History/HistoryView.swift@@ -4,9 +4,12 @@ import SwiftUI  // swiftlint:disable type_body_length struct HistoryView: View {+    /// Range control segments, in the order required by the spec ([6.1]).+    static let rangeOptions: [HistoryRange] = [.days(7), .days(14), .days(30), .weekToDate, .monthToDate]+     @Environment(\.horizontalSizeClass) private var hSizeClass     @State private var viewModel: HistoryViewModel-    @State private var selectedRange: Int = 7+    @State private var selectedRange: HistoryRange = .days(7)     @State private var showingSettings = false      private let makeDayDetailViewModel: (String) -> DayDetailViewModel@@ -57,9 +60,9 @@ struct HistoryView: View {                 }                  Picker("Range", selection: $selectedRange) {-                    Text("7d").tag(7)-                    Text("14d").tag(14)-                    Text("30d").tag(30)+                    ForEach(HistoryView.rangeOptions, id: \.self) { range in+                        Text(range.pickerLabel).tag(range)+                    }                 }                 .pickerStyle(.segmented) @@ -99,13 +102,13 @@ struct HistoryView: View {             }         }         .task {-            async let history: Void = viewModel.loadHistory(days: selectedRange)+            async let history: Void = viewModel.loadHistory(range: selectedRange)             async let pricing: Void = viewModel.refreshPricing()             _ = await (history, pricing)         }         .onChange(of: selectedRange) { _, newRange in             Task {-                async let history: Void = viewModel.loadHistory(days: newRange)+                async let history: Void = viewModel.loadHistory(range: newRange)                 async let pricing: Void = viewModel.refreshPricing()                 _ = await (history, pricing)             }@@ -116,7 +119,7 @@ struct HistoryView: View {         }         #endif         .refreshable {-            await viewModel.loadHistory(days: selectedRange)+            await viewModel.loadHistory(range: selectedRange)         }         #if !os(macOS)         .sheet(isPresented: $showingSettings) {@@ -193,7 +196,7 @@ struct HistoryView: View {             entries: derived.solar,             summary: derived.summary,             selectedDate: selectedDate,-            rangeDays: selectedRange,+            rangeDays: viewModel.resolvedRangeDays,             onSelect: selectDay         )     }@@ -204,7 +207,7 @@ struct HistoryView: View {             entries: derived.grid,             summary: derived.summary,             selectedDate: selectedDate,-            rangeDays: selectedRange,+            rangeDays: viewModel.resolvedRangeDays,             onSelect: selectDay         )     }@@ -215,7 +218,7 @@ struct HistoryView: View {             entries: derived.dailyUsage,             summary: derived.summary,             selectedDate: selectedDate,-            rangeDays: selectedRange,+            rangeDays: viewModel.resolvedRangeDays,             onSelect: selectDay         )     }@@ -286,7 +289,7 @@ struct HistoryView: View {                 .foregroundStyle(.secondary)             HStack {                 Button("Retry") {-                    Task { await viewModel.loadHistory(days: selectedRange) }+                    Task { await viewModel.loadHistory(range: selectedRange) }                 }                 .buttonStyle(.borderedProminent) 
Flux/FluxTests/History*Tests.swift Added/Modified +820
diff --git a/Flux/FluxTests/HistoryRangeConsistencyTests.swift b/Flux/FluxTests/HistoryRangeConsistencyTests.swiftnew file mode 100644index 0000000..8f9180d--- /dev/null+++ b/Flux/FluxTests/HistoryRangeConsistencyTests.swift@@ -0,0 +1,133 @@+import FluxCore+import Foundation+import SwiftData+import Testing+@testable import Flux++/// 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.+@MainActor @Suite(.serialized)+struct HistoryRangeConsistencyTests {+    private static let sampleDays: [DayEnergy] = [+        DayEnergy(+            date: "2026-04-08", epv: 7.2, eInput: 4.0, eOutput: 1.2, eCharge: 3.0, eDischarge: 2.5,+            offpeakGridImportKwh: 2.0, offpeakGridExportKwh: 0.4+        ),+        DayEnergy(+            date: "2026-04-09", epv: 6.1, eInput: 3.5, eOutput: 0.9, eCharge: 2.8, eDischarge: 2.2,+            offpeakGridImportKwh: 1.5, offpeakGridExportKwh: 0.3+        ),+        DayEnergy(+            date: "2026-04-12", epv: 5.0, eInput: 2.0, eOutput: 0.5, eCharge: 2.0, eDischarge: 1.8,+            offpeakGridImportKwh: 1.0, offpeakGridExportKwh: 0.2+        )+    ]++    @Test+    func derivedStateIsIdenticalRegardlessOfProducingRange() async throws {+        // 2026-04-12 is a Sunday. With Monday-start (firstWeekday = 2) the+        // week begins 2026-04-06, so week-to-date resolves to 7 — identical to+        // .days(7). The same day set therefore reaches both view models.+        let now = makeUTCDate(year: 2026, month: 4, day: 12, hour: 2, minute: 0)++        let fixedVM = makeViewModel(now: now)+        await fixedVM.loadHistory(range: .days(7))++        let weekVM = makeViewModel(now: now)+        await weekVM.loadHistory(range: .weekToDate)++        // Same resolved count.+        #expect(fixedVM.resolvedRangeDays == 7)+        #expect(weekVM.resolvedRangeDays == 7)++        // Identical derived state — the producing range is irrelevant ([5.2]).+        #expect(fixedVM.derived.summary == weekVM.derived.summary)+        #expect(fixedVM.derived.solar == weekVM.derived.solar)+        #expect(fixedVM.derived.grid == weekVM.derived.grid)+        #expect(fixedVM.derived.battery == weekVM.derived.battery)+        #expect(fixedVM.derived.dailyUsage == weekVM.derived.dailyUsage)+    }++    @Test+    func derivedStateMatchesDirectConstructionFromSameDays() {+        // The card-building path is `DerivedState(days:now:)`; an identical+        // array yields an identical state regardless of which range produced it.+        let now = makeUTCDate(year: 2026, month: 4, day: 12, hour: 2, minute: 0)+        let viaFixed = HistoryViewModel.DerivedState(days: Self.sampleDays, now: now)+        let viaWeek = HistoryViewModel.DerivedState(days: Self.sampleDays, now: now)+        #expect(viaFixed.summary == viaWeek.summary)+    }++    @Test+    func toDateSelectionFeedsResolvedCountToExpansionScope() async throws {+        // 2026-04-12 Sunday, Monday-start → month-to-date = 12 (days 1…12).+        let now = makeUTCDate(year: 2026, month: 4, day: 12, hour: 2, minute: 0)+        let viewModel = makeViewModel(now: now)+        await viewModel.loadHistory(range: .monthToDate)++        #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]).+        let derived = viewModel.derived+        let solar = HistorySolarCard(+            entries: derived.solar, summary: derived.summary,+            selectedDate: nil, rangeDays: viewModel.resolvedRangeDays, onSelect: { _ in }+        )+        let grid = HistoryGridUsageCard(+            entries: derived.grid, summary: derived.summary,+            selectedDate: nil, rangeDays: viewModel.resolvedRangeDays, onSelect: { _ in }+        )+        let usage = HistoryDailyUsageCard(+            entries: derived.dailyUsage, summary: derived.summary,+            selectedDate: nil, rangeDays: viewModel.resolvedRangeDays, onSelect: { _ in }+        )++        #expect(solar.expansionScope == .historyRange(days: 12))+        #expect(grid.expansionScope == .historyRange(days: 12))+        #expect(usage.expansionScope == .historyRange(days: 12))+    }++    // MARK: - Helpers++    private func makeViewModel(now: Date) -> HistoryViewModel {+        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+        // swiftlint:disable:next force_try+        let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+        let apiClient = StubHistoryAPIClient(days: Self.sampleDays)+        return HistoryViewModel(+            apiClient: apiClient,+            modelContext: ModelContext(container),+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )+    }++    private func makeUTCDate(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {+        let calendar = Calendar(identifier: .gregorian)+        return calendar.date(from: DateComponents(+            timeZone: TimeZone(secondsFromGMT: 0),+            year: year,+            month: month,+            day: day,+            hour: hour,+            minute: minute+        ))!+    }+}++/// Returns the same day set regardless of the requested count, so the producing+/// range is the only difference under test.+private final class StubHistoryAPIClient: FluxAPIClient, @unchecked Sendable {+    private let days: [DayEnergy]++    init(days: [DayEnergy]) { self.days = days }++    func fetchStatus() async throws -> StatusResponse { throw FluxAPIError.notConfigured }+    func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: days) }+    func fetchDay(date _: String) async throws -> DayDetailResponse { throw FluxAPIError.notConfigured }+    func saveNote(date _: String, text _: String) async throws -> NoteResponse { throw FluxAPIError.notConfigured }+}diff --git a/Flux/FluxTests/HistoryRangeTests.swift b/Flux/FluxTests/HistoryRangeTests.swiftnew file mode 100644index 0000000..5a7dc20--- /dev/null+++ b/Flux/FluxTests/HistoryRangeTests.swift@@ -0,0 +1,113 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@Suite+struct HistoryRangeTests {+    // MARK: - pickerLabel++    @Test+    func pickerLabelsMatchSpec() {+        #expect(HistoryRange.days(7).pickerLabel == "7d")+        #expect(HistoryRange.days(14).pickerLabel == "14d")+        #expect(HistoryRange.days(30).pickerLabel == "30d")+        #expect(HistoryRange.weekToDate.pickerLabel == "Wk")+        #expect(HistoryRange.monthToDate.pickerLabel == "Mo")+    }++    // MARK: - .days passthrough++    @Test+    func fixedDaysResolvePassthrough() {+        // now/firstWeekday must not influence a fixed range.+        let now = makeSydneyMidday(year: 2026, month: 4, day: 15)+        #expect(HistoryRange.days(7).resolvedDays(now: now, firstWeekday: 1) == 7)+        #expect(HistoryRange.days(14).resolvedDays(now: now, firstWeekday: 2) == 14)+        #expect(HistoryRange.days(30).resolvedDays(now: now, firstWeekday: 7) == 30)+    }++    // MARK: - monthToDate++    @Test+    func monthToDateOnFirstOfMonthResolvesToOne() {+        let now = makeSydneyMidday(year: 2026, month: 4, day: 1)+        #expect(HistoryRange.monthToDate.resolvedDays(now: now, firstWeekday: 2) == 1)+    }++    @Test+    func monthToDateMidMonthResolvesToInclusiveCount() {+        // 15th of the month → days 1…15 inclusive = 15.+        let now = makeSydneyMidday(year: 2026, month: 4, day: 15)+        #expect(HistoryRange.monthToDate.resolvedDays(now: now, firstWeekday: 2) == 15)+    }++    @Test+    func monthToDateOn31stResolvesTo31() {+        // March has 31 days → full month-to-date is 31, never clipped to 30.+        let now = makeSydneyMidday(year: 2026, month: 3, day: 31)+        #expect(HistoryRange.monthToDate.resolvedDays(now: now, firstWeekday: 2) == 31)+    }++    // MARK: - weekToDate++    @Test+    func weekToDateOnWeekStartDayResolvesToOne() {+        // 2026-04-13 is a Monday. With firstWeekday = 2 (Monday) the week+        // starts today, so the inclusive count is 1.+        let now = makeSydneyMidday(year: 2026, month: 4, day: 13)+        #expect(HistoryRange.weekToDate.resolvedDays(now: now, firstWeekday: 2) == 1)+    }++    @Test+    func weekToDateMidWeekMondayStart() {+        // 2026-04-15 is a Wednesday. Monday-start week → Mon, Tue, Wed = 3.+        let now = makeSydneyMidday(year: 2026, month: 4, day: 15)+        #expect(HistoryRange.weekToDate.resolvedDays(now: now, firstWeekday: 2) == 3)+    }++    @Test+    func weekToDateMidWeekSundayStart() {+        // 2026-04-15 is a Wednesday. Sunday-start week begins 2026-04-12 →+        // Sun, Mon, Tue, Wed = 4.+        let now = makeSydneyMidday(year: 2026, month: 4, day: 15)+        #expect(HistoryRange.weekToDate.resolvedDays(now: now, firstWeekday: 1) == 4)+    }++    @Test+    func weekToDateOnSundayWithSundayStartResolvesToOne() {+        // 2026-04-12 is a Sunday. Sunday-start week starts today → 1.+        let now = makeSydneyMidday(year: 2026, month: 4, day: 12)+        #expect(HistoryRange.weekToDate.resolvedDays(now: now, firstWeekday: 1) == 1)+    }++    // MARK: - Range control presentation++    @Test+    func rangeOptionsMatchSpecOrder() {+        // [6.1] The segmented control presents exactly 7d, 14d, 30d, Wk, Mo in+        // this order; locking it guards against accidental reordering.+        #expect(HistoryView.rangeOptions == [.days(7), .days(14), .days(30), .weekToDate, .monthToDate])+    }++    @Test+    func defaultRangeOptionIsSevenDays() {+        // [6.4] The default selected range is 7d.+        #expect(HistoryView.rangeOptions.first == .days(7))+    }++    // MARK: - Helpers++    /// A `Date` at 12:00 Sydney time on the given calendar date, so it sits+    /// unambiguously inside the intended Sydney day regardless of UTC offset.+    private func makeSydneyMidday(year: Int, month: Int, day: Int) -> Date {+        DateFormatting.sydneyCalendar.date(from: DateComponents(+            timeZone: DateFormatting.sydneyTimeZone,+            year: year,+            month: month,+            day: day,+            hour: 12,+            minute: 0+        ))!+    }+}diff --git a/Flux/FluxTests/HistoryStatsOverviewCardTests.swift b/Flux/FluxTests/HistoryStatsOverviewCardTests.swiftindex 7c95a4c..2542d66 100644--- a/Flux/FluxTests/HistoryStatsOverviewCardTests.swift+++ b/Flux/FluxTests/HistoryStatsOverviewCardTests.swift@@ -202,7 +202,7 @@ struct HistoryStatsOverviewCardTests {         )         let apiClient = StaticHistoryClient(days: [today])         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext)-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let action = HistoryStatsOverviewCard.tapAction(             for: .lowestSoc, summary: viewModel.summary,diff --git a/Flux/FluxTests/HistoryViewModelCacheUpsertTests.swift b/Flux/FluxTests/HistoryViewModelCacheUpsertTests.swiftindex 72428e5..768c240 100644--- a/Flux/FluxTests/HistoryViewModelCacheUpsertTests.swift+++ b/Flux/FluxTests/HistoryViewModelCacheUpsertTests.swift@@ -38,7 +38,7 @@ struct HistoryViewModelCacheUpsertTests {             nowProvider: { now },             warn: { sink.record($0) }         )-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let cached = try modelContext.fetch(FetchDescriptor<CachedDayEnergy>())         let row = try #require(cached.first)@@ -80,7 +80,7 @@ struct HistoryViewModelCacheUpsertTests {             nowProvider: { now },             warn: { sink.record($0) }         )-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let cached = try modelContext.fetch(FetchDescriptor<CachedDayEnergy>())         let row = try #require(cached.first)diff --git a/Flux/FluxTests/HistoryViewModelDailyUsageTests.swift b/Flux/FluxTests/HistoryViewModelDailyUsageTests.swiftindex 6eee03f..a0461ae 100644--- a/Flux/FluxTests/HistoryViewModelDailyUsageTests.swift+++ b/Flux/FluxTests/HistoryViewModelDailyUsageTests.swift@@ -18,7 +18,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let entries = viewModel.derived.dailyUsage         #expect(entries.count == 1)@@ -44,7 +44,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let entry = try #require(viewModel.derived.dailyUsage.first)         #expect(entry.blocks.map(\.kind) == DailyUsageBlock.Kind.chronologicalOrder)@@ -63,7 +63,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let entry = try #require(viewModel.derived.dailyUsage.first)         #expect(entry.blocks.map(\.kind) == [.night, .evening])@@ -85,7 +85,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let entries = viewModel.derived.dailyUsage         #expect(entries.count == 1)@@ -103,7 +103,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let derived = viewModel.derived         #expect(derived.dailyUsage.isEmpty)@@ -125,7 +125,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let derived = viewModel.derived         #expect(derived.dailyUsage.count == 1)@@ -148,7 +148,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.derived.dailyUsage.map(\.dayID) == ["2026-04-13"])     }@@ -170,7 +170,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let derived = viewModel.derived         #expect(derived.dailyUsage.count == 2)@@ -197,7 +197,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let entry = try #require(viewModel.derived.dailyUsage.first)         let night = try #require(entry.blocks.first(where: { $0.kind == .night }))@@ -232,7 +232,7 @@ struct HistoryViewModelDailyUsageTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.derived.summary.dailyUsageLargestKind == .night)     }diff --git a/Flux/FluxTests/HistoryViewModelRangeTests.swift b/Flux/FluxTests/HistoryViewModelRangeTests.swiftnew file mode 100644index 0000000..1f291aa--- /dev/null+++ b/Flux/FluxTests/HistoryViewModelRangeTests.swift@@ -0,0 +1,294 @@+import FluxCore+import Foundation+import SwiftData+import Testing+@testable import Flux++@MainActor @Suite(.serialized)+struct HistoryViewModelRangeTests {+    // MARK: - Default selection++    @Test+    func defaultRangeResolvesToSevenDays() throws {+        // [6.4] Before any load, the view model defaults to the 7d range.+        let modelContext = try makeModelContext()+        let viewModel = HistoryViewModel(apiClient: RecordingHistoryAPIClient(), modelContext: modelContext)++        #expect(viewModel.lastRequestedRange == .days(7))+        #expect(viewModel.resolvedRangeDays == 7)+    }++    // MARK: - Wk / Mo resolution++    @Test+    func monthToDateResolvesInclusiveDayCount() async throws {+        let modelContext = try makeModelContext()+        let apiClient = RecordingHistoryAPIClient()+        apiClient.historyResult = .success(HistoryResponse(days: []))++        // 02:00 UTC 2026-04-15 = 12:00 AEST 2026-04-15, so Sydney "today" is the+        // 15th and month-to-date is days 1…15 = 15.+        let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 2, minute: 0)+        let viewModel = HistoryViewModel(+            apiClient: apiClient,+            modelContext: modelContext,+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )++        await viewModel.loadHistory(range: .monthToDate)++        #expect(viewModel.resolvedRangeDays == 15)+        #expect(apiClient.requestedDays == [15])+    }++    @Test+    func weekToDateResolvesUsingInjectedFirstWeekday() async throws {+        let modelContext = try makeModelContext()+        let apiClient = RecordingHistoryAPIClient()+        apiClient.historyResult = .success(HistoryResponse(days: []))++        // 2026-04-15 is a Wednesday; Monday-start week → Mon/Tue/Wed = 3.+        let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 2, minute: 0)+        let viewModel = HistoryViewModel(+            apiClient: apiClient,+            modelContext: modelContext,+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )++        await viewModel.loadHistory(range: .weekToDate)++        #expect(viewModel.resolvedRangeDays == 3)+        #expect(apiClient.requestedDays == [3])+    }++    // MARK: - reload re-resolves across midnight++    @Test+    func reloadReResolvesAfterNowAdvancesPastSydneyMidnight() async throws {+        let modelContext = try makeModelContext()+        let apiClient = RecordingHistoryAPIClient()+        apiClient.historyResult = .success(HistoryResponse(days: []))++        // Mutable now: starts on the 1st (month-to-date = 1), then advances to+        // the 2nd (month-to-date = 2) before reload().+        let nowBox = NowBox(makeUTCDate(year: 2026, month: 4, day: 1, hour: 2, minute: 0))+        let viewModel = HistoryViewModel(+            apiClient: apiClient,+            modelContext: modelContext,+            nowProvider: { nowBox.value },+            firstWeekdayProvider: { 2 }+        )++        await viewModel.loadHistory(range: .monthToDate)+        #expect(viewModel.resolvedRangeDays == 1)++        nowBox.value = makeUTCDate(year: 2026, month: 4, day: 2, hour: 2, minute: 0)+        await viewModel.reload()++        #expect(viewModel.resolvedRangeDays == 2)+        #expect(apiClient.requestedDays == [1, 2])+    }++    // MARK: - Date-bounded offline fallback++    @Test+    func offlineFallbackExcludesPreStartDaysAndAutoSelectsNewest() async throws {+        let modelContext = try makeModelContext()+        // Sydney "today" = 2026-04-15; a 7-day window starts 2026-04-09.+        // 2026-04-07 is before the start and must be excluded; the gap on+        // 2026-04-12/13 must not pull in older days.+        for date in ["2026-04-07", "2026-04-10", "2026-04-11", "2026-04-14"] {+            modelContext.insert(CachedDayEnergy(from: DayEnergy(+                date: date, epv: 5.0, eInput: 1.0, eOutput: 0.3, eCharge: 1.5, eDischarge: 2.0+            )))+        }+        try modelContext.save()++        let apiClient = RecordingHistoryAPIClient()+        apiClient.historyResult = .failure(FluxAPIError.networkError("offline"))++        let now = makeUTCDate(year: 2026, month: 4, day: 14, hour: 18, minute: 0)+        let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })++        await viewModel.loadHistory(range: .days(7))++        #expect(viewModel.days.map(\.date) == ["2026-04-10", "2026-04-11", "2026-04-14"])+        #expect(viewModel.error == nil)+        // Ascending order means the newest day is last and is auto-selected.+        #expect(viewModel.selectedDay?.date == "2026-04-14")+    }++    @Test+    func offlineFallbackBoundedByWeekStart() async throws {+        let modelContext = try makeModelContext()+        // Sydney "today" = 2026-04-15 (Wed). Monday-start week begins 2026-04-13,+        // so only rows on/after 2026-04-13 may appear.+        for date in ["2026-04-11", "2026-04-13", "2026-04-15"] {+            modelContext.insert(CachedDayEnergy(from: DayEnergy(+                date: date, epv: 5.0, eInput: 1.0, eOutput: 0.3, eCharge: 1.5, eDischarge: 2.0+            )))+        }+        try modelContext.save()++        let apiClient = RecordingHistoryAPIClient()+        apiClient.historyResult = .failure(FluxAPIError.networkError("offline"))++        let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 2, minute: 0)+        let viewModel = HistoryViewModel(+            apiClient: apiClient,+            modelContext: modelContext,+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )++        await viewModel.loadHistory(range: .weekToDate)++        #expect(viewModel.days.map(\.date) == ["2026-04-13", "2026-04-15"])+        #expect(viewModel.selectedDay?.date == "2026-04-15")+    }++    // MARK: - Coalescing++    @Test+    func midLoadRangeSwitchCoalescesToLatest() async throws {+        let modelContext = try makeModelContext()+        let apiClient = GatedHistoryAPIClient()+        apiClient.historyResult = .success(HistoryResponse(days: []))++        let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 2, minute: 0)+        let viewModel = HistoryViewModel(+            apiClient: apiClient,+            modelContext: modelContext,+            nowProvider: { now },+            firstWeekdayProvider: { 2 }+        )++        // First load parks inside fetchHistory.+        let firstLoad = Task { await viewModel.loadHistory(range: .days(7)) }+        await apiClient.waitForFirstFetch()++        // A newer selection arrives during the in-flight load. It returns early+        // at the isLoading guard but records lastRequestedRange = .days(30).+        await viewModel.loadHistory(range: .days(30))++        // Release the parked fetch; the loop then re-resolves to .days(30).+        apiClient.releaseFirstFetch()+        await firstLoad.value++        #expect(viewModel.resolvedRangeDays == 30)+        #expect(apiClient.requestedDays == [7, 30])+    }++    // MARK: - Failure with empty cache++    @Test+    func failedFetchWithEmptyCacheYieldsErrorAndEmptyDays() async throws {+        let modelContext = try makeModelContext()+        let apiClient = RecordingHistoryAPIClient()+        apiClient.historyResult = .failure(FluxAPIError.serverError)++        let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 2, minute: 0)+        let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })++        await viewModel.loadHistory(range: .monthToDate)++        #expect(viewModel.days.isEmpty)+        #expect(viewModel.error == .serverError)+        #expect(viewModel.selectedDay == nil)+    }++    // MARK: - Helpers++    private func makeModelContext() throws -> ModelContext {+        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+        let container = try ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+        return ModelContext(container)+    }++    private func makeUTCDate(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {+        let calendar = Calendar(identifier: .gregorian)+        return calendar.date(from: DateComponents(+            timeZone: TimeZone(secondsFromGMT: 0),+            year: year,+            month: month,+            day: day,+            hour: hour,+            minute: minute+        ))!+    }+}++/// A mutable now holder so a test can advance the injected clock between loads.+/// Lock-guarded so the `@Sendable nowProvider` closure may read it safely.+private final class NowBox: @unchecked Sendable {+    private let lock = NSLock()+    private var stored: Date++    init(_ value: Date) { stored = value }++    var value: Date {+        get { lock.withLock { stored } }+        set { lock.withLock { stored = newValue } }+    }+}++private final class RecordingHistoryAPIClient: FluxAPIClient, @unchecked Sendable {+    var historyResult: Result<HistoryResponse, Error> = .failure(FluxAPIError.notConfigured)+    private(set) var requestedDays: [Int] = []++    func fetchStatus() async throws -> StatusResponse { throw FluxAPIError.notConfigured }++    func fetchHistory(days: Int) async throws -> HistoryResponse {+        requestedDays.append(days)+        return try historyResult.get()+    }++    func fetchDay(date _: String) async throws -> DayDetailResponse { throw FluxAPIError.notConfigured }+    func saveNote(date _: String, text _: String) async throws -> NoteResponse { throw FluxAPIError.notConfigured }+}++/// Parks the first `fetchHistory` call until released so a test can switch+/// range mid-load and exercise the coalescing loop.+@MainActor+private final class GatedHistoryAPIClient: FluxAPIClient {+    var historyResult: Result<HistoryResponse, Error> = .failure(FluxAPIError.notConfigured)+    private(set) var requestedDays: [Int] = []++    private var firstFetchStarted: CheckedContinuation<Void, Never>?+    private var firstFetchRelease: CheckedContinuation<Void, Never>?+    private var didStartFirstFetch = false+    private var didReleaseFirstFetch = false++    nonisolated func fetchStatus() async throws -> StatusResponse { 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+    }++    func fetchHistory(days: Int) async throws -> HistoryResponse {+        let isFirst = requestedDays.isEmpty+        requestedDays.append(days)+        if isFirst {+            didStartFirstFetch = true+            firstFetchStarted?.resume()+            firstFetchStarted = nil+            if !didReleaseFirstFetch {+                await withCheckedContinuation { firstFetchRelease = $0 }+            }+        }+        return try historyResult.get()+    }++    func waitForFirstFetch() async {+        if didStartFirstFetch { return }+        await withCheckedContinuation { firstFetchStarted = $0 }+    }++    func releaseFirstFetch() {+        didReleaseFirstFetch = true+        firstFetchRelease?.resume()+        firstFetchRelease = nil+    }+}diff --git a/Flux/FluxTests/HistoryViewModelTests.swift b/Flux/FluxTests/HistoryViewModelTests.swiftindex 039b4a5..ee06778 100644--- a/Flux/FluxTests/HistoryViewModelTests.swift+++ b/Flux/FluxTests/HistoryViewModelTests.swift@@ -17,7 +17,7 @@ struct HistoryViewModelTests {          let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext) -        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.days.count == 1)         #expect(viewModel.days.first?.date == expectedDay.date)@@ -35,7 +35,7 @@ struct HistoryViewModelTests {         let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now }) -        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let cached = try modelContext.fetch(             FetchDescriptor<CachedDayEnergy>(sortBy: [SortDescriptor(\CachedDayEnergy.date)])@@ -46,6 +46,9 @@ struct HistoryViewModelTests {     @Test     func loadHistoryFallsBackToCacheWhenNetworkFails() async throws {         let modelContext = try makeModelContext()+        // Cache row must fall inside the resolved window. now = 04:30 AEST on+        // 2026-04-15 (Sydney "today" = 2026-04-15), so a 7-day window covers+        // 2026-04-09…2026-04-15 and the 2026-04-14 row is included.         modelContext.insert(CachedDayEnergy(from: DayEnergy(             date: "2026-04-14", epv: 5.2, eInput: 0.9, eOutput: 0.3, eCharge: 1.8, eDischarge: 2.7         )))@@ -54,9 +57,10 @@ struct HistoryViewModelTests {         let apiClient = MockHistoryAPIClient()         apiClient.historyResult = .failure(FluxAPIError.networkError("offline")) -        let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext)+        let now = makeUTCDate(year: 2026, month: 4, day: 14, hour: 18, minute: 30)+        let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now }) -        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.days.count == 1)         #expect(viewModel.days.first?.date == "2026-04-14")@@ -71,7 +75,7 @@ struct HistoryViewModelTests {          let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext) -        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.days.isEmpty)         #expect(viewModel.error == .serverError)@@ -93,7 +97,7 @@ struct HistoryViewModelTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.solarSeries.count == 2, "solar shows every day")         #expect(viewModel.batterySeries.count == 2, "battery shows every day")@@ -121,7 +125,7 @@ struct HistoryViewModelTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.gridSeries.count == 1, "today renders even without an off-peak split")         let gridEntry = try #require(viewModel.gridSeries.first)@@ -144,7 +148,7 @@ struct HistoryViewModelTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.gridSeries.isEmpty, "no split info — entry omitted, not rendered with off-peak 0")     }@@ -168,7 +172,7 @@ struct HistoryViewModelTests {          let now = makeUTCDate(year: 2026, month: 4, day: 15, hour: 14, minute: 30)         let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          let summary = viewModel.summary         #expect(summary.solarDayCount == 1, "today is excluded from completed-day count")@@ -192,7 +196,7 @@ struct HistoryViewModelTests {         apiClient.historyResult = .success(HistoryResponse(days: [dayWithNote, dayWithoutNote]))          let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext)-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))          viewModel.selectDay(dayWithNote)         #expect(viewModel.selectedDay?.note == "Away in Bali")@@ -220,6 +224,9 @@ struct HistoryViewModelTests {     @Test     func cacheFallbackPathRendersNotes() async throws {         let modelContext = try makeModelContext()+        // 18:30 UTC on 2026-04-14 = 04:30 AEST on 2026-04-15, so the Sydney+        // "today" is 2026-04-15 and the 2026-04-14 cache row is inside a 7-day+        // window.         modelContext.insert(CachedDayEnergy(from: DayEnergy(             date: "2026-04-14", epv: 5.2, eInput: 0.9, eOutput: 0.3, eCharge: 1.8, eDischarge: 2.7,             note: "Cached note"@@ -229,8 +236,9 @@ struct HistoryViewModelTests {         let apiClient = MockHistoryAPIClient()         apiClient.historyResult = .failure(FluxAPIError.networkError("offline")) -        let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext)-        await viewModel.loadHistory(days: 7)+        let now = makeUTCDate(year: 2026, month: 4, day: 14, hour: 18, minute: 30)+        let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext, nowProvider: { now })+        await viewModel.loadHistory(range: .days(7))          #expect(viewModel.days.count == 1)         #expect(viewModel.days.first?.note == "Cached note")@@ -248,7 +256,7 @@ struct HistoryViewModelTests {         apiClient.historyResult = .success(HistoryResponse(days: [firstDay, secondDay]))          let viewModel = HistoryViewModel(apiClient: apiClient, modelContext: modelContext)-        await viewModel.loadHistory(days: 7)+        await viewModel.loadHistory(range: .days(7))         viewModel.selectDay(secondDay)          #expect(viewModel.selectedDay?.date == secondDay.date)@@ -275,13 +283,15 @@ struct HistoryViewModelTests {  private final class MockHistoryAPIClient: FluxAPIClient, @unchecked Sendable {     var historyResult: Result<HistoryResponse, Error> = .failure(FluxAPIError.notConfigured)+    private(set) var requestedDays: [Int] = []      func fetchStatus() async throws -> StatusResponse {         throw FluxAPIError.notConfigured     } -    func fetchHistory(days _: Int) async throws -> HistoryResponse {-        try historyResult.get()+    func fetchHistory(days: Int) async throws -> HistoryResponse {+        requestedDays.append(days)+        return try historyResult.get()     }      func fetchDay(date _: String) async throws -> DayDetailResponse {
docs/flux-v1.md Modified +2 / -2
diff --git a/docs/flux-v1.md b/docs/flux-v1.mdindex b090bd5..650b57b 100644--- a/docs/flux-v1.md+++ b/docs/flux-v1.md@@ -20,7 +20,7 @@ Hybrid layout combining a centred battery hero section, a three-column power rea  ### History Layout -Grouped vertical bar chart (5 bars per day) with a tappable day detail. Segmented control for 7/14/30 day ranges.+Grouped vertical bar chart (5 bars per day) with a tappable day detail. Segmented control for 7/14/30 day ranges plus week-to-date (Wk) and month-to-date (Mo).  ### Day Detail Layout @@ -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 to switch to 14 or 30 days.+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.  Each day shows 5 side-by-side bars: 
specs/history-month-week-to-date/* Added +489
diff --git a/specs/history-month-week-to-date/decision_log.md b/specs/history-month-week-to-date/decision_log.mdnew file mode 100644index 0000000..0f59331--- /dev/null+++ b/specs/history-month-week-to-date/decision_log.md@@ -0,0 +1,169 @@+# Decision Log: History Month and Week to Date++## Decision 1: Use the full spec workflow++**Date**: 2026-06-03+**Status**: accepted++### Context++T-1361 adds "month to date" and "week to date" range options to the History screen. Scope assessment found the change spans two subsystems (the SwiftUI app and the Go backend), touches more than three files, and carries open design questions (how to express a variable-length range to the backend, and how to handle the locale-driven week start).++### Decision++Run the full spec workflow (requirements → design → tasks) rather than smolspec.++### Rationale++The feature crosses the app/backend boundary and the backend's fixed `validDays = {7, 14, 30}` allowlist must change to support variable-length ranges. Several smolspec exclusion criteria apply (multiple subsystems, >3 files, ambiguous approach), so the full workflow is appropriate.++### Alternatives Considered++- **Smolspec**: Lightweight single-document spec — Rejected because the cross-subsystem scope and unresolved backend approach exceed smolspec's criteria.++---++## Decision 2: Sydney calendar for boundaries, device locale for week start++**Date**: 2026-06-03+**Status**: accepted++### Context++The stored daily data is keyed by Sydney-local dates ("YYYY-MM-DD"), and the backend computes "today" in the Sydney timezone. The ticket states the week must start on "the system-defined week start day (per the app, not the backend)."++### Decision++Compute "today", the month start, and the week start against the Sydney-based calendar so the selected day set matches the stored data. Derive only the week's first weekday from the device locale (`Calendar.current.firstWeekday`).++### Rationale++Using a non-Sydney calendar for day boundaries would produce day sets that do not align with the Sydney-keyed data, causing off-by-one errors near midnight and at month/week edges. The single locale-dependent aspect the ticket calls out is which weekday a week begins on, which is independent of the timezone used for the boundary arithmetic.++### Alternatives Considered++- **Fully device-local calendar**: Use the device timezone for all boundary math — Rejected because day boundaries would diverge from the Sydney-keyed data.+- **Hardcode week start (e.g. Monday)**: Simpler — Rejected because the ticket explicitly requires the app/locale-defined start day.++---++## Decision 3: Five-segment range control with "Wk" / "Mo" labels++**Date**: 2026-06-03+**Status**: accepted++### Context++The existing range control is a 3-segment segmented `Picker` (7d / 14d / 30d). The two new ranges need a place in the UI without losing the existing fixed ranges.++### Decision++Keep all three fixed ranges and append two segments, giving a five-option control in the order 7d / 14d / 30d / Wk / Mo. The default selection remains 7d.++### Rationale++Appending preserves existing behaviour and muscle memory; no fixed range is removed. Short labels ("Wk", "Mo") keep the five segments legible on iPhone width and read more plainly than the "WTD/MTD" jargon alternative.++### Alternatives Considered++- **Switch to a Menu/dropdown**: Scales to more options and frees horizontal space — Rejected (for now) because it adds a tap and changes the established interaction.+- **Replace 14d & 30d with Week & Month**: Keeps three segments — Rejected because it removes existing fixed ranges users rely on.+- **"WTD" / "MTD" labels**: Width-matches "30d" — Rejected as jargon-heavy versus plain "Wk"/"Mo".++---++## Decision 4: Defer range-transport mechanism to the design phase++**Date**: 2026-06-04+**Status**: accepted++### Context++The requirements reviews (design-critic + external peer validation) converged on one central question that is fundamentally a design choice: how a variable-length to-date range is expressed to the backend. Today the range is a bare `?days=N` integer that the Lambda re-anchors with `startDate = today-(N-1)` in Sydney time, against a `validDays={7,14,30}` allowlist. Wk/Mo need 1–31 days, and the selection (currently a bare `Int`, also serialized as `ChartScope.historyRange(days:)`) must carry enough information to scale charts and the expanded-chart view.++### Decision++Keep the requirements behaviour-focused (retrieve 1–31 days without clipping; boundaries computed against the Sydney calendar; charts scale to the actual length) and defer the mechanism to the design phase. Design will choose between (a) the app computing N and widening the backend allowlist to 1–31, versus (b) the backend learning the range type and computing both boundaries itself, and will decide how the selection model changes from `Int` to a range representation.++### Rationale++Requirements describe observable behaviour, not implementation. Both transport options can satisfy the same acceptance criteria; choosing between them depends on the double-computation / device-clock-skew trade-off (option a) versus a richer backend contract (option b), which is a design concern. Recording the deferral keeps the open question visible for the design phase.++### Alternatives Considered++- **Decide the mechanism now in requirements**: Rejected — it would prescribe implementation in a requirements document and pre-empt the design analysis.++### Consequences++**Positive:**+- Requirements stay testable and implementation-agnostic.+- The key design trade-off is recorded rather than lost.++**Negative:**+- The design phase must resolve the transport and selection-model question before tasks can be written.++---++## Decision 5: App resolves the range to a day-count; backend widens its allowlist++**Date**: 2026-06-04+**Status**: accepted++### Context++Decision 4 deferred how a variable-length to-date range reaches the backend. The existing contract is `GET /history?days=N`, with the Lambda re-deriving `startDate = today-(N-1)` in Sydney time against a `validDays={7,14,30}` allowlist. The selection is currently a bare `Int` that also feeds `ChartScope.historyRange(days:)`.++### Decision++The app resolves a new `HistoryRange` enum (`.days(7|14|30)`, `.weekToDate`, `.monthToDate`) to an inclusive day-count `N` (1–31) at load time — computed against the Sydney calendar, with the week's first weekday from `Calendar.current.firstWeekday` — and sends the existing `?days=N`. The backend's only change is replacing the allowlist with a `1 ≤ days ≤ 31` bounds check and matching error message.++### Rationale++This is the smallest change that satisfies the requirements: the wire contract, `ChartScope`, the chart axes, and `DerivedState`/`PeriodSummary` are all unchanged because they already adapt to the actual data and the resolved count. Both app and Lambda compute the window from the Sydney "today", so they agree except in the same seconds-wide midnight race the fixed ranges already tolerate. Because the boundary count is computed with the Sydney calendar (not the device calendar), the device timezone cannot shift the window; only the locale's first weekday is consulted.++### Alternatives Considered++- **App sends the start date (`?from=YYYY-MM-DD`)**: Single source of truth for the boundary, eliminating the double-computation race — Rejected as a larger backend contract change (new param, span/future-date validation, divergent code path) for a race that is already accepted for fixed ranges.++### Consequences++**Positive:**+- Minimal backend change; downstream app code largely untouched.+- Locale affects only the week-start weekday, not the date arithmetic.++**Negative:**+- The window boundary is computed on both sides; a request crossing Sydney midnight can be off by one until the next load (same as existing fixed ranges).++---++## Decision 6: Date-bound the offline cache fallback for all ranges++**Date**: 2026-06-04+**Status**: accepted++### Context++The offline fallback `loadCachedDays(limit: requestedDays)` returns the newest N cached days by count. For a to-date range this can surface days from before the week/month start when the cache has gaps ([4.3](requirements.md)).++### Decision++Change the fallback to fetch cached days with `date >= startDate` (where `startDate = today-(N-1)`), returned in the same ascending order as the online response. Apply this uniformly to fixed and to-date ranges rather than maintaining two paths.++### Rationale++Date-bounding is the only way to guarantee no pre-boundary day appears, and applying it uniformly keeps a single code path. For fixed ranges it is a strict correctness improvement: newest-N-by-count could previously cross the intended window on a gappy cache, whereas date-bounding matches what the online path returns.++### Alternatives Considered++- **Keep count-based for fixed ranges, add date-bound only for to-date**: Smaller diff — Rejected because two divergent fallback behaviours are harder to reason about and the unified path is also more correct for fixed ranges.++### Consequences++**Positive:**+- No day before the period start is ever shown offline; online/offline shapes match.++**Negative:**+- Fixed-range offline behaviour changes on gappy caches (returns the windowed days, not the most recent N) and the offline auto-selected day flips from oldest to newest — a latent-bug fix, since the current descending sort disagrees with the ascending online path.+- Two existing tests (`loadHistoryFallsBackToCacheWhenNetworkFails`, `cacheFallbackPathRendersNotes`) hardcode an out-of-window cache date against the real clock and WILL break; they must be rewritten to inject `nowProvider` with dates relative to the injected now. This is required task work, not optional.++---diff --git a/specs/history-month-week-to-date/design.md b/specs/history-month-week-to-date/design.mdnew file mode 100644index 0000000..ab0e012--- /dev/null+++ b/specs/history-month-week-to-date/design.md@@ -0,0 +1,138 @@+# Design: History Month and Week to Date++## Overview++Add "week to date" (Wk) and "month to date" (Mo) ranges to the History screen alongside the fixed 7/14/30-day ranges. The app resolves a selected range to an inclusive day-count ending today, computed against the Sydney calendar with the week's first weekday taken from the device locale, and sends it over the existing `?days=N` contract. The backend's only change is widening its accepted `days` range.++## Architecture++The range stops being a bare `Int` and becomes a `HistoryRange` enum that resolves to a day-count at load time. Everything downstream of the resolved count is unchanged because it already adapts to the actual data: chart axes derive their domain from the entries, `PeriodSummary`/`DerivedState` average over actual day counts, and `ChartScope.historyRange(days:)` carries only the resolved count `N`. The expanded chart re-fetches `?days=N` (and polls every 60s via `ChartSceneObserver`), so on the same Sydney day it reconstructs the identical window — a fixed 7d and a 7-day week both resolving to `days:7` is harmless precisely because their windows are identical, not because rendering is cached.++**Resolution path (per load trigger, satisfying [3.3](requirements.md)):**++```+HistoryRange ──resolvedDays(now, firstWeekday)──▶ N (1…31)+   │                                                │+   │                              ┌─────────────────┴───────────────┐+   ▼                              ▼                                  ▼+GET /history?days=N        rangeDays: N → cards            startDate = today-(N-1)+(backend re-derives          → ChartScope.historyRange      (offline cache bound)+ startDate = today-(N-1))      (days: N)+```++Both app and Lambda compute the window from the Sydney "today", so they agree except in the seconds-wide window where Sydney midnight passes mid-request — the same race the fixed ranges already tolerate. The expanded chart's observer carries only `N` (not the `HistoryRange`), so a chart left expanded across Sydney midnight keeps fetching `N` days ending on the new today rather than re-resolving the to-date window; this matches the fixed-range midnight tolerance and self-corrects when the chart is re-opened from the (re-resolved) inline card. Live re-resolution of the expanded view is out of scope.++### Pattern extension audit++Every site that touches the range, and whether it changes:++| Site | Today | Change? | Reason |+|---|---|---|---|+| `HistoryView` Picker (`:59-64`) | Int tags 7/14/30 | **Yes** | add Wk/Mo, tag with `HistoryRange` |+| `HistoryView.selectedRange` (`:9`) | `Int = 7` | **Yes** | → `HistoryRange = .days(7)` |+| `HistoryView` load calls — `.task`/`.onChange`/`.refreshable`/`macRefreshAction` (`:101-120`) | `loadHistory(days:)` | **Yes** | → `loadHistory(range:)` |+| `HistoryView` card `rangeDays:` args (`:196,207,218`) | `selectedRange` Int | **Yes** | → `viewModel.resolvedRangeDays` |+| `HistoryViewModel.loadHistory(days:)` (`:47`) | Int param | **Yes** | → `loadHistory(range:)`, resolve N inside |+| `HistoryViewModel.lastRequestedDays` (`:12`) | Int | **Yes** | → `lastRequestedRange: HistoryRange` |+| `HistoryViewModel.reload()` (`:78`) | replays Int | **Yes** | re-resolve from `lastRequestedRange` ([3.3](requirements.md)) |+| `HistoryViewModel.loadCachedDays(limit:)` (`:139`) | newest-N by count | **Yes** | → date-bounded by start ([4.3](requirements.md)) |+| `HistoryViewModel.resolvedRangeDays` | — | **Add** | last resolved count for cards/ChartScope |+| `FluxAPIClient.fetchHistory(days:)` | Int | No | still receives a resolved Int |+| `ChartScope.historyRange(days:)` (`ChartKind.swift:47`) | Int | No | resolved Int flows through |+| Cards `expansionScope`/`rangeDays` (Solar/Grid/DailyUsage `:13`) | Int | No | receive resolved Int |+| Chart x-axes (e.g. `HistoryDailyUsageCard:90`) | stride from `entries.count` | No | already adaptive |+| `DerivedState`/`PeriodSummary` (`HistoryDerivedState.swift`) | from `days` array | No | already adaptive |+| `ExpandedChartView.defaultHistoryRangeDays` (`:91`) | fallback 7 | No | unchanged; ChartScope still carries Int |+| `internal/api/history.go` `validDays` (`:16,27`) | `{7,14,30}` | **Yes** | accept 1–31, update message |++## Components and Interfaces++### FluxCore — `DateFormatting` additions++Pure, Sydney-calendar boundary helpers (testable, locale only via the passed `firstWeekday`):++```swift+/// Sydney-calendar 00:00 on the 1st of the month containing `now`.+static func startOfMonth(now: Date) -> Date++/// Sydney-calendar 00:00 on the week start containing `now`.+/// `firstWeekday` follows Calendar's convention (1 = Sunday … 7 = Saturday);+/// only the weekday is locale-driven — the date arithmetic is Sydney.+static func startOfWeek(now: Date, firstWeekday: Int) -> Date++/// Inclusive count of Sydney calendar days from `start` through `end`.+/// Computed by calendar-day difference (both normalised to Sydney midnight),+/// never by dividing an elapsed interval, so 23/25-hour DST days don't shift it.+static func inclusiveDayCount(from start: Date, through end: Date) -> Int+```++`startOfWeek` mutates a **copy** of `sydneyCalendar` (never the shared `static let`, which would corrupt every other date computation) with `firstWeekday` set, then `dateInterval(of: .weekOfYear, for: now)?.start`; `startOfMonth` uses `dateInterval(of: .month, for: now)?.start`.++### App — `HistoryRange` (new, in `Flux/History/`)++```swift+enum HistoryRange: Hashable {+    case days(Int)        // 7, 14, 30+    case weekToDate+    case monthToDate++    var pickerLabel: String   // "7d"/"14d"/"30d"/"Wk"/"Mo"++    /// Inclusive day-count ending on `now` (1…31). Fixed cases return their N.+    func resolvedDays(now: Date, firstWeekday: Int) -> Int+}+```++`pickerLabel` is the only UI string; the boundary math delegates to the FluxCore helpers. Selection order in the Picker: `.days(7), .days(14), .days(30), .weekToDate, .monthToDate` ([6.1](requirements.md)).++### App — `HistoryViewModel` changes++- `loadHistory(range: HistoryRange)` replaces `loadHistory(days:)`. It sets `lastRequestedRange = range` **before** the `isLoading` guard so a selection made during an in-flight load is not dropped, then resolves `N` from `nowProvider()` + a new injected `firstWeekdayProvider()`, sets `resolvedRangeDays = N`, and calls `fetchHistory(days: N)`. After the fetch it re-checks `lastRequestedRange`; if a newer range arrived during the load it loops and loads that one (coalescing — the latest selection always wins, so the picker segment and the rendered data never disagree).+- New init param `firstWeekdayProvider: @Sendable () -> Int = { Calendar.current.firstWeekday }`, mirroring the existing `nowProvider` injection for test determinism. Read at resolution time ([3.3](requirements.md)).+- `reload()` re-resolves from `lastRequestedRange` against the current `now`, so an app left open across Sydney midnight reloads the correct window.+- Offline fallback becomes date-bounded: `loadCachedDays(onOrAfter: startDate)` fetches `CachedDayEnergy` where `date >= startDate` (lexicographic compare on zero-padded `YYYY-MM-DD` equals chronological; `startDate` captured as a `let` for the `#Predicate`), **sorted ascending** — flipping the current `.reverse` descriptor so the offline shape matches the online response. `startDate = today-(N-1)` (Sydney), so a gappy cache never surfaces a day before the period start ([4.3](requirements.md)). Applying this uniformly to fixed ranges is a strict correctness improvement (newest-N-by-count could previously cross the window on a gap) and also corrects a latent bug: the current descending sort makes `selectDefaultDayIfNeeded` auto-select the *oldest* cached day offline, whereas ascending makes `days.last` the newest, matching online.+- `resolvedRangeDays: Int` (default 7) is set inside `loadHistory` and read by `HistoryView` for the cards' `rangeDays:`. On the first frame and briefly mid-load the cards read the default/previous count; harmless because charts and summaries are data-adaptive and only the expansion scope's `N` is affected.++### App — `HistoryView` changes++`selectedRange: HistoryRange = .days(7)`; the segmented Picker tags each case and shows `pickerLabel` ([6.3](requirements.md) keeps `.pickerStyle(.segmented)`); load triggers call `loadHistory(range: selectedRange)`; cards receive `rangeDays: viewModel.resolvedRangeDays`. Known limitation: five segments at large Dynamic Type sizes can truncate on the narrowest iPhones; the short fixed-width abbreviations ("Wk"/"Mo") mitigate this and the segmented style is retained per [6.3](requirements.md).++### Backend — `internal/api/history.go`++Replace the `validDays` allowlist with a bounds check:++```go+if err != nil || parsed < 1 || parsed > 31 {+    return errorResponse(400, "invalid days parameter, must be between 1 and 31")+}+```++The `err != nil` check is retained so a non-numeric value (e.g. `?days=x`) still 400s. The default-7 path stays inside the existing `if d := req.QueryStringParameters["days"]; d != ""`. `startDate = now.AddDate(0,0,-(days-1))` and the default of 7 are unchanged. The 31 ceiling covers the longest month-to-date; historical rows come from `flux-daily-energy` (no TTL), and only the single today row uses the 24-hour readings window, so no retention limit is hit.++## Error Handling++- **Out-of-range `days`**: the app only ever sends 1–31, so a 400 is reachable only via clock skew / the midnight race. It surfaces through the existing `errorState` with Retry ([4.4](requirements.md)); Retry re-resolves against the current `now`, self-correcting after the boundary settles.+- **Offline / fetch failure**: date-bounded cache fallback as above; empty cache shows the existing error or empty state unchanged.+- **Backgrounded app / stale window**: an already-rendered range persists until the next load trigger, so a window resolved before Sydney midnight stays shown until the user refreshes, switches range, or reactivates the screen — the accepted trade-off from Decision 5.++## Testing Strategy++**FluxCore (`DateFormatting`)** — example-based across edge inputs:+- `startOfMonth`/`inclusiveDayCount`: 28/29/30/31-day months; on the 1st → count 1; on the 31st → count 31.+- `startOfWeek`: `firstWeekday` ∈ {1 Sunday, 2 Monday, 7 Saturday}; on the week-start day → count 1; mid-week → expected count.+- DST: Sydney transition days (early April, early October) return the same day counts as non-DST days (guards against interval-division regressions).++**Property-based candidate** — the boundary helpers express clean invariants. Using Swift Testing `@Test(arguments:)` over a generated set of seed dates (no `Date.now`) and all `firstWeekday` 1…7:+- `weekToDate` resolved count ∈ 1…7; `startOfWeek` weekday == `firstWeekday`; `start ≤ now`.+- `monthToDate` resolved count ∈ 1…31; `startOfMonth` is day 1; `start ≤ now`.++**`HistoryViewModel`** (injected `now` + `firstWeekday`):+- `loadHistory(range:)` resolves the expected `N` and `startDate` for Wk/Mo on representative dates.+- `reload()` after advancing `now` past midnight re-resolves to the new window ([3.3](requirements.md)).+- Offline fallback with a gappy cache excludes days before `startDate`, returns days ascending, and auto-selects the newest (today) day ([4.3](requirements.md)).+- A range switch during an in-flight load coalesces to the latest selection (final `days`/`resolvedRangeDays` match the last-selected range).+- Failed fetch with empty cache yields `error` / empty days ([4.4](requirements.md)).++**Consistency ([5.2](requirements.md))**: feeding an identical `[DayEnergy]` array through the card-building path yields identical `DerivedState`/`PeriodSummary` regardless of which range produced it (unit, no fetch).++**Backend (`history.go`)** — map-based table test: `days` ∈ {1, 7, 31} accepted with correct window length; {0, 32, -1, "x"} → 400 with the new message; absent param → 7.diff --git a/specs/history-month-week-to-date/requirements.md b/specs/history-month-week-to-date/requirements.mdnew file mode 100644index 0000000..8a9ea4c--- /dev/null+++ b/specs/history-month-week-to-date/requirements.md@@ -0,0 +1,77 @@+# Requirements: History Month and Week to Date++## Introduction++The History screen currently offers three fixed-length ranges (7, 14, and 30 days). This feature adds two calendar-anchored ranges — "this week" and "this month" — so a user can see only the days since their current week or month began. Week and month boundaries are computed against the same Sydney-based calendar that keys the stored data, while the week's start day follows the device locale rather than a hardcoded value.++## Non-Goals++- Persisting the selected range across app launches (the range is not persisted today and this feature does not change that).+- Custom or arbitrary user-chosen date ranges (e.g. a date-range picker).+- "Last week" / "last month" (completed prior periods) — only the current week/month to date.+- Quarter-, year-, or all-time-to-date ranges.+- Changing how any History card aggregates or renders data; only the set of days it operates on changes.+- Backend retention changes to make older days available beyond what is already stored.++## Requirements++### 1. Week-to-date range++**User Story:** As a Flux user, I want a "this week" range on History, so that I can see only the days since my current week began.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHEN the user selects the Week range, the History screen SHALL display data from the start of the current week through today, inclusive.  +2. <a name="1.2"></a>The current week SHALL start on the most recent day, on or before today, whose weekday matches the locale-derived first weekday (precise rule defined in [3.2](#3.2)).  +3. <a name="1.3"></a>IF today is the week's start day, THEN the Week range SHALL display only today.  ++### 2. Month-to-date range++**User Story:** As a Flux user, I want a "this month" range on History, so that I can see only the days since the month began.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN the user selects the Month range, the History screen SHALL display data from the 1st of the current calendar month through today, inclusive.  +2. <a name="2.2"></a>IF today is the 1st of the month, THEN the Month range SHALL display only today.  ++### 3. Boundary computation++**User Story:** As a Flux user, I want week and month boundaries to line up with the data and respect my locale, so that the days shown are correct and the week starts on the right day for me.++**Acceptance Criteria:**++1. <a name="3.1"></a>The system SHALL compute "today", the month start, and the week start using the same Sydney-based calendar that keys the stored daily data.  +2. <a name="3.2"></a>The week's first weekday SHALL be derived from the device locale (not hardcoded), and the week start SHALL be the most recent Sydney calendar date — on or before the Sydney "today" — whose weekday equals that first weekday; the weekday SHALL be evaluated against the Sydney date, not the device-local date.  +3. <a name="3.3"></a>The week and month boundaries SHALL be recomputed against the current Sydney "today" on each load trigger (range selection, pull-to-refresh, or screen appearance); live recomputation while the screen sits idle is not required.  ++### 4. Variable-length data retrieval++**User Story:** As a Flux user, I want the to-date ranges to load reliably regardless of how far into the week or month it is, so that the range works on any day.++**Acceptance Criteria:**++1. <a name="4.1"></a>The system SHALL retrieve History data for any range length from 1 through 31 days without error, including a full month-to-date range requested on the 31st of a 31-day month (the result SHALL NOT be clipped to 30 days).  +2. <a name="4.2"></a>IF data is unavailable for some days within the selected range, THEN the screen SHALL display the available days using the same gap handling as the existing fixed-length ranges.  +3. <a name="4.3"></a>WHEN a Week or Month range is served from the offline cache, the displayed days SHALL be bounded by the computed week/month start, so no day earlier than the period start is shown even when the cache has gaps.  +4. <a name="4.4"></a>IF a selected range fails to load, THEN the screen SHALL present the same error or empty state used by the fixed-length ranges, without crashing or hanging.  ++### 5. Downstream consistency++**User Story:** As a Flux user, I want every card on the History screen to reflect the to-date range the same way the fixed ranges do, so that the numbers stay trustworthy and consistent.++**Acceptance Criteria:**++1. <a name="5.1"></a>All History cards driven by the selected range (period overview, charts, and daily usage list) SHALL compute their values from the Week/Month day set using the same logic applied to the fixed-length ranges.  +2. <a name="5.2"></a>Given an identical set of day records, every History card SHALL produce identical values regardless of which range selection produced that set.  +3. <a name="5.3"></a>Charts and the expanded-chart view SHALL scale their axes to the actual number of days in the selected range, including range lengths that are not 7, 14, or 30.  ++### 6. Range control presentation++**User Story:** As a Flux user, I want the two new ranges in the existing range control, so that switching to them works like switching between the day ranges I already use.++**Acceptance Criteria:**++1. <a name="6.1"></a>The History range control SHALL present five options in this order: 7d, 14d, 30d, Wk, Mo.  +2. <a name="6.2"></a>The new options SHALL be labelled "Wk" (week to date) and "Mo" (month to date).  +3. <a name="6.3"></a>The range control SHALL retain the existing segmented-control interaction and styling.  +4. <a name="6.4"></a>The default selected range SHALL remain 7d.  diff --git a/specs/history-month-week-to-date/tasks.md b/specs/history-month-week-to-date/tasks.mdnew file mode 100644index 0000000..dfcf659--- /dev/null+++ b/specs/history-month-week-to-date/tasks.md@@ -0,0 +1,81 @@+---+references:+    - specs/history-month-week-to-date/requirements.md+    - specs/history-month-week-to-date/design.md+    - specs/history-month-week-to-date/decision_log.md+---+# History Month and Week to Date++- [x] 1. Write Go table test for /history days validation (1-31) <!-- id:i6257bv -->+  - internal/api/history_test.go: days 1/7/31 accepted (window length = days), 0/32/-1/x -> 400 "between 1 and 31", absent -> 7.+  - Red: fails against the current {7,14,30} allowlist and old message.+  - Stream: 1+  - Requirements: [4.1](requirements.md#4.1)+  - References: internal/api/history.go++- [x] 2. Widen /history days bounds in history.go <!-- id:i6257bw -->+  - history.go: replace validDays with `if err != nil || parsed < 1 || parsed > 31`, message "invalid days parameter, must be between 1 and 31".+  - Keep default 7 inside the d != empty block; startDate math unchanged.+  - Blocked-by: i6257bv (Write Go table test for /history days validation (1-31)), history+  - Stream: 1+  - Requirements: [4.1](requirements.md#4.1)+  - References: internal/api/history.go++- [x] 3. Write unit + property tests for FluxCore date helpers <!-- id:i6257bx -->+  - Edges: month lengths 28/29/30/31; firstWeekday 1(Sun)/2(Mon)/7(Sat); single-day (1st of month, week-start day -> count 1); Sydney DST days (early Apr/Oct) same counts.+  - Property tests over seeded dates (no Date.now): wk count 1-7, mo count 1-31, startOfWeek weekday == firstWeekday, start <= now.+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift++- [x] 4. Implement DateFormatting month/week/day-count helpers <!-- id:i6257by -->+  - DateFormatting.swift: startOfMonth via dateInterval(.month); startOfWeek mutates a COPY of sydneyCalendar (never the shared static let) then dateInterval(.weekOfYear).+  - inclusiveDayCount via dateComponents([.day]) on Sydney-midnight-normalised dates +1 (no interval division).+  - Blocked-by: i6257bx (Write unit + property tests for FluxCore date helpers)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [2.1](requirements.md#2.1), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift++- [x] 5. Write tests for HistoryRange resolution and labels <!-- id:i6257bz -->+  - Flux/History tests: .days(n) passthrough; .weekToDate/.monthToDate resolve via FluxCore helpers incl single-day edges; pickerLabel == 7d/14d/30d/Wk/Mo.+  - Stream: 3+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [6.2](requirements.md#6.2)+  - References: Flux/Flux/History/HistoryView.swift++- [x] 6. Implement HistoryRange enum <!-- id:i6257c0 -->+  - Flux/Flux/History/HistoryRange.swift: Hashable enum .days(Int)/.weekToDate/.monthToDate; pickerLabel; resolvedDays(now:firstWeekday:) delegating to FluxCore helpers.+  - Blocked-by: i6257bz (Write tests for HistoryRange resolution and labels), i6257by (Implement DateFormatting month/week/day-count helpers)+  - Stream: 3+  - Requirements: [1.1](requirements.md#1.1), [2.1](requirements.md#2.1), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2)+  - References: Flux/Flux/History/HistoryView.swift++- [x] 7. Write/update HistoryViewModel tests for range loading <!-- id:i6257c1 -->+  - Cover: Wk/Mo resolution (injected now+firstWeekday); reload() re-resolves after now advances past midnight; offline fallback excludes pre-startDate days, ascending, auto-selects newest; mid-load range switch coalesces to latest; failed fetch + empty cache -> error/empty.+  - Fix loadHistoryFallsBackToCacheWhenNetworkFails + cacheFallbackPathRendersNotes to inject nowProvider with relative dates (they hardcode an out-of-window date).+  - Blocked-by: i6257c0 (Implement HistoryRange enum)+  - Stream: 3+  - Requirements: [3.3](requirements.md#3.3), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4)+  - References: Flux/Flux/History/HistoryViewModel.swift++- [x] 8. Implement HistoryViewModel range loading changes <!-- id:i6257c2 -->+  - HistoryViewModel: loadHistory(range:) replaces loadHistory(days:) - set lastRequestedRange before the isLoading guard, resolve N (nowProvider + injected firstWeekdayProvider), set resolvedRangeDays, fetchHistory(days:N), coalesce to latest lastRequestedRange; reload() re-resolves.+  - loadCachedDays(onOrAfter:startDate): let-captured #Predicate date>=startDate, sorted ascending (flip the current .reverse descriptor).+  - Blocked-by: i6257c1 (Write/update HistoryViewModel tests for range loading)+  - Stream: 3+  - Requirements: [3.3](requirements.md#3.3), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4)+  - References: Flux/Flux/History/HistoryViewModel.swift++- [x] 9. Wire HistoryView range control to HistoryRange <!-- id:i6257c3 -->+  - HistoryView: selectedRange:HistoryRange=.days(7); 5-segment Picker tags .days(7)/.days(14)/.days(30)/.weekToDate/.monthToDate showing pickerLabel, keep .pickerStyle(.segmented).+  - .task/.onChange/.refreshable/macRefreshAction call loadHistory(range:); cards receive rangeDays: viewModel.resolvedRangeDays.+  - Blocked-by: i6257c2 (Implement HistoryViewModel range loading changes), i6257c0 (Implement HistoryRange enum)+  - Stream: 3+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [3.3](requirements.md#3.3)+  - References: Flux/Flux/History/HistoryView.swift++- [x] 10. Add downstream consistency test for range day sets <!-- id:i6257c4 -->+  - Identical [DayEnergy] array -> identical DerivedState/PeriodSummary regardless of producing range; to-date selection feeds resolved N to the card expansionScope (ChartScope.historyRange(days:)).+  - Blocked-by: i6257c2 (Implement HistoryViewModel range loading changes)+  - Stream: 3+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3)+  - References: Flux/Flux/History/HistoryDerivedState.swift

Things to double-check

Expanded chart at odd lengths

AC 5.3's expanded-chart axis scaling for a non-7/14/30 day count is asserted via the resolved-N passthrough, not exercised end-to-end. The axis stride is max(1, entries.count / 6) (data-adaptive), unchanged by this branch — worth an eyeball on a real 12- or 31-day to-date range.

Five-segment picker on narrow iPhones

At the largest Dynamic Type sizes the five segments can truncate on the narrowest devices. The short “Wk”/“Mo” labels mitigate it; not verified at accessibility text sizes.

Uncommitted review fixes

The three fixes above live in the working tree, not yet in a commit. They must be committed before the branch is pushed.