One commit (ae5b106) on the History feature: hard-number totals/records now include today (averages still exclude it), and the Week/Month-to-date charts reserve the full period on the x-axis. Four parallel review agents (reuse, quality, efficiency, spec/docs) found no code bugs; all actionable findings were fixed in this pass.
PeriodSummary gained 5 fields so totals and averages are independent; a value that feeds both a total and an average no longer shares one accumulator.chartXScale(domain:) is the same mechanism Day Detail already uses.Ready to push
All four review dimensions confirmed the today/averages split is correct (complete-days numerators divided by complete-days counts; em-dash gates use today-inclusive display counts). The one code finding (a magic ?? 30 month-length fallback) was refactored to a calendar-month interval; the spec/doc staleness was corrected. swiftlint --strict is clean and every History test suite — including the new HistoryChartDomainTests — passes. The only red test in the full bundle is the pre-existing flaky DashboardViewModelTests/refreshSkipsWhenAlreadyLoading, which passes in isolation and is unrelated to this change.
ae5b106 [feat]: History totals include today; Wk/Mo charts reserve the full period The History screen has summary tiles ("Total solar", "Most usage", …) and bar charts for the selected time range. Two things were tweaked.
1. Today now counts. Before, tiles that show a plain number — a total for the period, or the single biggest day — ignored today because today isn't finished. That looked wrong: if today already had the most usage, the "Most usage" tile still pointed at an earlier day. Now those plain-number tiles include today. The tiles that show an average per day still leave today out, because half a day would drag the average down unfairly.
2. Charts hold space for the rest of the week/month. When you pick "this week" on a Monday, the chart used to draw one fat bar filling the whole width. Now it draws that bar on the far left and leaves empty room for the days still to come, so the layout looks the same all week long. The same happens for "this month".
The numbers now match what a person expects, and the charts stop jumping around as the week fills in.
The change lives entirely in the History feature (HistoryDerivedState, the chart cards, HistoryViewModel, HistoryView) plus one new file, HistoryChartDomain.swift.
PeriodSummary previously reused one field (e.g. solarTotalKwh) as both the displayed total and the numerator of the per-day average. Those two now need different cohorts (total = all days incl. today; average = complete days only), so the struct gained complete-days numerators (solarCompleteTotalKwh, dischargeCompleteTotalKwh, dailyUsageCompleteTotalKwh) and today-inclusive display counts (dayCount, dailyUsageDisplayDayCount) that gate the em-dash placeholders. The accumulator Totals.addCompleteDay + considerSocLow were folded into one addDay(…, isToday:) that does display aggregates for every day and average bases only when !isToday.
HistoryChartDomain computes a ClosedRange<Date> span and the list of Sydney-midnight slot dates for the to-date ranges; charts apply it with a historyChartXScale(_:) modifier and render invisible zero-height scaffold BarMarks at each slot. Fixed 7/14/30 ranges pass nil and auto-fit.
Reserving the full calendar month means thin bars early in the month; that was an explicit choice for consistent alignment. The reservation is scoped to inline cards because the enlarged-chart ChartScope only carries a day count, not the range type.
Cohort separation. The core insight is that a display total and a per-day average are different reductions over different cohorts, and conflating them is what made "include today" hard. The fix stores both reductions: display totals accumulate over all parsed days in addDay; average numerators accumulate under the guard !isToday tail of the same method. Records (mostUsageDay/mostSolarDay) moved out of the complete-days guard to join lowestSocDay, which already considered today. The em-dash gates were repointed from average denominators (solarDayCount, dailyUsageDayCount) to today-inclusive display counts (dayCount, dailyUsageDisplayDayCount), which also fixes the daily-usage chart showing a "no breakdown" placeholder on a today-only range even though the bar exists.
Bar-width determinism. A bare chartXScale(domain:) over a wide domain renders a single real bar at an inconsistent width because Swift Charts can't infer the day-unit from one mark. The scaffold provides N marks at one-day spacing so inference is stable; the marks are zero-height, .clear, and accessibilityHidden(true) so they're invisible to sight and VoiceOver, and they don't enter the explicit chartForegroundStyleScale domain so the legend is untouched.
DST + boundaries. HistoryChartDomain derives month boundaries from Calendar.dateInterval(of: .month) (no hard-coded length) and steps slots one calendar day at a time so 23/25-hour DST days keep each slot at Sydney midnight. HistoryViewModel.chartDomain reads resolvedRange (set at load) rather than lastRequestedRange so the reservation matches the rendered data during mid-load coalescing.
today-only range (totals incl. today, averages nil); mid-window today (total = complete + partial, average = complete only); week start honouring locale first weekday; full calendar month incl. an April DST month; fixed ranges → nil domain.
Flux/Flux/History/HistoryDerivedState.swift
Why it matters. Core behaviour change and a data-consistency point: totals/records include today while per-day averages exclude it. Verify the averages divide a complete-days numerator by a complete-days count, not an all-days total.
What to look at. HistoryDerivedState.swift — PeriodSummary fields + Totals.addDay(isToday:) + snapshot
Flux/Flux/History/HistoryChartDomain.swift
Why it matters. New file driving Requirement 2. The scaffold is the non-obvious part — without it a single bar over a wide domain renders at the wrong width.
What to look at. HistoryChartDomain.make / build / scaffold + historyChartXScale modifier
Flux/Flux/History/HistoryStatsOverviewCard.swift
Why it matters. Determines whether a today-only range shows a real number or an em-dash; easy to get subtly wrong by reusing an average denominator.
What to look at. valueText/accessibilityLabel use dayCount & dailyUsageDisplayDayCount; HistoryDailyUsageCard.shouldShowPlaceholder likewise
Flux/Flux/History/HistoryViewModel.swift
Why it matters. Subtle correctness: during mid-load range coalescing, the reservation must match the data currently in `days`.
What to look at. HistoryViewModel.resolvedRange (set in load) + chartDomain computed property
Totals (Total solar/usage, Battery discharged/charged) and day-records (Most usage/solar) include today, matching Peak imports / Exported / Lowest SoC which already did. Per-day averages keep the complete-days basis so a partial day doesn't skew them. Reverses the 'exclude today' half of Decision 5.
Wk → full 7-day week, Mo → full calendar month, via chartXScale(domain:). Fixed 7/14/30 ranges pass nil (they already span N days ending today). Confirmed with the user that month reserves the full calendar month (accepting thin bars early in the month).
A lone bar over a wide domain renders at an inconsistent width; scaffolding every slot gives Charts the one-day spacing it needs. Chosen over a categorical axis (which would have rewritten the selection overlay and axis logic).
The review flagged a ?? 30 magic fallback; replaced with Calendar.dateInterval(of: .month) so 28–31-day months are handled with no magic number.
The enlarged-chart host passes nil because its ChartScope carries only a day count, not the range type. Threading the range type through the Codable scope, registry and macOS window was deferred as disproportionate for a secondary view.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | HistoryChartDomain.make (reuse) | Month length used `calendar.range(of: .day, in: .month)?.count ?? 30` — an arbitrary magic fallback that would under-reserve a day on a 31-day month if ever hit. | Refactored make/build to derive both boundaries from `Calendar.dateInterval(of: .month)` and step slots one day at a time; no magic number, DST-safe. |
| minor | history-usage-stats/requirements.md (spec) | Definitions and the tile cohort table still said Total solar/usage and Most usage/solar 'exclude today' — contradicting the new code. | Updated the today-inclusion block and table rows 2.1/2.2/2.6/2.7 with a Decision 15 reference; annotated the 'complete day' definition as average-only. |
| minor | history-usage-stats/design.md (spec) | Record descriptions said 'Best complete day' and referenced the now-renamed `addCompleteDay`. | Added a superseding banner pointing to Decision 15 and the current `addDay` shape; retained the original prose as historical context. |
| minor | docs/agent-notes/ios-app-viewmodels.md (docs) | PeriodSummary note stated totals exclude today (except grid) — the inverse of the new rule; would mislead future agents. | Rewrote the bullet to enumerate today-inclusive totals/records, complete-days averages, and the display-count gates. |
| minor | docs/agent-notes/ios-app-views.md (docs) | Placeholder gate documented as `dailyUsageDayCount`; the range picker was listed as 7/14/30; `HistoryBatteryCard` listed as a mounted card. | Fixed the placeholder field to `dailyUsageDisplayDayCount`, updated the picker to 7/14/30/Wk/Mo, noted the battery card is not mounted, added a Decision 15 today note and a HistoryChartDomain bullet. |
| nit | HistoryChartDomain.scaffold (quality) | Doc comment said 'Pass `[]`' which read ambiguously. | Reworded to 'When `slotDates` is empty (the no-domain case)'. |
| nit | HistoryViewModel.chartDomain (efficiency) | Computed property is read once per card (3x) per render, each rebuilding a <=31-element [Date]. | Left as-is: matches the existing `resolvedRangeDays` access pattern, the arrays are tiny, and the values are identical across the three reads. Not worth a new threaded parameter. |
| minor | PeriodSummary naming (quality) | `solarDayCount`/`batteryDayCount` both equal the complete-day count and read ambiguously next to `dayCount`. | Left as-is: pre-existing public field names with clear doc comments; renaming would churn tests for no behaviour gain. |
| minor | Battery card chartDomain (spec, false positive) | Reviewer noted HistoryBatteryCard doesn't receive chartDomain. | Skipped: HistoryBatteryCard is not mounted anywhere in the app (dead code), so it has no chart to reserve. Documented the unmounted status in the agent notes. |
| nit | FluxCore date helper (reuse) | Slot-date enumeration could become a shared FluxCore utility. | Skipped: single use site, and the interval refactor already removed the duplicated month-length logic. |
Click to expand.
diff --git a/Flux/Flux/History/HistoryDerivedState.swift b/Flux/Flux/History/HistoryDerivedState.swiftindex 78644d1..860409e 100644--- a/Flux/Flux/History/HistoryDerivedState.swift+++ b/Flux/Flux/History/HistoryDerivedState.swift@@ -85,10 +85,24 @@ extension HistoryViewModel { } struct PeriodSummary: Equatable {+ // Period totals and day-records are hard numbers, so they include+ // today's partial value — the rule the grid aggregates and Lowest SoC+ // already followed (Decision 15 supersedes the "exclude today" half of+ // Decision 5). Per-day *averages* still exclude today (a partial day+ // would skew them): each divides a complete-days-only total+ // (`…CompleteTotalKwh`) by a complete-days count.++ /// Sum of `epv` across every day in range, today included. let solarTotalKwh: Double- /// Excludes today (today is partial; including it would skew daily- /// averages). `batteryDayCount` follows the same rule.+ /// Sum of `epv` across complete days only. Numerator for+ /// `solarPerDayKwh` so the average is not skewed by today's partial day.+ let solarCompleteTotalKwh: Double+ /// Complete days only (today excluded). Denominator for+ /// `solarPerDayKwh`; `batteryDayCount` follows the same rule. let solarDayCount: Int+ /// All days in range, today included. Gates the Total solar tile so a+ /// today-only range still shows a number rather than an em-dash.+ let dayCount: Int let peakImportTotalKwh: Double let offpeakImportTotalKwh: Double let exportTotalKwh: Double@@ -96,13 +110,27 @@ extension HistoryViewModel { /// are the actionable headline so showing today's in-progress /// number matters more than a clean daily average. let gridDayCount: Int+ /// Sum of `eCharge` across every day in range, today included. let chargeTotalKwh: Double+ /// Sum of `eDischarge` across every day in range, today included. let dischargeTotalKwh: Double+ /// Sum of `eDischarge` across complete days only. Numerator for+ /// `dischargePerDayKwh`.+ let dischargeCompleteTotalKwh: Double let batteryDayCount: Int- /// Sum of clamped stacked totals across complete days-with-blocks.+ /// Sum of clamped stacked totals across every day-with-blocks, today+ /// included. let dailyUsageTotalKwh: Double- /// Number of complete days-with-blocks contributing to the total.+ /// Sum of clamped stacked totals across complete days-with-blocks only.+ /// Numerator for `dailyUsageAvgKwh`.+ let dailyUsageCompleteTotalKwh: Double+ /// Number of complete days-with-blocks. Denominator for the daily-usage+ /// averages (`dailyUsageAvgKwh`, `dailyUsageLargestKindAvgKwh`). let dailyUsageDayCount: Int+ /// Number of days-with-blocks including today. Gates the Total usage+ /// tile and the Daily usage chart placeholder so today's breakdown is+ /// shown when it is the only day with data.+ let dailyUsageDisplayDayCount: Int /// Largest contributing block kind across complete days-with-blocks, /// with ties broken by chronological order (AC 1.8). `nil` when no /// complete day-with-blocks exists in the range.@@ -117,8 +145,9 @@ extension HistoryViewModel { let nightTotalKwh: Double let nightBlockDayCount: Int /// Best day-with-blocks (max stacked kWh, ties broken by most recent).+ /// Today included — a record is a hard number. let mostUsageDay: DayKwhRecord?- /// Best complete day by `epv`. Ties broken by most recent.+ /// Best day by `epv` (max, ties broken by most recent). Today included. let mostSolarDay: DayKwhRecord? /// Day-with-low whose raw `socLow` is the minimum; ties broken by /// most recent. Today included.@@ -126,16 +155,21 @@ extension HistoryViewModel { static let empty = PeriodSummary( solarTotalKwh: 0,+ solarCompleteTotalKwh: 0, solarDayCount: 0,+ dayCount: 0, peakImportTotalKwh: 0, offpeakImportTotalKwh: 0, exportTotalKwh: 0, gridDayCount: 0, chargeTotalKwh: 0, dischargeTotalKwh: 0,+ dischargeCompleteTotalKwh: 0, batteryDayCount: 0, dailyUsageTotalKwh: 0,+ dailyUsageCompleteTotalKwh: 0, dailyUsageDayCount: 0,+ dailyUsageDisplayDayCount: 0, dailyUsageLargestKind: nil, dailyUsageLargestKindTotalKwh: 0, nightTotalKwh: 0,@@ -146,15 +180,15 @@ extension HistoryViewModel { ) var solarPerDayKwh: Double? {- solarDayCount > 0 ? solarTotalKwh / Double(solarDayCount) : nil+ solarDayCount > 0 ? solarCompleteTotalKwh / Double(solarDayCount) : nil } var dischargePerDayKwh: Double? {- batteryDayCount > 0 ? dischargeTotalKwh / Double(batteryDayCount) : nil+ batteryDayCount > 0 ? dischargeCompleteTotalKwh / Double(batteryDayCount) : nil } var dailyUsageAvgKwh: Double? {- dailyUsageDayCount > 0 ? dailyUsageTotalKwh / Double(dailyUsageDayCount) : nil+ dailyUsageDayCount > 0 ? dailyUsageCompleteTotalKwh / Double(dailyUsageDayCount) : nil } /// Denominator is `dailyUsageDayCount` (all complete days), not the@@ -213,14 +247,12 @@ extension HistoryViewModel { if let usageEntry { dailyUsage.append(usageEntry) }- if !isToday {- totals.addCompleteDay(- day,- parsedDate: parsedDate,- dailyUsageEntry: usageEntry- )- }- totals.considerSocLow(day: day, parsedDate: parsedDate)+ totals.addDay(+ day,+ parsedDate: parsedDate,+ isToday: isToday,+ dailyUsageEntry: usageEntry+ ) } self.solar = solar@@ -293,22 +325,30 @@ extension HistoryViewModel.LowestSocRecord: DayRecordValue { extension HistoryViewModel { fileprivate struct Totals {+ // Display aggregates — every day in range, today included.+ var dayCount = 0 var solarTotal = 0.0+ var chargeTotal = 0.0+ var dischargeTotal = 0.0+ var dailyUsageStackTotal = 0.0+ var dailyUsageDisplayDayCount = 0+ var mostUsage: DayKwhRecord?+ var mostSolar: DayKwhRecord?+ var lowestSoc: LowestSocRecord?+ // Grid aggregates — every day with an off-peak record (today included). var peakImportTotal = 0.0 var offpeakImportTotal = 0.0 var exportTotal = 0.0- var chargeTotal = 0.0- var dischargeTotal = 0.0- var completeDayCount = 0 var gridDayCount = 0- var dailyUsageStackTotal = 0.0- var dailyUsageDayCount = 0+ // Average bases — complete days only (today is partial → would skew).+ var completeDayCount = 0+ var solarCompleteTotal = 0.0+ var dischargeCompleteTotal = 0.0+ var dailyUsageCompleteStackTotal = 0.0+ var dailyUsageCompleteDayCount = 0 var dailyUsageKindSums: [DailyUsageBlock.Kind: Double] = [:] var nightTotal = 0.0 var nightBlockDayCount = 0- var mostUsage: DayKwhRecord?- var mostSolar: DayKwhRecord?- var lowestSoc: LowestSocRecord? mutating func addGrid(_ entry: GridEntry) { peakImportTotal += entry.peakImportKwh@@ -317,15 +357,20 @@ extension HistoryViewModel { gridDayCount += 1 } - mutating func addCompleteDay(+ /// Folds one day into both the display aggregates (always) and the+ /// average bases (complete days only). Totals and day-records are hard+ /// numbers so they include today; the averages exclude it.+ mutating func addDay( _ day: DayEnergy, parsedDate: Date,+ isToday: Bool, dailyUsageEntry: DailyUsageEntry? ) {+ // Display aggregates (today included).+ dayCount += 1 solarTotal += day.epv chargeTotal += day.eCharge dischargeTotal += day.eDischarge- completeDayCount += 1 Self.consider( &mostSolar,@@ -333,29 +378,41 @@ extension HistoryViewModel { prefersLarger: true ) + if let entry = dailyUsageEntry {+ dailyUsageStackTotal += entry.stackedTotalKwh+ dailyUsageDisplayDayCount += 1+ Self.consider(+ &mostUsage,+ candidate: DayKwhRecord(dayID: day.date, date: parsedDate, kwh: entry.stackedTotalKwh),+ prefersLarger: true+ )+ }++ considerSocLow(day: day, parsedDate: parsedDate)++ // Average bases (today excluded).+ guard !isToday else { return }+ completeDayCount += 1+ solarCompleteTotal += day.epv+ dischargeCompleteTotal += day.eDischarge+ guard let entry = dailyUsageEntry else { return } - dailyUsageStackTotal += entry.stackedTotalKwh- dailyUsageDayCount += 1+ dailyUsageCompleteStackTotal += entry.stackedTotalKwh+ dailyUsageCompleteDayCount += 1 for block in entry.blocks { dailyUsageKindSums[block.kind, default: 0] += block.totalKwh } - Self.consider(- &mostUsage,- candidate: DayKwhRecord(dayID: day.date, date: parsedDate, kwh: entry.stackedTotalKwh),- prefersLarger: true- )- if let night = entry.blocks.first(where: { $0.kind == .night }) { nightTotal += night.totalKwh nightBlockDayCount += 1 } } - /// Lowest SoC includes today, so it has its own entry point called- /// from the main loop unconditionally.- mutating func considerSocLow(day: DayEnergy, parsedDate: Date) {+ /// Lowest SoC includes today; `socLow` is a final-or-running floor, so+ /// today's value is meaningful even mid-day.+ private mutating func considerSocLow(day: DayEnergy, parsedDate: Date) { guard let soc = day.socLow, soc.isFinite else { return } Self.consider( &lowestSoc,@@ -388,16 +445,21 @@ extension HistoryViewModel { let largest = largestDailyUsageKind return PeriodSummary( solarTotalKwh: solarTotal,+ solarCompleteTotalKwh: solarCompleteTotal, solarDayCount: completeDayCount,+ dayCount: dayCount, peakImportTotalKwh: peakImportTotal, offpeakImportTotalKwh: offpeakImportTotal, exportTotalKwh: exportTotal, gridDayCount: gridDayCount, chargeTotalKwh: chargeTotal, dischargeTotalKwh: dischargeTotal,+ dischargeCompleteTotalKwh: dischargeCompleteTotal, batteryDayCount: completeDayCount, dailyUsageTotalKwh: dailyUsageStackTotal,- dailyUsageDayCount: dailyUsageDayCount,+ dailyUsageCompleteTotalKwh: dailyUsageCompleteStackTotal,+ dailyUsageDayCount: dailyUsageCompleteDayCount,+ dailyUsageDisplayDayCount: dailyUsageDisplayDayCount, dailyUsageLargestKind: largest, dailyUsageLargestKindTotalKwh: largest.flatMap { dailyUsageKindSums[$0] } ?? 0, nightTotalKwh: nightTotal,@@ -409,7 +471,7 @@ extension HistoryViewModel { } private var largestDailyUsageKind: DailyUsageBlock.Kind? {- guard dailyUsageDayCount > 0 else { return nil }+ guard dailyUsageCompleteDayCount > 0 else { return nil } return DailyUsageBlock.Kind.largest( among: DailyUsageBlock.Kind.chronologicalOrder.lazy.map { (kind: $0, value: dailyUsageKindSums[$0] ?? 0)
diff --git a/Flux/Flux/History/HistoryChartDomain.swift b/Flux/Flux/History/HistoryChartDomain.swiftnew file mode 100644index 0000000..fb198c2--- /dev/null+++ b/Flux/Flux/History/HistoryChartDomain.swift@@ -0,0 +1,89 @@+import Charts+import FluxCore+import Foundation+import SwiftUI++/// Full-period x-axis reservation for the to-date History ranges. A partly+/// elapsed week or month renders its bars left-aligned with empty slots held+/// for the days not yet elapsed, so the layout is consistent regardless of how+/// far into the period we are. Fixed `.days` ranges always span N days ending+/// today, so the view model returns `nil` for them and the charts auto-fit.+struct HistoryChartDomain: Equatable {+ /// `firstSlot ... endExclusive`, where `endExclusive` is Sydney 00:00 of the+ /// day after the period's last day so the final day's bar slot sits fully+ /// inside the plot.+ let span: ClosedRange<Date>+ /// Sydney 00:00 of every day in the period, first through last inclusive.+ /// Rendered as invisible scaffold bars so Swift Charts infers a consistent+ /// one-day bar width even when only one real day has data (the first-day-of+ /// the-period case).+ let slotDates: [Date]++ /// Builds the domain for a to-date range, or `nil` for a fixed `.days`+ /// range (no reservation needed) or if the calendar arithmetic fails.+ static func make(range: HistoryRange, now: Date, firstWeekday: Int) -> HistoryChartDomain? {+ let calendar = DateFormatting.sydneyCalendar+ let start: Date+ let endExclusive: Date+ switch range {+ case .days:+ return nil+ case .weekToDate:+ // A week is always seven calendar days from the locale week start.+ start = DateFormatting.startOfWeek(now: now, firstWeekday: firstWeekday)+ guard let end = calendar.date(byAdding: .day, value: 7, to: start) else { return nil }+ endExclusive = end+ case .monthToDate:+ // The calendar-month interval gives both boundaries without a+ // hard-coded length, so 28–31-day months are handled uniformly.+ guard let interval = calendar.dateInterval(of: .month, for: now) else { return nil }+ start = interval.start+ endExclusive = interval.end+ }+ return build(start: start, endExclusive: endExclusive, calendar: calendar)+ }++ private static func build(start: Date, endExclusive: Date, calendar: Calendar) -> HistoryChartDomain? {+ guard start < endExclusive else { return nil }+ // Step one calendar day at a time (not interval division) so 23/25-hour+ // DST days keep each slot pinned to Sydney midnight.+ var slots: [Date] = []+ var cursor = start+ while cursor < endExclusive {+ slots.append(cursor)+ guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }+ cursor = next+ }+ guard let first = slots.first else { return nil }+ return HistoryChartDomain(span: first ... endExclusive, slotDates: slots)+ }++ /// Invisible zero-height bars at every slot date. Their presence gives Swift+ /// Charts the one-day spacing it needs to size real bars consistently; they+ /// render nothing and are hidden from VoiceOver. When `slotDates` is empty+ /// (the no-domain case) this contributes nothing to the chart.+ @ChartContentBuilder+ static func scaffold(_ slotDates: [Date]) -> some ChartContent {+ ForEach(slotDates, id: \.self) { date in+ BarMark(+ x: .value("Day", date),+ y: .value("kWh", 0)+ )+ .foregroundStyle(.clear)+ .accessibilityHidden(true)+ }+ }+}++extension View {+ /// Applies `chartXScale(domain:)` when a reservation domain is present, and+ /// is a no-op otherwise so fixed `.days` ranges keep auto-fitting.+ @ViewBuilder+ func historyChartXScale(_ domain: HistoryChartDomain?) -> some View {+ if let domain {+ chartXScale(domain: domain.span)+ } else {+ self+ }+ }+}
diff --git a/Flux/Flux/History/HistoryStatsOverviewCard.swift b/Flux/Flux/History/HistoryStatsOverviewCard.swiftindex 359d360..4f4d404 100644--- a/Flux/Flux/History/HistoryStatsOverviewCard.swift+++ b/Flux/Flux/History/HistoryStatsOverviewCard.swift@@ -96,9 +96,10 @@ extension HistoryStatsOverviewCard { static func valueText(for tile: TileKey, summary: HistoryViewModel.PeriodSummary) -> String { switch tile { case .totalUsage:- return summary.dailyUsageDayCount == 0 ? "—" : HistoryFormatters.kwh(summary.dailyUsageTotalKwh)+ return summary.dailyUsageDisplayDayCount == 0+ ? "—" : HistoryFormatters.kwh(summary.dailyUsageTotalKwh) case .totalSolar:- return summary.solarDayCount == 0 ? "—" : HistoryFormatters.kwh(summary.solarTotalKwh)+ return summary.dayCount == 0 ? "—" : HistoryFormatters.kwh(summary.solarTotalKwh) case .exported: return summary.gridDayCount == 0 ? "—" : HistoryFormatters.kwh(summary.exportTotalKwh) case .peakImports:@@ -168,11 +169,11 @@ extension HistoryStatsOverviewCard { let label = label(for: tile) switch tile { case .totalUsage:- return summary.dailyUsageDayCount == 0+ return summary.dailyUsageDisplayDayCount == 0 ? "\(label), no data" : "\(label), \(HistoryStatsFormatters.accessibleKwh(summary.dailyUsageTotalKwh))" case .totalSolar:- return summary.solarDayCount == 0+ return summary.dayCount == 0 ? "\(label), no data" : "\(label), \(HistoryStatsFormatters.accessibleKwh(summary.solarTotalKwh))" case .exported:@@ -298,16 +299,21 @@ private func previewSummary(populated: Bool) -> HistoryViewModel.PeriodSummary { let date = DateFormatting.parseDayDate("2026-04-13")! return HistoryViewModel.PeriodSummary( solarTotalKwh: 98.4,+ solarCompleteTotalKwh: 98.4, solarDayCount: 6,+ dayCount: 7, peakImportTotalKwh: 7.2, offpeakImportTotalKwh: 12.4, exportTotalKwh: 14.0, gridDayCount: 6, chargeTotalKwh: 24.5, dischargeTotalKwh: 22.0,+ dischargeCompleteTotalKwh: 22.0, batteryDayCount: 6, dailyUsageTotalKwh: 64.5,+ dailyUsageCompleteTotalKwh: 64.5, dailyUsageDayCount: 6,+ dailyUsageDisplayDayCount: 7, dailyUsageLargestKind: .evening, dailyUsageLargestKindTotalKwh: 18.0, nightTotalKwh: 12.0,@@ -343,11 +349,12 @@ private func previewEntries() -> [HistoryViewModel.SolarEntry] { #Preview("HistoryStatsOverviewCard — Mac, mixed em-dash") { let mixed = HistoryViewModel.PeriodSummary(- solarTotalKwh: 98.4, solarDayCount: 6,+ solarTotalKwh: 98.4, solarCompleteTotalKwh: 98.4, solarDayCount: 6, dayCount: 7, peakImportTotalKwh: 0, offpeakImportTotalKwh: 0, exportTotalKwh: 0, gridDayCount: 0,- chargeTotalKwh: 0, dischargeTotalKwh: 0, batteryDayCount: 6,- dailyUsageTotalKwh: 64.5, dailyUsageDayCount: 6,+ chargeTotalKwh: 0, dischargeTotalKwh: 0, dischargeCompleteTotalKwh: 0, batteryDayCount: 6,+ dailyUsageTotalKwh: 64.5, dailyUsageCompleteTotalKwh: 64.5,+ dailyUsageDayCount: 6, dailyUsageDisplayDayCount: 7, dailyUsageLargestKind: .evening, dailyUsageLargestKindTotalKwh: 18.0, nightTotalKwh: 0, nightBlockDayCount: 0, mostUsageDay: nil,
diff --git a/Flux/Flux/History/HistoryDailyUsageCard.swift b/Flux/Flux/History/HistoryDailyUsageCard.swiftindex 3199cd5..a4ec0b7 100644--- a/Flux/Flux/History/HistoryDailyUsageCard.swift+++ b/Flux/Flux/History/HistoryDailyUsageCard.swift@@ -9,6 +9,9 @@ struct HistoryDailyUsageCard: View { let summary: HistoryViewModel.PeriodSummary let selectedDate: Date? let rangeDays: Int+ /// Full-period x-axis reservation (Wk/Mo). `nil` (the default, used by the+ /// expanded host which only knows a day count) leaves the chart auto-fitting.+ var chartDomain: HistoryChartDomain? let onSelect: (String) -> Void var expansionScope: ChartScope { .historyRange(days: rangeDays) }@@ -36,7 +39,10 @@ struct HistoryDailyUsageCard: View { } static func shouldShowPlaceholder(summary: HistoryViewModel.PeriodSummary) -> Bool {- summary.dailyUsageDayCount == 0+ // Display count includes today, so a today-only range still renders+ // today's bar instead of the "no breakdown" placeholder. The KPI and+ // subtitle (per-day averages) stay em-dash until a complete day exists.+ summary.dailyUsageDisplayDayCount == 0 } static func kpi(for summary: HistoryViewModel.PeriodSummary) -> String {@@ -65,7 +71,12 @@ struct HistoryDailyUsageCard: View { private var chart: some View { let kinds = DailyUsageBlock.Kind.chronologicalOrder+ // Base the axis stride on the reserved slot count when a domain is set,+ // so a sparse to-date month still gets ~6 evenly-spaced labels.+ let axisDayCount = chartDomain?.slotDates.count ?? entries.count return Chart {+ HistoryChartDomain.scaffold(chartDomain?.slotDates ?? [])+ if let selectedDate { RuleMark(x: .value("Day", selectedDate)) .foregroundStyle(.gray.opacity(0.18))@@ -87,8 +98,9 @@ struct HistoryDailyUsageCard: View { domain: kinds.map(\.displayLabel), range: kinds.map(\.chartColor) )+ .historyChartXScale(chartDomain) .chartXAxis {- AxisMarks(values: .stride(by: .day, count: max(1, entries.count / 6)))+ AxisMarks(values: .stride(by: .day, count: max(1, axisDayCount / 6))) } .animation(.default, value: entries.count) .accessibilityElement(children: .ignore)
diff --git a/Flux/Flux/History/HistoryGridUsageCard.swift b/Flux/Flux/History/HistoryGridUsageCard.swiftindex e8868ad..8e41ff6 100644--- a/Flux/Flux/History/HistoryGridUsageCard.swift+++ b/Flux/Flux/History/HistoryGridUsageCard.swift@@ -8,6 +8,9 @@ struct HistoryGridUsageCard: View { let summary: HistoryViewModel.PeriodSummary let selectedDate: Date? let rangeDays: Int+ /// Full-period x-axis reservation (Wk/Mo). `nil` (the default, used by the+ /// expanded host which only knows a day count) leaves the chart auto-fitting.+ var chartDomain: HistoryChartDomain? let onSelect: (String) -> Void var expansionScope: ChartScope { .historyRange(days: rangeDays) }@@ -58,6 +61,8 @@ struct HistoryGridUsageCard: View { @ViewBuilder private var chart: some View { Chart {+ HistoryChartDomain.scaffold(chartDomain?.slotDates ?? [])+ if let selectedDate { RuleMark(x: .value("Day", selectedDate)) .foregroundStyle(.gray.opacity(0.18))@@ -96,6 +101,7 @@ struct HistoryGridUsageCard: View { "Peak import": Color.red, "Export": Color.blue ])+ .historyChartXScale(chartDomain) .historySelectionOverlay( entries: entries.map { ($0.dayID, $0.date) }, onSelect: onSelect
diff --git a/Flux/Flux/History/HistorySolarCard.swift b/Flux/Flux/History/HistorySolarCard.swiftindex eeb1d12..87648c0 100644--- a/Flux/Flux/History/HistorySolarCard.swift+++ b/Flux/Flux/History/HistorySolarCard.swift@@ -8,6 +8,9 @@ struct HistorySolarCard: View { let summary: HistoryViewModel.PeriodSummary let selectedDate: Date? let rangeDays: Int+ /// Full-period x-axis reservation (Wk/Mo). `nil` (the default, used by the+ /// expanded host which only knows a day count) leaves the chart auto-fitting.+ var chartDomain: HistoryChartDomain? let onSelect: (String) -> Void var expansionScope: ChartScope { .historyRange(days: rangeDays) }@@ -38,6 +41,8 @@ struct HistorySolarCard: View { @ViewBuilder private var chart: some View { Chart {+ HistoryChartDomain.scaffold(chartDomain?.slotDates ?? [])+ if let selectedDate { RuleMark(x: .value("Day", selectedDate)) .foregroundStyle(.gray.opacity(0.18))@@ -59,6 +64,7 @@ struct HistorySolarCard: View { .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) } }+ .historyChartXScale(chartDomain) .historySelectionOverlay( entries: entries.map { ($0.dayID, $0.date) }, onSelect: onSelect
diff --git a/Flux/Flux/History/HistoryViewModel.swift b/Flux/Flux/History/HistoryViewModel.swiftindex 40ef996..9eacafa 100644--- a/Flux/Flux/History/HistoryViewModel.swift+++ b/Flux/Flux/History/HistoryViewModel.swift@@ -14,6 +14,10 @@ final class HistoryViewModel { /// Read by `HistoryView` for the cards' `rangeDays:` (which carries the /// expansion scope's `N`). private(set) var resolvedRangeDays: Int = 7+ /// The range whose data is currently in `days`. Drives `chartDomain` so the+ /// charts' x-axis reservation matches the rendered data, not an in-flight+ /// selection.+ private(set) var resolvedRange: HistoryRange = .days(7) private let apiClient: any FluxAPIClient private let modelContext: ModelContext@@ -78,6 +82,7 @@ final class HistoryViewModel { let now = nowProvider() let resolvedDays = range.resolvedDays(now: now, firstWeekday: firstWeekdayProvider()) resolvedRangeDays = resolvedDays+ resolvedRange = range do { let response = try await apiClient.fetchHistory(days: resolvedDays)@@ -207,6 +212,17 @@ extension HistoryViewModel { DerivedState(days: days, now: nowProvider()) } + /// Full-period x-axis reservation for the to-date ranges (Wk → full week,+ /// Mo → full calendar month), or `nil` for the fixed `.days` ranges, which+ /// always span N days ending today and so need no reservation.+ var chartDomain: HistoryChartDomain? {+ HistoryChartDomain.make(+ range: resolvedRange,+ now: nowProvider(),+ firstWeekday: firstWeekdayProvider()+ )+ }+ /// Convenience accessors for tests and previews. Each rebuilds /// `DerivedState` independently — production callers should read /// `derived` once and destructure instead.
diff --git a/Flux/Flux/History/HistoryView.swift b/Flux/Flux/History/HistoryView.swiftindex 0aad75d..e7c4f58 100644--- a/Flux/Flux/History/HistoryView.swift+++ b/Flux/Flux/History/HistoryView.swift@@ -197,6 +197,7 @@ struct HistoryView: View { summary: derived.summary, selectedDate: selectedDate, rangeDays: viewModel.resolvedRangeDays,+ chartDomain: viewModel.chartDomain, onSelect: selectDay ) }@@ -208,6 +209,7 @@ struct HistoryView: View { summary: derived.summary, selectedDate: selectedDate, rangeDays: viewModel.resolvedRangeDays,+ chartDomain: viewModel.chartDomain, onSelect: selectDay ) }@@ -219,6 +221,7 @@ struct HistoryView: View { summary: derived.summary, selectedDate: selectedDate, rangeDays: viewModel.resolvedRangeDays,+ chartDomain: viewModel.chartDomain, onSelect: selectDay ) }
diff --git a/Flux/FluxTests/HistoryChartDomainTests.swift b/Flux/FluxTests/HistoryChartDomainTests.swiftnew file mode 100644index 0000000..f7d1371--- /dev/null+++ b/Flux/FluxTests/HistoryChartDomainTests.swift@@ -0,0 +1,78 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@Suite+struct HistoryChartDomainTests {+ private let calendar = DateFormatting.sydneyCalendar++ /// Sydney-local wall-clock date for an unambiguous assertion baseline.+ private func sydneyDate(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date {+ calendar.date(from: DateComponents(+ timeZone: DateFormatting.sydneyTimeZone,+ year: year, month: month, day: day, hour: hour+ ))!+ }++ @Test("Fixed .days ranges reserve nothing")+ func fixedRangesReturnNil() {+ let now = sydneyDate(2026, 6, 10)+ #expect(HistoryChartDomain.make(range: .days(7), now: now, firstWeekday: 2) == nil)+ #expect(HistoryChartDomain.make(range: .days(14), now: now, firstWeekday: 1) == nil)+ #expect(HistoryChartDomain.make(range: .days(30), now: now, firstWeekday: 2) == nil)+ }++ @Test("Week-to-date reserves a full 7-day week from the week start")+ func weekToDateReservesFullWeek() throws {+ // 2026-06-10 is a Wednesday; Monday-start week begins 2026-06-08.+ let now = sydneyDate(2026, 6, 10)+ let domain = try #require(HistoryChartDomain.make(range: .weekToDate, now: now, firstWeekday: 2))++ let weekStart = DateFormatting.startOfWeek(now: now, firstWeekday: 2)+ #expect(domain.slotDates.count == 7)+ #expect(domain.slotDates.first == weekStart)+ #expect(domain.span.lowerBound == weekStart)+ // Upper bound is exclusive: Sydney 00:00 of the next week's first day.+ #expect(domain.span.upperBound == calendar.date(byAdding: .day, value: 7, to: weekStart))+ // Every slot is a distinct Sydney midnight one day apart.+ #expect(domain.slotDates.last == calendar.date(byAdding: .day, value: 6, to: weekStart))+ #expect(domain.slotDates.allSatisfy { calendar.startOfDay(for: $0) == $0 })+ }++ @Test("Week start honours the locale first weekday")+ func weekStartFollowsFirstWeekday() throws {+ let now = sydneyDate(2026, 6, 10) // Wednesday+ let mondayStart = try #require(HistoryChartDomain.make(range: .weekToDate, now: now, firstWeekday: 2))+ let sundayStart = try #require(HistoryChartDomain.make(range: .weekToDate, now: now, firstWeekday: 1))+ // Monday-start begins 2026-06-08; Sunday-start begins 2026-06-07.+ #expect(mondayStart.slotDates.first == sydneyDate(2026, 6, 8, hour: 0))+ #expect(sundayStart.slotDates.first == sydneyDate(2026, 6, 7, hour: 0))+ #expect(mondayStart.slotDates.count == 7)+ #expect(sundayStart.slotDates.count == 7)+ }++ @Test("Month-to-date reserves every day of the calendar month")+ func monthToDateReservesFullMonth() throws {+ // June has 30 days.+ let now = sydneyDate(2026, 6, 9)+ let domain = try #require(HistoryChartDomain.make(range: .monthToDate, now: now, firstWeekday: 2))++ let monthStart = DateFormatting.startOfMonth(now: now)+ #expect(domain.slotDates.count == 30)+ #expect(domain.slotDates.first == monthStart)+ #expect(domain.span.lowerBound == monthStart)+ #expect(domain.span.upperBound == sydneyDate(2026, 7, 1, hour: 0), "exclusive bound is the 1st of next month")+ }++ @Test("Month-to-date across the DST boundary stays day-aligned")+ func monthToDateHandlesDST() throws {+ // Sydney DST ends in early April 2026; April still has 30 calendar days,+ // and calendar-day arithmetic keeps every slot at Sydney midnight.+ let now = sydneyDate(2026, 4, 15)+ let domain = try #require(HistoryChartDomain.make(range: .monthToDate, now: now, firstWeekday: 2))+ #expect(domain.slotDates.count == 30)+ #expect(domain.slotDates.allSatisfy { calendar.startOfDay(for: $0) == $0 })+ #expect(domain.span.upperBound == sydneyDate(2026, 5, 1, hour: 0))+ }+}
diff --git a/Flux/FluxTests/HistoryViewModelOverviewTests.swift b/Flux/FluxTests/HistoryViewModelOverviewTests.swiftindex cfd3612..fb24f18 100644--- a/Flux/FluxTests/HistoryViewModelOverviewTests.swift+++ b/Flux/FluxTests/HistoryViewModelOverviewTests.swift@@ -22,8 +22,8 @@ struct HistoryViewModelOverviewTests { #expect(summary.lowestSocDay == nil) } - @Test("(b) only-today: complete-day fields empty; Lowest SoC populated when today has socLow")- func onlyTodayPopulatesOnlyLowestSoc() {+ @Test("(b) only-today: hard-number totals/records include today; averages stay empty")+ func onlyTodayPopulatesTotalsAndRecords() { let today = day( "2026-04-16", epv: 5.0, eInput: 1.0, eOutput: 0.5, eCharge: 1.0, eDischarge: 1.0, socLow: 22.7, socLowTime: "14:30:00",@@ -32,11 +32,23 @@ struct HistoryViewModelOverviewTests { let derived = HistoryViewModel.DerivedState(days: [today], now: now(month: 4, day: 16)) let summary = derived.summary + // Hard numbers — today is the only data and now contributes.+ #expect(abs(summary.solarTotalKwh - 5.0) < 0.001)+ #expect(abs(summary.dailyUsageTotalKwh - 2.5) < 0.001)+ #expect(summary.dayCount == 1)+ #expect(summary.dailyUsageDisplayDayCount == 1)+ #expect(summary.mostUsageDay?.dayID == "2026-04-16")+ #expect(abs((summary.mostUsageDay?.kwh ?? 0) - 2.5) < 0.001)+ #expect(summary.mostSolarDay?.dayID == "2026-04-16")+ #expect(abs((summary.mostSolarDay?.kwh ?? 0) - 5.0) < 0.001)++ // Averages — no complete day yet, so they remain undefined.+ #expect(summary.solarDayCount == 0)+ #expect(summary.solarPerDayKwh == nil)+ #expect(summary.dailyUsageAvgKwh == nil) #expect(summary.nightTotalKwh == 0) #expect(summary.nightBlockDayCount == 0) #expect(summary.nightAvgKwh == nil)- #expect(summary.mostUsageDay == nil)- #expect(summary.mostSolarDay == nil) let record = try? #require(summary.lowestSocDay) #expect(record?.dayID == "2026-04-16")
diff --git a/Flux/FluxTests/HistoryViewModelDailyUsageTests.swift b/Flux/FluxTests/HistoryViewModelDailyUsageTests.swiftindex a0461ae..fc646ea 100644--- a/Flux/FluxTests/HistoryViewModelDailyUsageTests.swift+++ b/Flux/FluxTests/HistoryViewModelDailyUsageTests.swift@@ -130,6 +130,11 @@ struct HistoryViewModelDailyUsageTests { let derived = viewModel.derived #expect(derived.dailyUsage.count == 1) #expect(derived.dailyUsage.first?.isToday == true)+ // Hard numbers include today's breakdown…+ #expect(derived.summary.dailyUsageDisplayDayCount == 1)+ #expect(abs(derived.summary.dailyUsageTotalKwh - 5.0) < 0.001)+ #expect(derived.summary.mostUsageDay?.dayID == "2026-04-16")+ // …but the per-day average and largest-kind stay undefined (no complete day). #expect(derived.summary.dailyUsageDayCount == 0) #expect(derived.summary.dailyUsageLargestKind == nil) #expect(derived.summary.dailyUsageAvgKwh == nil)@@ -176,9 +181,14 @@ struct HistoryViewModelDailyUsageTests { #expect(derived.dailyUsage.count == 2) let today = try #require(derived.dailyUsage.first(where: { $0.dayID == "2026-04-16" })) #expect(today.isToday)- #expect(derived.summary.dailyUsageDayCount == 1)- #expect(abs(derived.summary.dailyUsageTotalKwh - 5.0) < 0.001, "today excluded from total")- #expect(abs((derived.summary.dailyUsageAvgKwh ?? -1) - 5.0) < 0.001)+ #expect(derived.summary.dailyUsageDayCount == 1, "average denominator excludes today")+ #expect(derived.summary.dailyUsageDisplayDayCount == 2, "display count includes today")+ // Total is a hard number: yesterday 5.0 + today's partial (2.0 + 0.4).+ #expect(abs(derived.summary.dailyUsageTotalKwh - 7.4) < 0.001, "today included in total")+ #expect(+ abs((derived.summary.dailyUsageAvgKwh ?? -1) - 5.0) < 0.001,+ "per-day average still excludes today's partial day"+ ) } @Test
diff --git a/Flux/FluxTests/HistoryViewModelTests.swift b/Flux/FluxTests/HistoryViewModelTests.swiftindex ee06778..2549cb1 100644--- a/Flux/FluxTests/HistoryViewModelTests.swift+++ b/Flux/FluxTests/HistoryViewModelTests.swift@@ -175,8 +175,13 @@ struct HistoryViewModelTests { await viewModel.loadHistory(range: .days(7)) let summary = viewModel.summary- #expect(summary.solarDayCount == 1, "today is excluded from completed-day count")- #expect(abs(summary.solarTotalKwh - 10.0) < 0.001, "today's solar excluded from total")+ #expect(summary.solarDayCount == 1, "today is excluded from completed-day count (average basis)")+ #expect(summary.dayCount == 2, "today is included in the all-days count")+ #expect(abs(summary.solarTotalKwh - 18.0) < 0.001, "today's solar included in the total (10 + 8)")+ #expect(+ abs((summary.solarPerDayKwh ?? -1) - 10.0) < 0.001,+ "per-day average still excludes today's partial solar"+ ) #expect(summary.gridDayCount == 2, "today's grid is still counted in the off-peak split") #expect(abs(summary.peakImportTotalKwh - (1.5 + 1.5)) < 0.001, "peak = (4-2.5) + (3-1.5)") #expect(abs(summary.offpeakImportTotalKwh - 4.0) < 0.001)
diff --git a/Flux/FluxTests/HistoryDailyUsageCardTests.swift b/Flux/FluxTests/HistoryDailyUsageCardTests.swiftindex dc4b7f0..b863202 100644--- a/Flux/FluxTests/HistoryDailyUsageCardTests.swift+++ b/Flux/FluxTests/HistoryDailyUsageCardTests.swift@@ -25,10 +25,15 @@ struct HistoryDailyUsageCardTests { } @Test- func placeholderRendersWhenOnlyTodayHasBlocks() {- // Single today entry exists, but dailyUsageDayCount excludes today.- let summary = makeSummary(largest: nil, avg: nil, totalKwh: 0, dayCount: 0)- #expect(HistoryDailyUsageCard.shouldShowPlaceholder(summary: summary))+ func chartRendersWhenOnlyTodayHasBlocks() {+ // Today has a breakdown but no complete day does: the chart shows+ // today's bar (display count > 0), so the placeholder is suppressed.+ // The KPI/subtitle stay em-dash because the per-day average needs a+ // complete day.+ let summary = makeSummary(largest: nil, avg: nil, totalKwh: 4.0, dayCount: 0, displayDayCount: 1)+ #expect(HistoryDailyUsageCard.shouldShowPlaceholder(summary: summary) == false)+ #expect(HistoryDailyUsageCard.kpi(for: summary) == "—")+ #expect(HistoryDailyUsageCard.subtitle(for: summary) == nil) } @Test@@ -79,25 +84,32 @@ struct HistoryDailyUsageCardTests { ) } + // swiftlint:disable:next function_default_parameter_at_end private func makeSummary( largest: DailyUsageBlock.Kind?, avg _: Double?, totalKwh: Double, dayCount: Int,+ displayDayCount: Int = 0, eveningSum: Double = 0 ) -> HistoryViewModel.PeriodSummary { HistoryViewModel.PeriodSummary( solarTotalKwh: 0,+ solarCompleteTotalKwh: 0, solarDayCount: 0,+ dayCount: 0, peakImportTotalKwh: 0, offpeakImportTotalKwh: 0, exportTotalKwh: 0, gridDayCount: 0, chargeTotalKwh: 0, dischargeTotalKwh: 0,+ dischargeCompleteTotalKwh: 0, batteryDayCount: 0, dailyUsageTotalKwh: totalKwh,+ dailyUsageCompleteTotalKwh: totalKwh, dailyUsageDayCount: dayCount,+ dailyUsageDisplayDayCount: displayDayCount, dailyUsageLargestKind: largest, dailyUsageLargestKindTotalKwh: eveningSum, nightTotalKwh: 0,
diff --git a/Flux/FluxTests/HistoryStatsOverviewCardTests.swift b/Flux/FluxTests/HistoryStatsOverviewCardTests.swiftindex 2542d66..40b37e6 100644--- a/Flux/FluxTests/HistoryStatsOverviewCardTests.swift+++ b/Flux/FluxTests/HistoryStatsOverviewCardTests.swift@@ -136,7 +136,7 @@ struct HistoryStatsOverviewCardTests { @Test("Stat tile spells out kilowatt hours") func statTileAccessibilityLabel() {- let summary = makeSummary(solarTotalKwh: 98.4, solarDayCount: 5)+ let summary = makeSummary(solarTotalKwh: 98.4, solarDayCount: 5, dayCount: 5) #expect(HistoryStatsOverviewCard.accessibilityLabel(tile: .totalSolar, summary: summary) == "Total solar, 98.4 kilowatt hours") }@@ -223,9 +223,11 @@ struct HistoryStatsOverviewCardTests { DateFormatting.parseDayDate(dayID)! } + // swiftlint:disable:next function_default_parameter_at_end private func makeSummary( solarTotalKwh: Double = 0, solarDayCount: Int = 0,+ dayCount: Int = 0, peakImportTotalKwh: Double = 0, offpeakImportTotalKwh: Double = 0, exportTotalKwh: Double = 0,@@ -235,6 +237,7 @@ struct HistoryStatsOverviewCardTests { batteryDayCount: Int = 0, dailyUsageTotalKwh: Double = 0, dailyUsageDayCount: Int = 0,+ dailyUsageDisplayDayCount: Int = 0, dailyUsageLargestKind: DailyUsageBlock.Kind? = nil, dailyUsageLargestKindTotalKwh: Double = 0, nightTotalKwh: Double = 0,@@ -245,16 +248,21 @@ struct HistoryStatsOverviewCardTests { ) -> HistoryViewModel.PeriodSummary { HistoryViewModel.PeriodSummary( solarTotalKwh: solarTotalKwh,+ solarCompleteTotalKwh: solarTotalKwh, solarDayCount: solarDayCount,+ dayCount: dayCount, peakImportTotalKwh: peakImportTotalKwh, offpeakImportTotalKwh: offpeakImportTotalKwh, exportTotalKwh: exportTotalKwh, gridDayCount: gridDayCount, chargeTotalKwh: chargeTotalKwh, dischargeTotalKwh: dischargeTotalKwh,+ dischargeCompleteTotalKwh: dischargeTotalKwh, batteryDayCount: batteryDayCount, dailyUsageTotalKwh: dailyUsageTotalKwh,+ dailyUsageCompleteTotalKwh: dailyUsageTotalKwh, dailyUsageDayCount: dailyUsageDayCount,+ dailyUsageDisplayDayCount: dailyUsageDisplayDayCount, dailyUsageLargestKind: dailyUsageLargestKind, dailyUsageLargestKindTotalKwh: dailyUsageLargestKindTotalKwh, nightTotalKwh: nightTotalKwh,
diff --git a/Flux/FluxTests/Charts/ExpandedHistoryHostTests.swift b/Flux/FluxTests/Charts/ExpandedHistoryHostTests.swiftindex a6ac8c3..3b723c0 100644--- a/Flux/FluxTests/Charts/ExpandedHistoryHostTests.swift+++ b/Flux/FluxTests/Charts/ExpandedHistoryHostTests.swift@@ -92,16 +92,21 @@ struct ExpandedHistoryHostTests { dailyUsage: [], summary: HistoryViewModel.PeriodSummary( solarTotalKwh: solarTotal,+ solarCompleteTotalKwh: solarTotal, solarDayCount: solarTotal > 0 ? 1 : 0,+ dayCount: solarTotal > 0 ? 1 : 0, peakImportTotalKwh: 0, offpeakImportTotalKwh: 0, exportTotalKwh: 0, gridDayCount: 0, chargeTotalKwh: 0, dischargeTotalKwh: 0,+ dischargeCompleteTotalKwh: 0, batteryDayCount: 0, dailyUsageTotalKwh: 0,+ dailyUsageCompleteTotalKwh: 0, dailyUsageDayCount: 0,+ dailyUsageDisplayDayCount: 0, dailyUsageLargestKind: nil, dailyUsageLargestKindTotalKwh: 0, nightTotalKwh: 0,
diff --git a/specs/history-usage-stats/decision_log.md b/specs/history-usage-stats/decision_log.mdindex 8f58aca..4e87f4b 100644--- a/specs/history-usage-stats/decision_log.md+++ b/specs/history-usage-stats/decision_log.md@@ -463,3 +463,44 @@ The alternative was keeping `kpi: String` and passing an empty string, which sti - One existing struct grew a new optional parameter; trivial drift in the public API of the chrome. ---++## Decision 15: Hard-number totals and day-records include today; only averages exclude it++**Date**: 2026-06-08+**Status**: accepted (supersedes the "exclude today" half of Decision 5)++### Context++Decision 5 excluded today from Total solar, Total usage, Most usage, and Most solar to keep the overview tiles reconciling 1:1 with the chart-card KPIs. In use this proved surprising: a tile labelled "Most usage" that ignores today reads as wrong from a user's perspective — if today already has the highest usage of the range, the tile should say so. The user asked that any tile carrying a *hard number* (a total or a single-day max/min) account for today, while accepting that *averages* must keep excluding today's partial day.++### Decision++Split the period aggregates by kind rather than by tile:++- **Totals** (Total solar, Total usage) and the **Battery card totals** (discharged / charged) now sum every day in range, today included — matching the grid totals (Peak imports, Exported) which already did.+- **Day-records** (Most usage, Most solar) now consider today, joining Lowest SoC which already did.+- **Per-day averages** (Solar /day, Battery /day discharged, Daily-usage /day, Avg night, largest-kind /day) keep excluding today: each divides a complete-days-only total by a complete-days count.++Because a total and its average previously shared one field (e.g. `solarTotalKwh` fed both the tile and `solarPerDayKwh`), `PeriodSummary` now carries a separate complete-days numerator for each average (`solarCompleteTotalKwh`, `dischargeCompleteTotalKwh`, `dailyUsageCompleteTotalKwh`) and a display-oriented day count (`dayCount`, `dailyUsageDisplayDayCount`) that gates the Total tiles and the Daily-usage chart placeholder so a today-only range shows today's number and bar instead of an em-dash.++### Rationale++"Hard numbers include today" is the rule the grid totals and Lowest SoC already followed, so this makes the asymmetry of Decision 5 *consistent* (everything-but-averages includes today) rather than per-tile. Averages still exclude the partial day, which was the only sound part of the original concern. Cross-card reconciliation is preserved because each chart card's headline total reads from the same now-inclusive field as its overview tile.++### Alternatives Considered++- **Keep Decision 5 unchanged**: rejected — the user explicitly flagged the today-blind "Most usage" as wrong.+- **Include today everywhere, averages too**: rejected — a partial day skews per-day averages, the one case Decision 5 got right.+- **Add a today-delta and subtract it for averages**: rejected — storing explicit complete-days numerators is self-documenting and keeps the existing total/count→average pattern.++### Consequences++**Positive:**+- "Most usage / Most solar / Total usage / Total solar" reflect today, matching user expectation and the grid/SoC tiles.+- A today-only range (e.g. Week-to-date on the week's first day) now shows real numbers and the daily-usage bar instead of em-dashes.+- Averages remain unskewed by the partial day.++**Negative:**+- `PeriodSummary` grew five fields (three complete-days numerators, two display counts) to keep totals and averages independent.++---
diff --git a/specs/history-usage-stats/requirements.md b/specs/history-usage-stats/requirements.mdindex fd0524e..9f11870 100644--- a/specs/history-usage-stats/requirements.md+++ b/specs/history-usage-stats/requirements.md@@ -19,17 +19,18 @@ The History screen renders four chart cards (Solar, Grid, Battery, Daily Usage) ## Definitions -- **complete day**: a day in the range whose date is strictly before today, resolved through `DateFormatting.isToday(_:now:)` (the same boundary every other History aggregate uses).+- **complete day**: a day in the range whose date is strictly before today, resolved through `DateFormatting.isToday(_:now:)`. After [Decision 15](decision_log.md) this boundary applies only to the per-day *averages*, not to totals or day-records (which now include today). - **day-with-blocks**: a day whose `DayEnergy.dailyUsage` is present and has at least one block (per `history-daily-usage` spec). - **day-with-offpeak**: a day whose `DayEnergy.offpeakGridImportKwh` is non-nil. Days without an off-peak record are excluded from the Peak imports and Exported tiles because the off-peak / peak split cannot be derived without it. - **day-with-night-block**: a day-with-blocks whose blocks include a `night` block. - **day-with-low**: a day whose `DayEnergy.socLow` is non-nil. - **stat tile**: one labelled value rendered inside the overview card. -Today inclusion across the eight tiles:+Today inclusion across the eight tiles (updated by [Decision 15](decision_log.md), which superseded the "exclude today" half of [Decision 5](decision_log.md)):+- **Total usage, Total solar, Most usage, Most solar** include today — they are hard numbers (totals and single-day maxes). - **Lowest SoC** includes today via the `socLow` semantics already in `DayEnergy`. - **Peak imports** and **Exported** include today when today has an off-peak record, because they reuse the existing `peakImportTotalKwh` / `exportTotalKwh` aggregates which do not gate on `!isToday`.-- All other tiles (Total usage, Total solar, Avg night, Most usage, Most solar) exclude today via the same `DateFormatting.isToday(_:now:)` boundary the rest of `PeriodSummary` already applies.+- **Avg night** excludes today — it is a per-day average and a partial day would skew it. The complete-days numerators backing the other per-day averages (Solar/day, Battery/day, Daily-usage/day) likewise exclude today. ## 1. iOS / macOS: Overview Card Placement and Chrome @@ -52,13 +53,13 @@ The eight tiles SHALL be defined as follows. Each tile renders a label and a val | # | Tile label | Value | Cohort | |---|---|---|---|-| 2.1 | Total usage | `sum(dailyUsage.stackedTotalKwh)` | complete days-with-blocks |-| 2.2 | Total solar | `sum(epv)` | complete days |+| 2.1 | Total usage | `sum(dailyUsage.stackedTotalKwh)` | days-with-blocks in range, today included (Decision 15) |+| 2.2 | Total solar | `sum(epv)` | all days in range, today included (Decision 15) | | 2.3 | Exported | `sum(eOutput)` | days-with-offpeak in range (today included when it has an off-peak record) | | 2.4 | Peak imports | `sum(eInput − offpeakGridImportKwh)`, each summand clamped ≥ 0 | days-with-offpeak in range (today included when it has an off-peak record) |-| 2.5 | Avg night | `sum(night-block kWh) / count(days-with-night-block)` | complete days-with-night-block |-| 2.6 | Most usage | `max(dailyUsage.stackedTotalKwh)` | complete days-with-blocks |-| 2.7 | Most solar | `max(epv)` | complete days |+| 2.5 | Avg night | `sum(night-block kWh) / count(days-with-night-block)` | complete days-with-night-block (today excluded — average) |+| 2.6 | Most usage | `max(dailyUsage.stackedTotalKwh)` | days-with-blocks in range, today included (Decision 15) |+| 2.7 | Most solar | `max(epv)` | all days in range, today included (Decision 15) | | 2.8 | Lowest SoC | `min(socLow)` | days-with-low (today included if it has a `socLow`) | 1. <a name="2.1"></a>**Total usage** — Label `"Total usage"`. Value formatted via `HistoryFormatters.kwh`. Tile renders an em-dash `"—"` for the value WHEN the cohort is empty.
diff --git a/specs/history-usage-stats/design.md b/specs/history-usage-stats/design.mdindex 6db2e4a..f49ca7e 100644--- a/specs/history-usage-stats/design.md+++ b/specs/history-usage-stats/design.md@@ -43,6 +43,8 @@ A new `HistoryStatsOverviewCard` rendering eight stat tiles for the active 7 / 1 ## Components and Interfaces +> **Superseded in part by [Decision 15](decision_log.md).** This section describes the design as originally built, where totals and day-records were computed over *complete days only*. Decision 15 later moved totals and day-records (Total solar/usage, Most usage/solar) to **include today**, leaving only the per-day averages on the complete-day basis. As part of that change `PeriodSummary` gained complete-days numerators (`solarCompleteTotalKwh`, `dischargeCompleteTotalKwh`, `dailyUsageCompleteTotalKwh`) and today-inclusive display counts (`dayCount`, `dailyUsageDisplayDayCount`), and the `addCompleteDay` / `considerSocLow` entry points described below were folded into a single `addDay(_:parsedDate:isToday:dailyUsageEntry:)` that does the display aggregates for every day and the average bases only for `!isToday`. See `HistoryDerivedState.swift` for the current shape; the prose below is retained for historical context.+ ### `HistoryViewModel.PeriodSummary` additions The existing four fields (`solarTotalKwh`, `exportTotalKwh`, `peakImportTotalKwh`, `dailyUsageTotalKwh`) cover tiles 2.1 / 2.2 / 2.3 / 2.4. Four new fields cover tiles 2.5 – 2.8:
diff --git a/specs/history-month-week-to-date/decision_log.md b/specs/history-month-week-to-date/decision_log.mdindex 0f59331..a36e875 100644--- a/specs/history-month-week-to-date/decision_log.md+++ b/specs/history-month-week-to-date/decision_log.md@@ -167,3 +167,45 @@ Date-bounding is the only way to guarantee no pre-boundary day appears, and appl - 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. ---++## Decision 7: To-date charts reserve the full period on the x-axis++**Date**: 2026-06-09+**Status**: accepted++### Context++The History charts size their x-axis to the data they hold. For a to-date range early in the period this looks inconsistent: Week-to-date on a Monday draws a single full-width bar, and the bar geometry shifts every day as more days arrive. A user asked that a partly-elapsed week or month hold space for the days not yet elapsed, so the first day's bar sits at the far left as if the rest of the period were already there.++### Decision++For **Week-to-date** and **Month-to-date** only, reserve the whole period on the x-axis:++- Week-to-date → a full 7-day week from the locale week start.+- Month-to-date → the full calendar month (28–31 days) from the 1st.++A `HistoryChartDomain` value (computed by `HistoryViewModel` from the resolved range, Sydney `now`, and the locale first weekday) carries the `ClosedRange<Date>` span and the list of Sydney-midnight slot dates. Charts apply it via `chartXScale(domain:)` and render invisible zero-height scaffold bars at every slot so Swift Charts infers a consistent one-day bar width even when only one real day has data. Fixed 7/14/30-day ranges pass `nil` and keep auto-fitting — they always span N days ending today, so they need no reservation. The reservation is scoped to the inline History cards; the enlarged-chart host passes `nil` because its `ChartScope` carries only a day count, not the range type.++### Rationale++`chartXScale(domain:)` is already the proven mechanism for a fixed time axis in this codebase (Day Detail's `DayChartDomain`). Reserving the full calendar period — rather than padding to a trailing N-days window — is what "space for the rest of the period" means and keeps every day of the week/month in the same horizontal slot all period long. The scaffold solves the one-bar degenerate case (the user's "first day of the week" example) that a bare domain would render as a single fat bar.++### Alternatives Considered++- **Reserve for all five ranges**: rejected — the fixed ranges already span N days ending today, so there is nothing to reserve except on a brand-new system; the user asked specifically about the to-date ranges.+- **Pad month to a trailing 30/31-day window instead of the calendar month**: rejected — it would not align bars to calendar days and contradicts "the rest of *this* period".+- **Bare `chartXScale(domain:)` with no scaffold**: rejected — a single real bar over a wide domain renders at an inconsistent (wide) width, which is exactly the first-day-of-period case the request highlights.+- **Thread the range type through `ChartScope` so the enlarged chart reserves too**: deferred — it touches the Codable scope, the registry, and the macOS chart window for a secondary view; the inline cards are where the request applies.++### Consequences++**Positive:**+- Week/month charts keep a stable, day-aligned layout from the first day of the period; bars are left-aligned with empty space held on the right.+- Reuses the existing `chartXScale` pattern; no change to the wire contract or `DerivedState`.++**Negative:**+- Early in a long period the bars are thin (e.g. month-to-date on the 2nd reserves ~30 slots for 2 bars) — an accepted consequence of consistent alignment.+- The enlarged-chart presentation does not reserve, so it can differ from the inline card for a to-date range until the scope carries the range type.+- Each to-date chart renders N invisible scaffold marks (≤31), a negligible cost.++---
diff --git a/docs/agent-notes/ios-app-viewmodels.md b/docs/agent-notes/ios-app-viewmodels.mdindex 398dc31..7582861 100644--- a/docs/agent-notes/ios-app-viewmodels.md+++ b/docs/agent-notes/ios-app-viewmodels.md@@ -15,7 +15,12 @@ All ViewModels follow `@MainActor @Observable final class` pattern with `private - **Dependencies:** `apiClient`, `modelContext` (SwiftData), `nowProvider`, injectable `warn: @Sendable (String) -> Void` (defaults to `HistoryCacheLog.defaultWarn`, which logs to `Logger(subsystem: "eu.arjen.flux", category: "history-cache")`). - **State:** `days`, `selectedDay`, `isLoading`, `error`. Range (7/14/30) is owned by `HistoryView`, not the ViewModel. - **Derived series:** `derived` computed property returns `DerivedState` (`solar`, `grid`, `battery`, `dailyUsage` series + `summary`). Built in a single pass in `DerivedState.init(days:now:)`. Convenience accessors (`solarSeries`, `gridSeries`, `batterySeries`, `dailyUsageSeries`, `summary`) for tests/previews.-- **PeriodSummary:** Aggregates per-day totals across complete days (today excluded except for grid off-peak counts, which include today). Daily-usage fields: `dailyUsageTotalKwh`, `dailyUsageDayCount`, `dailyUsageLargestKind` (with 0.01 kWh tolerance-band tie-break by chronological order), `dailyUsageLargestKindTotalKwh`, plus `dailyUsageAvgKwh` / `dailyUsageLargestKindAvgKwh` accessors. Overview-card fields: `nightTotalKwh`, `nightBlockDayCount` (denominator for `nightAvgKwh`, presence-based — only days that contributed a `night` block count), `mostUsageDay` / `mostSolarDay` / `lowestSocDay` (record types — see below). `lowestSocDay` includes today; the rest follow the existing `!isToday` / off-peak-presence rules. Tie-breaks on all three day-records pick the most recent date.+- **PeriodSummary:** Hard-number totals and day-records **include today**; per-day **averages exclude today** (a partial day would skew them). This is the rule after `history-usage-stats` Decision 15, which superseded the "exclude today" half of Decision 5. Concretely:+ - **Totals (today included):** `solarTotalKwh`, `chargeTotalKwh`, `dischargeTotalKwh`, `dailyUsageTotalKwh`, and the grid totals `peakImportTotalKwh` / `offpeakImportTotalKwh` / `exportTotalKwh`.+ - **Day-records (today included):** `mostUsageDay`, `mostSolarDay`, `lowestSocDay`. Tie-breaks pick the most recent date.+ - **Averages (today excluded):** `solarPerDayKwh`, `dischargePerDayKwh`, `dailyUsageAvgKwh`, `dailyUsageLargestKindAvgKwh`, `nightAvgKwh`. Each divides a complete-days-only numerator (`solarCompleteTotalKwh`, `dischargeCompleteTotalKwh`, `dailyUsageCompleteTotalKwh`, `dailyUsageLargestKindTotalKwh`, `nightTotalKwh`) by a complete-days count (`solarDayCount` / `batteryDayCount` = complete-day count, `dailyUsageDayCount`, `nightBlockDayCount` — presence-based for night).+ - **Display counts (today included, gate em-dashes):** `dayCount` gates the Total solar tile; `dailyUsageDisplayDayCount` gates the Total usage tile and the Daily-usage chart placeholder, so a today-only range shows real numbers and the bar instead of em-dashes. `gridDayCount` (days-with-offpeak, today included) gates the grid tiles.+ - `dailyUsageLargestKind` is the dominant complete-days block kind (0.01 kWh tolerance-band tie-break by chronological order). - **DayKwhRecord** / **LowestSocRecord:** Equatable nested types in `HistoryDerivedState.swift`. Both carry `dayID`, `date`, and the comparable value (`kwh` or raw `Double` `soc`). `LowestSocRecord` additionally carries `socLowTime: String?` (full ISO timestamp from the wire). The raw-`Double` `soc` is load-bearing for the tie-break (see `HistoryStatsFormatters.socPercent` rounding); a fileprivate `DayRecordValue` protocol makes the generic `consider` comparator in `Totals` work over both types. - **DailyUsageEntry:** Per-day struct with `blocks` sorted into chronological order, clamped `totalKwh` ≥ 0 per block, `stackedTotalKwh`, `isToday`. `accessibilitySummary` formats `{date}: {kwh}, {largestKind} largest` for VoiceOver. - Upsert-based caching: `cacheHistoricalDays()` updates existing `CachedDayEnergy` records, including the four derived fields (`dailyUsage`, `socLow`, `socLowTime`, `peakPeriods`). `warnIfClearing(cached:day:)` fires the injected `warn` callback once per (date, fieldName) pair when a non-nil cached value is overwritten with nil — observability for unexpected backend nil-emit (Decision 6).
diff --git a/docs/agent-notes/ios-app-views.md b/docs/agent-notes/ios-app-views.mdindex c9e614d..45738b6 100644--- a/docs/agent-notes/ios-app-views.md+++ b/docs/agent-notes/ios-app-views.md@@ -10,14 +10,15 @@ ## History Views (History/) -- **HistoryView** — Multi-card layout. Range picker (7/14/30), the period overview card, four chart cards (solar, grid usage, battery, daily usage), then a per-day summary card and `View day detail` link. Empty/error states replace the cards when there is no data.-- **HistoryStatsOverviewCard** — Sits above the chart cards. Eight tiles in a `LazyVGrid` (2 columns on iOS compact, 4 on regular and macOS) covering Total usage, Total solar, Exported, Peak imports, Avg night, Most usage, Most solar, Lowest SoC. Card chrome KPI is the inclusive Sydney date range covered (`HistoryCardChrome.kpi` is optional). The three day-record tiles (Most usage, Most solar, Lowest SoC) are tappable when populated and call `HistoryViewModel.selectDay` via the existing `(String) -> Void` plumbing — em-dash tiles are non-tappable and don't expose `.isButton`. Static helpers (`label(for:)`, `valueText(for:summary:)`, `dateLineText(for:summary:)`, `isTappable(tile:summary:)`, `tapAction(for:summary:onSelect:)`, `accessibilityLabel(tile:summary:)`) keyed by `TileKey` make the rendering testable without a SwiftUI layout pass.+- **HistoryView** — Multi-card layout. Range picker (7/14/30/Wk/Mo), the period overview card, three mounted chart cards (solar, grid usage, daily usage), then a per-day summary card and `View day detail` link. (`HistoryBatteryCard` still exists but is not currently mounted in this layout.) The chart-card helpers pass `viewModel.chartDomain` for the to-date x-axis reservation. Empty/error states replace the cards when there is no data.+- **HistoryStatsOverviewCard** — Sits above the chart cards. Eight tiles in a `LazyVGrid` (2 columns on iOS compact, 4 on regular and macOS) covering Total usage, Total solar, Exported, Peak imports, Avg night, Most usage, Most solar, Lowest SoC. Per `history-usage-stats` Decision 15, the totals (Total usage, Total solar) and the day-records (Most usage, Most solar) include today — only Avg night (and the chart cards' per-day averages) excludes it. Total-usage/Total-solar em-dash gates use the today-inclusive display counts (`dailyUsageDisplayDayCount`, `dayCount`), so a today-only range shows a real number. Card chrome KPI is the inclusive Sydney date range covered (`HistoryCardChrome.kpi` is optional). The three day-record tiles (Most usage, Most solar, Lowest SoC) are tappable when populated and call `HistoryViewModel.selectDay` via the existing `(String) -> Void` plumbing — em-dash tiles are non-tappable and don't expose `.isButton`. Static helpers (`label(for:)`, `valueText(for:summary:)`, `dateLineText(for:summary:)`, `isTappable(tile:summary:)`, `tapAction(for:summary:onSelect:)`, `accessibilityLabel(tile:summary:)`) keyed by `TileKey` make the rendering testable without a SwiftUI layout pass. - **HistoryStatsFormatters** — Tile-specific formatters: `socPercent` (half-up, `—` for non-finite), `shortDate` (`MMM d` Sydney), `dateWithTime` (parses an ISO timestamp and renders `MMM d at HH:mm`), `dateRange` (min/max so reversed input still works), and the `accessibleKwh` / `accessibleSocPercent` spell-out variants for VoiceOver. - **HistorySolarCard** — Green daily bars with a dashed average rule. Today's bar dimmed. - **HistoryGridUsageCard** — Diverging stacked bars: peak import (red) on top of off-peak import (teal), exports (blue) below the zero line. Header KPI leads with peak imports because that's the actionable number for an off-peak charging strategy. Days without an off-peak record are hidden from this card; if no day has a split the card shows a placeholder. - **HistoryBatteryCard** — Charge (orange, above zero) and discharge (purple, below zero) per day.-- **HistoryDailyUsageCard** — Stacked bars per day with five segments in chronological order (Night → Morning Peak → Off-Peak → Afternoon Peak → Evening). Colour palette pinned per Decision 5: indigo / orange / teal / red / purple. Today's bar at 50% opacity. Placeholder copy `No load breakdown available for this range.` when `summary.dailyUsageDayCount == 0` (catches both no-blocks and today-only ranges). Subtitle is `"{kind} largest at {kwh} kWh/day average"`. Static helpers (`kpi(for:)`, `subtitle(for:)`, `opacity(for:)`, `placeholderCopy`, `shouldShowPlaceholder(summary:)`) are exposed for unit tests since the project has no rendered-tree inspection library.+- **HistoryDailyUsageCard** — Stacked bars per day with five segments in chronological order (Night → Morning Peak → Off-Peak → Afternoon Peak → Evening). Colour palette pinned per Decision 5: indigo / orange / teal / red / purple. Today's bar at 50% opacity. Placeholder copy `No load breakdown available for this range.` when `summary.dailyUsageDisplayDayCount == 0` (no day-with-blocks at all); a today-only range now shows today's bar rather than the placeholder, with the KPI/subtitle staying em-dash until a complete day exists. Subtitle is `"{kind} largest at {kwh} kWh/day average"`. Static helpers (`kpi(for:)`, `subtitle(for:)`, `opacity(for:)`, `placeholderCopy`, `shouldShowPlaceholder(summary:)`) are exposed for unit tests since the project has no rendered-tree inspection library. - **DailyUsageBlockKindStyling** — Extension on `FluxCore.DailyUsageBlock.Kind` exposing `chronologicalOrder`, `chronologicalIndex`, `chartColor`, `displayLabel`. Lives in the iOS app target (not FluxCore) because `Color` is SwiftUI; shared with Day Detail's `DailyUsageCard` so labels stay aligned across screens.+- **HistoryChartDomain** — Full-period x-axis reservation for the to-date ranges (`history-month-week-to-date` Decision 7). `HistoryViewModel.chartDomain` builds it from the resolved range, Sydney `now`, and locale first weekday: Wk → a full 7-day week, Mo → the full calendar month (from the month interval), fixed `.days` → `nil` (auto-fit). Charts apply it with the `historyChartXScale(_:)` View modifier and render invisible zero-height `scaffold` `BarMark`s at every slot date so Swift Charts infers a consistent one-day bar width even when only one real day has data. Scoped to the inline cards — the expanded-chart host passes `nil` because its `ChartScope` carries only a day count, not the range type. - **HistoryCardChrome** — Shared rounded-rectangle container, header (title + KPI) and optional subtitle. - **ChartHighlightOverlay** — `historySelectionOverlay` view extension. Shared drag-to-select gesture that maps the touch x-position to the nearest entry's date and reports the day ID; a single `selectedDay` on the view model drives the highlight rectangle in every chart. - **HistoryFormatters** — `kwh` helper picks 1 decimal under 100 kWh, 0 above.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8d03a2d..5bce327 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **History overview hard numbers now include today.** Total solar, Total usage, Most usage, and Most solar (and the Battery card's discharged/charged totals) now count today's value, matching Peak imports, Exported, and Lowest SoC which already did. A range whose only data is today (e.g. Week-to-date on the week's first day) now shows real numbers and the daily-usage bar instead of em-dashes. Per-day averages (Solar/day, Battery/day, Daily-usage/day, Avg night) still exclude today's partial day so they aren't skewed.+- **Week-to-date and month-to-date charts reserve space for the full period.** A partly-elapsed week or month now draws its bars left-aligned with empty space held for the days not yet elapsed — Wk reserves the full 7-day week, Mo the full calendar month — so the layout stays consistent from the first day of the period instead of a lone bar stretching to fill the width. The fixed 7/14/30-day ranges are unchanged. - **SwiftLint now passes `--strict` cleanly.** Added `Flux/.swiftlint.yml` (the project had none, so the linter was scanning test targets and `.build-xc/` artifacts): build directories (`.build`, `.build-xc`, `DerivedData`) and the `FluxTests` / `FluxUITests` / `FluxCore/Tests` targets are now excluded. The 26 remaining production violations were fixed rather than suppressed — short identifiers renamed to meaningful names (including the `registerDeviceIfNeeded` `tz:` → `timeZone:` label), three over-length lines reflowed, a scoped `line_length` exception around the release-note copy in `WhatsNewCatalogue`, and a per-file `file_length` disable on `HistoryDerivedState` matching the convention already used by `DayDetailView` and `APIModels`. No app behaviour changes. ## [1.5] - 2026-06-02
The scaffold's bar-width effect could not be eyeballed here (no backend credentials to run the live app). Worth a quick on-device look at Wk on the week's first day and Mo on the 1st–2nd to confirm bars render thin and left-aligned rather than fat.
dischargeCompleteTotalKwh feeds dischargePerDayKwh, exercised only by the (currently unmounted) Battery card. The accumulator shares addDay's tested logic, but there's no direct assertion on the discharge average specifically.