flux branch worktree-sim-pill-hero commits 2 files 9 touched lines +125 / -40

Pre-push review: worktree-sim-pill-hero

Moves the Dashboard Simulate control into the hero as a labelled pill, loads presets on the Dashboard, and updates the dashboard-simulation spec. Follow-up to T-1495.

At a glance

  • The Simulate control now lives in DashboardHeroPanel (injected via a generic accessory slot), so it renders on iPhone, iPad regular, and macOS — the old header placement only rendered on compact iPhone, so iPad/macOS previously had no Dashboard trigger at all (a latent [2.1] gap, now closed).
  • The hero menu now populates without visiting Settings: DashboardView refreshes SimulationPresetsService in a .task on appear. This is the fix for the reported bug.
  • The trigger is a labelled “Simulate” pill styled like the tab-bar pills, accented with a dot while active; laid out as a sibling of the SoC numeral so the numeral shrinks rather than colliding on narrow devices.
  • Removed the now-dead FluxScreenHeader.trailingAccessory: AnyView? — the generic accessory slot replaces it (and drops an AnyView the Swift rules discourage).
  • Spec docs (design / implementation / tasks) updated and a new Decision 15 records the placement change; no decision had pinned the old placement, so this is documentation, not a rule violation.

Verdict

Ready to push

The change is small, view-layer only, and verified on both platforms (ios-lint/ios-build/ios-test, macos-lint/macos-build all green) plus a real-device install confirmed by the author. Four parallel review agents (reuse, quality, efficiency, spec/docs) raised no spec violation and no correctness/security issues. The worthwhile findings — a code-quality tidy in the pill, and stale spec docs — were fixed in the second commit; the remaining flags were judgment calls deliberately left as-is with rationale. Two items below are worth a human eyeball before merge.

Review findings

5 raised · 2 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The Dashboard has a “Simulate” button that lets you preview a what-if — like “what if I started charging the car?”. It used to be a tiny icon squeezed into the top row of tabs, which was hard to tap, and the list of saved scenarios wouldn't show up until you'd opened the Settings screen at least once.

Why it matters

Now the button is a clearly-labelled pill sitting next to the big battery percentage, so it's an obvious, easy tap target. The list of scenarios loads as soon as the Dashboard opens. And the button now shows up on iPad and Mac too, where it was previously missing.

Key concepts

  • Preset: a saved label + wattage, e.g. “Charge car” at 1700 W.
  • Hero: the big battery-percentage panel at the top of the Dashboard.

Architecture

DashboardHeroPanel became generic over an Accessory: View with a defaulted { EmptyView() } closure — the idiomatic SwiftUI container-slot pattern. DashboardView injects DashboardSimulateMenu into that slot. Because the hero panel is rendered by both the compact layout (dashboardContent) and the regular layout (dashboardContentRegular, used by iPad regular + macOS), the control now appears everywhere; previously it lived in headerSection, which only the compact layout renders.

Patterns

  • The pill is laid out as a sibling of the numeral inside a nested HStack (not an .overlay), with .lineLimit(1).minimumScaleFactor(0.6) on the numeral so a wide 4-character reading shrinks instead of running under the pill.
  • Presets are loaded where they're consumed: a .task on the Dashboard calls SimulationPresetsService.refresh(), which is cancellation-guarded against stale overwrites.
  • The pill reuses the existing tab-bar pill tokens (tabBarCornerRadius / tabBarFill / border) so it reads as part of the navigation language.

Trade-offs

The .task re-fetches presets on every Dashboard appearance — a deliberate choice to pick up cross-device edits/deletes ([2.4]/[2.7]) at the cost of one small GET per appearance.

Deep dive

Making DashboardHeroPanel generic removes the prior FluxScreenHeader.trailingAccessory: AnyView? plumbing — the generic slot is type-erasure-free and satisfies the project's “avoid AnyView” rule. The defaulted closure infers Accessory == EmptyView at omitting call sites (the two #Previews), so no existing caller changed.

Architecture impact

This consolidates the Simulate trigger to a single placement across all three layouts, fixing a latent [2.1] coverage gap: the active-state SimulationBanner can only appear once a simulation is started, but the regular layout had no way to start one, so on iPad/macOS the feature was effectively unreachable from the Dashboard. Recorded as Decision 15.

Edge cases

  • Narrow-device collision: the 108 pt numeral with a common 4-char value (“84.3%”) is the worst case; the sibling layout + minimumScaleFactor handles it. Not visually verified (no booted simulator with credentials) — flagged for an eyeball.
  • Bind/refresh ordering: the service is bound synchronously in reloadDependencies() before the Dashboard renders, and try? swallows a .notConfigured if it somehow isn't; concurrent refreshes are safe via Task.checkCancellation().

Completeness assessment

Fully implemented: §2.1 (all layouts), §2.3 (empty-state path preserved), §5.x labelling (banner unchanged), the preset-load fix. Not covered: a behavioural test asserting the Dashboard refreshes presets on appear (low-risk one-liner; the service's refresh() is already tested). Missing: nothing required by the spec.

Important changes — detailed

DashboardHeroPanel: generic accessory slot, sibling layout

DashboardHeroPanel.swift

Why it matters. The panel now hosts the Simulate control on every layout and removes an AnyView. The accessory is a sibling of the numeral, so the numeral shrinks via minimumScaleFactor rather than running under the pill on narrow devices.

What to look at. DashboardHeroPanel.swift:7, 31-52, 56-71

Takeaway. SwiftUI container-slot pattern: `struct V<Accessory: View>` + `@ViewBuilder accessory: () -> Accessory = { EmptyView() }` lets a data-only view accept an injected control without AnyView, and existing callers that omit it still compile (Accessory infers EmptyView).
Rationale. Sibling-not-overlay avoids collision between the 108pt numeral and the pill; injecting from DashboardView keeps the panel free of any simulate-specific concern.

DashboardView: load presets on appear + hero injection

DashboardView.swift

Why it matters. Fixes the reported bug: the shared presets service was bound at startup but only refreshed by Settings, so the hero menu was empty until that screen was visited. Also relocates the control out of the header.

What to look at. DashboardView.swift:113-120 (.task), 218-226 (hero accessory)

Takeaway. Load shared-service data where it is consumed; .task on a view runs once per appearance (not per auto-refresh tick), making it the right hook for on-appear freshness.
Rationale. Makes the Dashboard authoritative for its own data instead of depending on the user having opened Settings; re-runs on appearance to pick up cross-device preset edits ([2.4]/[2.7]).

DashboardSimulateMenu: labelled pill with active-state affordance

SimulationBanner.swift

Why it matters. Replaces the bare icon with a labelled, tappable pill that signals the active simulation (accent + dot) without opening the menu.

What to look at. SimulationBanner.swift:90-128 (pillLabel)

Takeaway. Reuse existing design tokens (tabBarCornerRadius / tabBarFill / border) for a new control so it reads as part of the established visual language; .menuIndicator(.hidden) suppresses the default chevron on a custom Menu label.
Rationale. Tidied in the review to derive the accent fill/stroke colours and the rounded-rect shape once instead of branching isSimulating four times.

Decision 15 + spec doc updates

decision_log.md

Why it matters. The original design pinned the control to headerSection; this change reverses that. design/implementation/tasks docs were stale and are corrected, with a decision-log entry capturing the rationale.

What to look at. decision_log.md (Decision 15); design.md:179,182-184; implementation.md:13,109; tasks.md:131

Takeaway. When code diverges from a written design rationale, update the design and add a decision-log entry rather than leaving the two to contradict.
Rationale. Project rule: document design changes in the decision log. No prior decision constrained placement, so this is documentation rather than a rule violation.

Key decisions

Hero pill rather than header icon or bottom row.

The author chose the top-right hero pill over (a) keeping/enlarging the header icon — rejected because the iPad/macOS regular layout would still have no control — and (b) a full-width row at the bottom of the hero — rejected as visually heavier than the hero's minimalist language. Captured in Decision 15.

Refresh presets on every Dashboard appearance, not only when empty.

An isEmpty guard would avoid the per-appearance GET but drops the cross-device freshness the unconditional refresh provides ([2.4]/[2.7]). For a two-user app the saved request is negligible, so the unconditional refresh was kept.

Tab-bar pill chrome left inline, not extracted.

The pill is the third inline copy of the RoundedRectangle.fill + strokeBorder pattern (also in FluxTabBar and FluxTabBarSettingsButton), but it is a conditional-colour variant, not an identical duplicate. Extracting a shared modifier would mean inventing a new abstraction and migrating the two existing copies — out of scope for this change.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorSimulationBanner.swift pillLabelisSimulating was branched four times (accent, fill, border, dot) where the fill/stroke colours and the rounded-rect shape could be derived once.Refactored to derive accent/fill/stroke/shape as locals; reused the shape for background, overlay, and contentShape. No behaviour change.
majorspecs/dashboard-simulation docsdesign.md, implementation.md, and tasks.md described the control as a Menu in headerSection / FluxScreenHeader; implementation.md framed §2.1 around 'compact iPhone' only. All now stale and contradicting the code.Updated the three docs to describe the hero-panel pill and all-layout visibility; added Decision 15 recording the placement change and the on-appear preset refresh.
minorDashboardView.swift .taskPresets are re-fetched on every Dashboard appearance; an isEmpty guard could skip the fetch when already loaded.Left as-is by design: the unconditional refresh supports cross-device preset edits/deletes ([2.4]/[2.7]); the cost is one small GET per appearance. Documented in Decision 15.
minorFluxV5Components.swift / SimulationBanner.swift reuseThe pill is a third inline copy of the tab-bar pill chrome.Not extracted: it is a conditional-colour variant, and a shared modifier would be a new abstraction requiring migration of the two existing copies. Consistent with the codebase's existing inline duplication.
minorFluxTestsNo behavioural test asserts the Dashboard refreshes presets on appear (the bug-fix path).Left as a known gap: the one-line .task is low-risk and SimulationPresetsService.refresh() is already covered by service tests. Noted for follow-up if desired.

Per-file diffs

Click to expand.

Flux/Flux/Dashboard/DashboardHeroPanel.swift Modified +24 / -11
diff --git a/Flux/Flux/Dashboard/DashboardHeroPanel.swift b/Flux/Flux/Dashboard/DashboardHeroPanel.swiftindex afc9f79..3647106 100644--- a/Flux/Flux/Dashboard/DashboardHeroPanel.swift+++ b/Flux/Flux/Dashboard/DashboardHeroPanel.swift@@ -4,7 +4,7 @@ import SwiftUI /// Hero battery numeral on the Dashboard. The numeral and the rest of the /// panel are rendered in the user-selected app font (Settings → App font), /// resolved via `\.appFontFamily` in the environment.-struct DashboardHeroPanel: View {+struct DashboardHeroPanel<Accessory: View>: View {     let live: LiveData?     let rolling15min: RollingAvg?     let battery: BatteryInfo?@@ -13,19 +13,26 @@ struct DashboardHeroPanel: View {     /// load, so they render in the simulation accent and are announced as     /// simulated (Req 5.3/5.4).     let isSimulating: Bool+    /// Control pinned to the top-trailing of the hero, beside the SoC numeral+    /// (the Dashboard passes the Simulate menu). Laid out as a sibling of the+    /// numeral — not an overlay — so the numeral shrinks slightly on a narrow+    /// device rather than running under the control.+    @ViewBuilder let accessory: () -> Accessory      init(         live: LiveData?,         rolling15min: RollingAvg?,         battery: BatteryInfo? = nil,         offpeakWindowStart: String? = nil,-        isSimulating: Bool = false+        isSimulating: Bool = false,+        @ViewBuilder accessory: @escaping () -> Accessory = { EmptyView() }     ) {         self.live = live         self.rolling15min = rolling15min         self.battery = battery         self.offpeakWindowStart = offpeakWindowStart         self.isSimulating = isSimulating+        self.accessory = accessory     }      /// Colour for the discharge/charge rate and "empty by" time in the@@ -42,16 +49,22 @@ struct DashboardHeroPanel: View {     var body: some View {         FluxPanel(padding: FluxTheme.Metrics.panelHeroPadding) {             VStack(alignment: .leading, spacing: 0) {-                HStack(alignment: .firstTextBaseline, spacing: 6) {-                    Text(heroNumber)-                        .appFont(FluxTheme.Typography.heroNumber)-                        .tracking(-4)-                        .foregroundStyle(FluxTheme.Palette.amber)-                        .monospacedDigit()-                        .accessibilityLabel(accessibilityValue)-                    Text("%")-                        .appFont(FluxTheme.Typography.heroUnit)-                        .foregroundStyle(FluxTheme.Palette.tertiaryText)+                HStack(alignment: .top, spacing: 8) {+                    HStack(alignment: .firstTextBaseline, spacing: 6) {+                        Text(heroNumber)+                            .appFont(FluxTheme.Typography.heroNumber)+                            .tracking(-4)+                            .foregroundStyle(FluxTheme.Palette.amber)+                            .monospacedDigit()+                            .lineLimit(1)+                            .minimumScaleFactor(0.6)+                            .accessibilityLabel(accessibilityValue)+                        Text("%")+                            .appFont(FluxTheme.Typography.heroUnit)+                            .foregroundStyle(FluxTheme.Palette.tertiaryText)+                    }+                    Spacer(minLength: 8)+                    accessory()                 }                 .frame(maxWidth: .infinity, alignment: .leading)                 // Trim the giant glyph's intrinsic leading so the panel's
Flux/Flux/Dashboard/DashboardView.swift Modified +17 / -9
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex 1135c29..34f2b69 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -110,6 +110,14 @@ struct DashboardView: View {         }         .scrollContentBackground(.hidden)         .scrollBounceBehavior(.basedOnSize)+        .task {+            // Presets power the hero Simulate menu. The shared service is bound+            // at startup but otherwise only refreshed by the Settings →+            // Simulation screen, so without this the menu stayed empty until+            // that screen was visited. Re-runs on each appearance to pick up+            // edits/deletes synced from another device.+            try? await simulationService.refresh()+        }     }      private var usesRegularLayout: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) }@@ -163,18 +171,16 @@ struct DashboardView: View {             FluxScreenHeader(                 selection: tabBinding,                 onSettingsTap: onSettingsTap,-                onTabActivate: onTabActivate,-                trailingAccessory: AnyView(simulateMenu)+                onTabActivate: onTabActivate             )         } else {-            HStack(alignment: .top) {-                legacyHeader-                Spacer()-                simulateMenu-            }+            legacyHeader         }     } +    // The Simulate control lives in the hero panel (see `heroPanel`), not the+    // header row, so its touch target is large and unambiguous and it appears+    // on every layout (the header is absent from the iPad/macOS regular path).     private var simulateMenu: some View {         DashboardSimulateMenu(             viewModel: viewModel,@@ -212,7 +218,9 @@ struct DashboardView: View {             battery: viewModel.status?.battery,             offpeakWindowStart: viewModel.status?.offpeak?.windowStart,             isSimulating: viewModel.isSimulating-        )+        ) {+            simulateMenu+        }     }      @ViewBuilder
Flux/Flux/Dashboard/SimulationBanner.swift Modified +34 / -6
diff --git a/Flux/Flux/Dashboard/SimulationBanner.swift b/Flux/Flux/Dashboard/SimulationBanner.swiftindex b480065..5722086 100644--- a/Flux/Flux/Dashboard/SimulationBanner.swift+++ b/Flux/Flux/Dashboard/SimulationBanner.swift@@ -88,17 +88,47 @@ struct DashboardSimulateMenu: View {                 }             }         } label: {-            Label("Simulate", systemImage: "wand.and.stars")-                .labelStyle(.iconOnly)-                .foregroundStyle(-                    viewModel.isSimulating ? FluxTheme.Palette.simulation : FluxTheme.Palette.secondaryText-                )+            pillLabel         }+        .menuIndicator(.hidden)         .accessibilityLabel(             viewModel.isSimulating ? "Simulation active. Change or stop simulation" : "Simulate"         )     } +    /// Labelled pill (icon + "Simulate"), styled like the tab-bar pills so it+    /// reads as part of the navigation language. While simulating it takes the+    /// simulation accent and shows a trailing dot, so the active state is+    /// obvious without opening the menu.+    private var pillLabel: some View {+        let accent = viewModel.isSimulating+            ? FluxTheme.Palette.simulation+            : FluxTheme.Palette.secondaryText+        let fill = viewModel.isSimulating+            ? FluxTheme.Palette.simulation.opacity(0.14)+            : FluxTheme.Palette.tabBarFill+        let stroke = viewModel.isSimulating+            ? FluxTheme.Palette.simulation.opacity(0.5)+            : FluxTheme.Palette.border+        let shape = RoundedRectangle(cornerRadius: FluxTheme.Metrics.tabBarCornerRadius, style: .continuous)+        return HStack(spacing: 5) {+            Image(systemName: "wand.and.stars")+            Text("Simulate")+            if viewModel.isSimulating {+                Circle()+                    .fill(FluxTheme.Palette.simulation)+                    .frame(width: 6, height: 6)+            }+        }+        .appFont(.footnote, weight: .semibold)+        .foregroundStyle(accent)+        .padding(.horizontal, 11)+        .padding(.vertical, 7)+        .background(shape.fill(fill))+        .overlay(shape.strokeBorder(stroke, lineWidth: FluxTheme.Metrics.hairline))+        .contentShape(shape)+    }+     @ViewBuilder     private func presetButton(_ preset: SimulationPreset) -> some View {         let title = "\(preset.label) · \(PowerFormatting.format(Double(preset.watts)))"
Flux/Flux/Helpers/FluxV5Components.swift Modified +0 / -8
diff --git a/Flux/Flux/Helpers/FluxV5Components.swift b/Flux/Flux/Helpers/FluxV5Components.swiftindex bd1a1fb..144f374 100644--- a/Flux/Flux/Helpers/FluxV5Components.swift+++ b/Flux/Flux/Helpers/FluxV5Components.swift@@ -201,19 +201,11 @@ struct FluxScreenHeader: View {     @Binding var selection: FluxTab     var onSettingsTap: (() -> Void)?     var onTabActivate: ((FluxTab) -> Void)?-    /// Optional accessory rendered on the trailing side of the tab-bar row,-    /// before the settings affordance. The Dashboard uses it for the Simulate-    /// menu so the control lives in the scroll content (visible on compact-    /// iPhone where the nav bar is hidden), not the toolbar chrome.-    var trailingAccessory: AnyView?      var body: some View {         VStack(alignment: .leading, spacing: 14) {             HStack(spacing: 8) {                 FluxTabBar(selection: $selection, onActivate: onTabActivate)-                if let trailingAccessory {-                    trailingAccessory-                }                 if let onSettingsTap {                     FluxTabBarSettingsButton(action: onSettingsTap)                 }
specs/dashboard-simulation/decision_log.md Modified +48 / -0
diff --git a/specs/dashboard-simulation/decision_log.md b/specs/dashboard-simulation/decision_log.mdindex 9d19c18..7734c7b 100644--- a/specs/dashboard-simulation/decision_log.md+++ b/specs/dashboard-simulation/decision_log.md@@ -450,3 +450,45 @@ One rule covers every starting state correctly with ~6 lines server-side, reusin - Assumes self-consumption priority (reduce export before reducing battery charge); a feed-in-priority configuration would behave differently. The 5 kW ceiling is a fixed constant — if the real inverter limit differs it updates in one place (`maxDischargeKW`).  ---++## Decision 15: Place the Simulate control in the hero panel and load presets on the Dashboard++**Date**: 2026-06-10+**Status**: accepted (supersedes the placement in the "Where the control lives" section of the design)++### Context++The control was first built as an icon-only `Menu` in `headerSection` — the trailing side of the `FluxScreenHeader` tab-bar row, beside the settings gear (the placement the design's "Where the control lives" section specified). In use this had two problems. First, a bare icon wedged between the tab bar and the gear was a small, ambiguous touch target. Second — and more serious — `headerSection` is rendered only by the compact layout (`dashboardContent`); the regular layout (`dashboardContentRegular`, used by iPad regular and macOS, where `IPadLayoutGate.isActive` is always true) deliberately omits it, so those platforms had **no** Dashboard trigger at all and could only ever show the active-state banner, which can never appear without a trigger. That under-met [2.1] (control on both iOS and macOS / Decision 7). Separately, the shared `SimulationPresetsService` was bound at startup but only refreshed by the Settings ▸ Simulation screen, so the Dashboard menu stayed empty until that screen had been visited once.++### Decision++Inject the `DashboardSimulateMenu` into `DashboardHeroPanel` as a top-trailing accessory beside the SoC numeral, rendered as a labelled "Simulate" pill (SF Symbol + text) styled like the existing tab-bar pills, taking the simulation accent with a status dot while active. The panel takes the control through a generic `accessory: () -> Accessory` slot (defaulting to `EmptyView`), laid out as a *sibling* of the numeral so the numeral shrinks via `minimumScaleFactor` on a narrow device rather than running under the pill. Because the hero panel is rendered by both layouts, the control now appears on iPhone, iPad regular, and macOS. Also load presets from the Dashboard: `DashboardView` refreshes `SimulationPresetsService` in a `.task` on appear, re-running on each appearance to pick up cross-device edits/deletes ([2.4]/[2.7]).++### Rationale++Moving the control into the hero fixes the [2.1] coverage gap and the touch-target complaint in one change, and removes the `FluxScreenHeader.trailingAccessory: AnyView?` plumbing (the project's Swift rules discourage `AnyView`) — the generic accessory slot replaces it. Laying the pill out as a sibling rather than an overlay keeps it from colliding with the common four-character SoC reading (e.g. "84.3%") at 108 pt on narrow devices. Refreshing presets from the Dashboard makes that screen authoritative for its own data instead of depending on the user having opened Settings first; the cost is a single small `GET /simulation-presets` per appearance, which the service already guards against stale overwrites via `Task.checkCancellation()`.++### Alternatives Considered++- **Keep the icon in `headerSection`, just enlarge it**: Smallest change - Rejected because it leaves the iPad/macOS regular layout with no control ([2.1] gap) and the header row remains a cramped target.+- **A full-width "Simulate" row at the bottom of the hero**: Largest, most obvious target - Rejected as visually heavier than the hero's minimalist language; the corner pill reads clearly enough.+- **Refresh presets only when the cached list is empty**: Avoids a per-appearance `GET` - Rejected because it drops the cross-device freshness the unconditional refresh gives ([2.4]/[2.7]); the saved request is negligible for a two-user app.+- **Add the trigger to the regular layout's system toolbar instead**: Reuses existing chrome - Rejected because the compact layout hides the nav bar, so a single shared placement in scroll content is simpler and consistent across layouts.++### Consequences++**Positive:**+- One Simulate control on every layout (iPhone, iPad regular, macOS), satisfying [2.1] where the old placement did not.+- Larger, labelled touch target that reads as an action and signals active state without opening the menu.+- The Dashboard menu is populated on first appearance, without a detour through Settings.+- Removes an `AnyView` from `FluxScreenHeader`.++**Negative:**+- `DashboardHeroPanel` is now generic over its accessory; a minor type-signature cost for a previously data-only view.+- One extra `GET /simulation-presets` per Dashboard appearance (intended, and cheap).++### Impact++`DashboardHeroPanel` (generic accessory slot + sibling layout), `DashboardView` (hero injection, presets `.task`, header simplification), `DashboardSimulateMenu` (pill label), and `FluxScreenHeader` (removed `trailingAccessory`).++---
specs/dashboard-simulation/design.md Modified +3 / -3
diff --git a/specs/dashboard-simulation/design.md b/specs/dashboard-simulation/design.mdindex 5038ab0..0c5b281 100644--- a/specs/dashboard-simulation/design.md+++ b/specs/dashboard-simulation/design.md@@ -176,12 +176,12 @@ The simulation parameter is a **separate** method, not a change to `fetchStatus( | Editor sheet (label, watts stepper/field, validation) | `Settings/Simulation/SimulationPresetEditor.swift` | `SoCAlertEditor` | | List view model | `Settings/Simulation/SimulationPresetsViewModel.swift` | `SoCAlertsViewModel` | | Settings entry | `Settings/SettingsView.swift` (edit) | add `NavigationLink` next to the "Alerts" section, iOS + macOS |-| Dashboard control | `Dashboard/DashboardView.swift` (edit) | `Menu` listing presets + "Off"; empty → "Add in Settings" deep link ([2.3]) |+| Dashboard control | `DashboardSimulateMenu` (`Dashboard/SimulationBanner.swift`), injected into `DashboardHeroPanel` | `Menu` listing presets + "Off"; empty → "Add in Settings" deep link ([2.3]) | | Simulation indicator | `Dashboard/SimulationBanner.swift` | **Deliberately distinct** from the hero `cantEmptyBeforeOffpeak` line | -**Where the control lives ([2.1], [2.3]).** The control must sit in the Dashboard's *scroll content*, not the navigation toolbar: on compact iPhone the nav bar is hidden (`DashboardView` line 51, `.toolbar(... .hidden, for: .navigationBar)`), so a toolbar item would be invisible there. Placement:+**Where the control lives ([2.1], [2.3]).** The control sits in the Dashboard's *scroll content*, not the navigation toolbar: on compact iPhone the nav bar is hidden (`DashboardView` line 51, `.toolbar(... .hidden, for: .navigationBar)`), so a toolbar item would be invisible there. It is injected into the **hero panel** rather than the header row (see Decision 15) so the touch target is large and it renders on every layout. Placement: -- **Trigger (inactive):** a `Menu` in `headerSection` (trailing side of the existing `FluxScreenHeader`, alongside the settings affordance) labelled "Simulate" with an SF Symbol. Visible on iPhone, iPad, and macOS because it is part of the content, not the chrome. The menu lists each preset (label + watts) and an "Off" entry; with no presets it shows a single "Add a preset…" item that opens Settings ▸ Simulation ([2.3]).+- **Trigger (inactive):** a `Menu` (`DashboardSimulateMenu`) injected into `DashboardHeroPanel` as a top-trailing accessory beside the SoC numeral, rendered as a labelled "Simulate" pill (SF Symbol + text) styled like the tab-bar pills. Because it lives in the hero panel — which **both** the compact (`dashboardContent`) and regular (`dashboardContentRegular`, iPad regular + macOS) layouts render — it is visible on iPhone, iPad, and macOS. (The earlier design placed it in `headerSection`, which only the compact layout renders, so the regular layout had no trigger — see Decision 15.) The menu lists each preset (label + watts) and an "Off" entry; with no presets it shows a single "Add a preset…" item that opens Settings ▸ Simulation ([2.3]). - **Active state:** the `SimulationBanner` renders at the top of `dashboardContent`, in the same slot and following the placement of the existing `stalenessBanner` (line 247) — a full-width banner above the panels — but with the distinct simulation treatment below. It shows the active preset + delta and a **Stop** control; the "Simulate" menu remains available to switch presets.  `stalenessBanner` is the *placement/structure* baseline (a Dashboard-level banner at the top of content); the simulation banner's visual treatment is deliberately distinct (next paragraph).
specs/dashboard-simulation/implementation.md Modified +2 / -2
diff --git a/specs/dashboard-simulation/implementation.md b/specs/dashboard-simulation/implementation.mdindex 59f6a01..6655928 100644--- a/specs/dashboard-simulation/implementation.md+++ b/specs/dashboard-simulation/implementation.md@@ -10,7 +10,7 @@ A three-level explanation of the implemented feature, written as a pre-push revi  The Dashboard normally shows your battery's real numbers: how much power the house is using, how fast the battery is draining, and roughly when it will be "empty". Dashboard Simulation lets you ask a what-if question — "what would happen if I started charging the car right now?" — and see the answer on the same screen, without actually turning anything on. -You set up named scenarios called **presets** in Settings. A preset is just a label plus a wattage, like "Charge car" at 1700 watts. On the Dashboard there is a new **Simulate** wand button. Tap it, pick a preset, and the figures change to show the picture *as if* that extra load were running. A coloured banner appears across the top saying you are looking at a what-if, and the affected numbers change colour so you can never mistake them for real readings. Tap **Stop** (or just relaunch the app) and everything snaps back to reality.+You set up named scenarios called **presets** in Settings. A preset is just a label plus a wattage, like "Charge car" at 1700 watts. On the Dashboard there is a new **Simulate** pill beside the big battery percentage. Tap it, pick a preset, and the figures change to show the picture *as if* that extra load were running. A coloured banner appears across the top saying you are looking at a what-if, and the affected numbers change colour so you can never mistake them for real readings. Tap **Stop** (or just relaunch the app) and everything snaps back to reality.  ### Why It Matters @@ -106,7 +106,7 @@ Every task in `tasks.md` 1–17 is checked; task 18 (run full Go + iOS + macOS t - 1.6 server-confirmed-then-apply with visible error: `SimulationPresetsService` mirrors SoC Alerts; editor keeps the sheet open on failure.  ### Group 2 — Activate From the Dashboard — Fully implemented-- 2.1 control on both platforms: `DashboardSimulateMenu` in scroll content (not toolbar) so it shows on compact iPhone.+- 2.1 control on both platforms: `DashboardSimulateMenu` is injected into `DashboardHeroPanel` as a top-trailing pill, so it renders on every layout — compact iPhone (`dashboardContent`) and regular iPad/macOS (`dashboardContentRegular`) alike (Decision 15). Presets are loaded by a `DashboardView` `.task` on appear, so the menu is populated without first visiting Settings ▸ Simulation. - 2.2 replace-on-switch: `activateSimulation` overwrites the single `activeSimulationPresetID`. - 2.3 empty state offers a path to create: "Add a preset…" item (SettingsLink on macOS, callback on iOS). - 2.4 deleted/synced-away active preset turns simulation off: `refresh()` clears the id when absent from the list (tested).
specs/dashboard-simulation/tasks.md Modified +1 / -1
diff --git a/specs/dashboard-simulation/tasks.md b/specs/dashboard-simulation/tasks.mdindex 72bd375..6169357 100644--- a/specs/dashboard-simulation/tasks.md+++ b/specs/dashboard-simulation/tasks.md@@ -128,7 +128,7 @@ references:   - Requirements: [2.2](requirements.md#2.2), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [4.5](requirements.md#4.5), [5.5](requirements.md#5.5)  - [x] 17. Implement Dashboard simulation UI (view wiring) <!-- id:0plbs03 -->-  - Simulate menu in DashboardView headerSection (lists presets + Off; empty shows Add a preset deep-linking to Settings Simulation).+  - Simulate menu (`DashboardSimulateMenu`) as a top-trailing pill in `DashboardHeroPanel` (lists presets + Off; empty shows Add a preset deep-linking to Settings Simulation). Placed in the hero rather than `headerSection` so it renders on every layout — see Decision 15.   - New Dashboard/SimulationBanner.swift at the top of dashboardContent (stalenessBanner placement baseline) with a distinct FluxTheme.Palette.simulation accent, preset+delta, and a Stop control.   - Tint the simulated values (trio House, hero discharge/empty-by) in the accent while active; accessibility labels announce simulated; iOS + macOS.   - Blocked-by: 0plbs00 (Implement Settings Simulation list + editor (GREEN)), 0plbs02 (Implement DashboardViewModel simulation state + fetch wiring (GREEN))
CHANGELOG.md Modified +1 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 4a47342..ae5f60b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added  - **Week-to-date and month-to-date History ranges** (T-1361). The History range control adds two calendar-anchored options — **Wk** (this week so far) and **Mo** (this month so far) — alongside the fixed 7/14/30-day ranges, giving a five-segment control in the order 7d / 14d / 30d / Wk / Mo with 7d still the default. Boundaries are computed against the same Sydney calendar that keys the stored data, with the week's first day taken from the device locale, and are recomputed on every load (range switch, pull-to-refresh, screen appearance). The `/history` endpoint now accepts any range from 1 to 31 days, so a full month-to-date on the 31st of a 31-day month is no longer clipped to 30. The offline cache fallback is bounded by the computed week/month start, so a gappy cache never surfaces a day before the period start, and it now auto-selects the newest cached day (matching the online behaviour) instead of the oldest.-- **Dashboard Simulation** (T-1495). A new **Simulate** control on the Dashboard applies a saved load preset — e.g. "Charge car" at 1.7 kW — as a clearly-labelled what-if, showing how house load, battery flow, and the "empty by" estimate would change, without touching any device. Manage presets (label + watts) in **Settings → Simulation**. The simulation is computed on the backend so its figures stay consistent with the real Dashboard: the battery is shown discharging up to its limit with any excess drawn from the grid, and on a sunny day the added load is taken from your solar export first — so the picture is accurate whether you're on grid power in the evening or soaking up solar at midday. A distinct banner and tinted values make it obvious the numbers are simulated, and it clears when you turn it off or relaunch the app. iOS + macOS.+- **Dashboard Simulation** (T-1495). A new **Simulate** pill in the Dashboard hero applies a saved load preset — e.g. "Charge car" at 1.7 kW — as a clearly-labelled what-if, showing how house load, battery flow, and the "empty by" estimate would change, without touching any device. The pill sits beside the battery percentage on iPhone, iPad, and macOS, and takes the simulation accent with a dot while a what-if is active. Manage presets (label + watts) in **Settings → Simulation**. The simulation is computed on the backend so its figures stay consistent with the real Dashboard: the battery is shown discharging up to its limit with any excess drawn from the grid, and on a sunny day the added load is taken from your solar export first — so the picture is accurate whether you're on grid power in the evening or soaking up solar at midday. A distinct banner and tinted values make it obvious the numbers are simulated, and it clears when you turn it off or relaunch the app. iOS + macOS.  ### Changed 

Things to double-check

Pill / numeral layout on a narrow device.

Couldn't render the live hero (no booted simulator with credentials), so the sibling-layout + minimumScaleFactor(0.6) guard against the 108 pt numeral colliding with the pill on a 375 pt device (e.g. iPhone 13 mini, common “84.3%” reading) is reasoned, not visually confirmed. The author's on-device install was an iPhone (likely ≥393 pt). Worth a quick eyeball on the narrowest target before merge.

Per-appearance preset GET.

If returning to the Dashboard frequently feels chatty, the isEmpty guard is a one-line change — but it trades away cross-device preset freshness. Flagged so the trade-off is a conscious one.