-
Notifications
You must be signed in to change notification settings - Fork 10
Keep Manual… visible after leaving the planner #1032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0f59234
b129511
91940d6
4754273
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "ftw": patch | ||
| --- | ||
|
|
||
| Keep Manual… strategy buttons on the Plan card in simple view, open them when the live mode changes to a manual fallback, and mark a tap before the server confirms. Hide manual now stays hidden — the status poll no longer reopens the drawer — and a tap no longer flickers back to the previous strategy. | ||
|
|
||
| A house left in a manual mode can start planning again: the Plan card shows "Use the plan" whenever the planner is not driving. It hands the battery to the planner mode this household's own prefs imply — the passive one unless battery export is allowed — and never grants export rights on its own. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,9 @@ | |
| const STATUS_DISPLAY_TAU_MS = 8 * 1000; | ||
| let chartRange = "5m"; // current selected range | ||
| let currentMode = null; | ||
| let lastRevealedMode = null; // last mode the manual drawer auto-opened for | ||
| let pendingMode = null; // mode tapped here, not yet confirmed by the server | ||
| let pendingModeUntil = 0; // browser-clock deadline for that optimistic paint | ||
| let animating = !document.hidden; // 30fps redraw loop flag | ||
| let lastDataTs = 0; // browser-clock timestamp of newest pushed point | ||
| let lastPushAt = 0; // browser-clock timestamp of last push attempt — for dedupe (NEVER mix with server ts) | ||
|
|
@@ -782,13 +785,25 @@ | |
| // Buttons come from GET /api/modes; if that hasn't landed yet (offline at | ||
| // first paint), this confirmed-live poll is the retry trigger. | ||
| if (!modeCatalogRendered) renderModeCatalog(); | ||
| // A tap paints its button before the POST returns. A status read already | ||
| // in flight still answers with the old mode, so prefer the tapped one | ||
| // until the server confirms it or the short wait runs out. | ||
| if (pendingMode && (data.mode === pendingMode || Date.now() >= pendingModeUntil)) pendingMode = null; | ||
| var activeMode = pendingMode || data.mode; | ||
| var allModeButtons = document.querySelectorAll("#mode-buttons-primary button, #mode-buttons button"); | ||
| allModeButtons.forEach(function (btn) { | ||
| if (btn.dataset.mode === data.mode) btn.classList.add("active"); | ||
| if (btn.dataset.mode === activeMode) btn.classList.add("active"); | ||
| else btn.classList.remove("active"); | ||
| }); | ||
| revealManualModes(activeMode); | ||
| // When planner is driving, grey out the grid-target slider and show a hint. | ||
| var plannerActive = (data.mode || "").indexOf("planner_") === 0; | ||
| // "Use the plan" is the way out of a manual mode. It has nothing to | ||
| // offer while the planner already drives, so it only shows when it does. | ||
| var planUseRow = document.getElementById("plan-use-row"); | ||
| var planUseBtn = document.getElementById("plan-use-btn"); | ||
| if (planUseBtn) planUseBtn.hidden = plannerActive; | ||
| if (planUseRow) planUseRow.hidden = plannerActive; | ||
| var gridSlider = document.getElementById("grid-target-slider"); | ||
| var gridSend = document.getElementById("grid-target-send"); | ||
| var gridHint = document.getElementById("grid-target-hint"); | ||
|
|
@@ -2306,6 +2321,10 @@ | |
| } | ||
|
|
||
| function setMode(mode) { | ||
| markModeActive(mode); | ||
| revealManualModes(mode); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| pendingMode = mode; | ||
| pendingModeUntil = Date.now() + 4000; | ||
| apiFetch("/api/mode", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
|
|
@@ -2317,10 +2336,42 @@ | |
| fetchStatus(); | ||
| }) | ||
| .catch(function () { | ||
| pendingMode = null; // the write failed — show server truth again | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failed tap clears newer pending modeLow Severity The Reviewed by Cursor Bugbot for commit b129511. Configure here. |
||
| setConnected(false); | ||
| }); | ||
| } | ||
|
|
||
| function markModeActive(mode) { | ||
| document.querySelectorAll("#mode-buttons-primary button, #mode-buttons button").forEach(function (btn) { | ||
| if (btn.dataset.mode === mode) btn.classList.add("active"); | ||
| else btn.classList.remove("active"); | ||
| }); | ||
| } | ||
|
|
||
| // Open the manual drawer when the live mode lives there, so a reload | ||
| // (or a change made from the phone app / HA) never leaves the current | ||
| // setting with no button on screen. | ||
| // | ||
| // Only on a transition. The status poll repeats the same mode every couple | ||
| // of seconds; re-opening on every repeat would undo an explicit "Hide | ||
| // manual" a second after the user pressed it. A move to a *different* | ||
| // manual mode still opens the drawer — that button has to be on screen. | ||
| function revealManualModes(mode) { | ||
| if (!mode || mode === lastRevealedMode) return; | ||
| var panel = document.getElementById("mode-buttons"); | ||
| if (!panel) return; | ||
| var match = panel.querySelector('button[data-mode="' + mode + '"]'); | ||
| // Before the catalog paints, a missing button means "not rendered yet", | ||
| // not "not a manual mode" — don't record it, or the catalog's own call | ||
| // would come back as a repeat and never open the drawer. | ||
| if (!match && !modeCatalogRendered) return; | ||
| lastRevealedMode = mode; | ||
| if (!match) return; | ||
| panel.style.display = "flex"; | ||
| var advBtn = document.getElementById("mode-advanced-btn"); | ||
| if (advBtn) advBtn.textContent = "Hide manual"; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| // ---- Mode buttons, built from the server's canonical catalog ---- | ||
| // The dashboard no longer hard-codes which modes exist or how they're | ||
| // labelled. GET /api/modes returns every selectable mode with a label, | ||
|
|
@@ -2365,6 +2416,7 @@ | |
| advanced.replaceChildren(frags.advanced); | ||
| primary.hidden = !primary.childElementCount; | ||
| modeCatalogRendered = true; | ||
| revealManualModes(currentMode); | ||
| return true; | ||
| }) | ||
| .catch(function () { | ||
|
|
@@ -2457,6 +2509,34 @@ | |
| } | ||
| }); | ||
| } | ||
| // Permission to sell from the battery is a deliberate household answer, so | ||
| // a prefs read that fails or answers with nothing usable lands on the mode | ||
| // that never exports. | ||
| var PLANNER_FALLBACK_MODE = "planner_passive_arbitrage"; | ||
| // "Use the plan" — the one control that hands a manually-driven house back | ||
| // to the planner. Which planner mode that is follows from the household's | ||
| // own prefs, and the server already maps them (mapped_mode), so the two | ||
| // surfaces cannot drift. setMode() from here on, so the optimistic paint, | ||
| // the pending-mode hold and the drawer all behave as they do for a tap. | ||
| var planUseBtn = document.getElementById("plan-use-btn"); | ||
| if (planUseBtn) { | ||
| planUseBtn.addEventListener("click", function () { | ||
| apiFetch("/api/planner/prefs", { headers: { Accept: "application/json" } }) | ||
| .then(function (r) { | ||
| if (!r.ok) throw new Error("HTTP " + r.status); | ||
| return r.json(); | ||
| }) | ||
| .then(function (prefs) { | ||
| var mapped = prefs && prefs.mapped_mode; | ||
| setMode(typeof mapped === "string" && mapped.indexOf("planner_") === 0 | ||
| ? mapped | ||
| : PLANNER_FALLBACK_MODE); | ||
| }) | ||
| .catch(function () { | ||
| setMode(PLANNER_FALLBACK_MODE); | ||
| }); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }); | ||
| } | ||
| var advBtn = document.getElementById("mode-advanced-btn"); | ||
| if (advBtn) { | ||
| advBtn.addEventListener("click", function () { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,9 +69,10 @@ | |
| <div class="header-right"> | ||
| <button id="theme-toggle" class="icon-btn" data-menu-label="Dark mode" title="Toggle light / dark theme">☾</button> | ||
| <!-- Advanced toggle moved below the Plan chart so it's visually | ||
| colocated with the controls + diagnostics it reveals (twins, | ||
| drivers, models, manual mode buttons). The button still | ||
| lives in the DOM as #ui-mode-toggle, just rendered there. --> | ||
| colocated with the diagnostics it reveals (twins, drivers, | ||
| models). The button still lives in the DOM as #ui-mode-toggle, | ||
| just rendered there. Manual strategy fallbacks stay on the | ||
| Plan card itself. --> | ||
| <ftw-notif-history poll-ms="30000" data-menu-label="Notification history"></ftw-notif-history> | ||
| <!-- Always-available entry into the setup wizard. The wizard | ||
| currently REPLACES config on save, so the copy says "Run setup | ||
|
|
@@ -566,14 +567,29 @@ <h2>Plan</h2> | |
| <p id="plan-export-unknown" class="plan-export-unknown" hidden>Not checked — battery export stays off.</p> | ||
| </div> | ||
| <p id="plan-export-sentence" class="plan-export-sentence"></p> | ||
| <!-- The way back to the planner. Without it a house left in a | ||
| manual mode has no control here that starts planning again. | ||
| Which planner mode it lands on is the server's answer | ||
| (GET /api/planner/prefs → mapped_mode), so this button never | ||
| decides on its own whether the battery may sell. Shown only | ||
| while a manual mode is driving. --> | ||
| <div class="mode-buttons mode-buttons-primary" id="plan-use-row" hidden> | ||
| <button type="button" id="plan-use-btn" | ||
| title="Hand the battery back to the plan">Use the plan</button> | ||
| </div> | ||
| <!-- Remaining /api/modes entries (manual) still render here. | ||
| Planner keys are skipped in renderModeCatalog. --> | ||
| <div class="mode-buttons mode-buttons-primary" id="mode-buttons-primary" hidden></div> | ||
| <div class="strategy-hint" id="strategy-hint"></div> | ||
| <div class="mode-advanced-toggle advanced-only"> | ||
| <!-- Manual fallbacks are a drawer on this card, not a diagnostic. | ||
| They used to be `.advanced-only`, so the simple view hid both | ||
| the current manual mode and the way back to a planner | ||
| strategy. Keep them behind "Manual…" — that is already the | ||
| progressive disclosure. --> | ||
| <div class="mode-advanced-toggle"> | ||
| <button class="btn-link" id="mode-advanced-btn" title="Show manual modes">Manual…</button> | ||
| </div> | ||
| <div class="mode-buttons mode-buttons-advanced advanced-only" id="mode-buttons" style="display:none"></div> | ||
| <div class="mode-buttons mode-buttons-advanced" id="mode-buttons" style="display:none"></div> | ||
|
Comment on lines
+589
to
+592
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This changes rendered strategy controls, yet the commit's own verification says the dashboard was not driven in a browser and leaves the human browser-review checkbox unchecked. A human needs to inspect the Plan card in simple mode with both planner and manual live states before landing, because repository policy does not allow source inspection and tests alone to validate UI changes. AGENTS.md reference: AGENTS.md:L90-L91 Useful? React with 👍 / 👎. |
||
| </div> | ||
| <p class="plan-help"> | ||
| Forecast-driven battery schedule for the next 48 h, recomputed every few minutes. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { readFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| import { describe, it } from "node:test"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const webRoot = dirname(fileURLToPath(import.meta.url)); | ||
| const html = readFileSync(join(webRoot, "index.html"), "utf8"); | ||
| const app = readFileSync(join(webRoot, "app.js"), "utf8"); | ||
|
|
||
| describe("strategy mode picker", () => { | ||
| it("keeps Manual… on the Plan card in simple view", () => { | ||
| // The simple/advanced UI toggle hides diagnostics. Manual fallbacks | ||
| // used to ride that same class, so a house already on Self (manual) | ||
| // had no selected button and no labelled way back to a planner | ||
| // strategy until someone found ★ Advanced. | ||
| const strategy = html.match(/class="plan-strategy"[\s\S]*?class="plan-help"/)?.[0] || ""; | ||
| assert.match(strategy, /id="mode-advanced-btn"/); | ||
| assert.match(strategy, /id="mode-buttons"/); | ||
| assert.doesNotMatch(strategy, /class="[^"]*advanced-only/); | ||
| }); | ||
|
|
||
| it("opens the manual drawer when the live mode lives there", () => { | ||
| assert.match(app, /function revealManualModes\(mode\)/); | ||
| assert.match(app, /revealManualModes\(activeMode\)/); | ||
| assert.match(app, /revealManualModes\(currentMode\)/); | ||
| }); | ||
|
|
||
| it("auto-opens only when the mode changes", () => { | ||
| // The status poll repeats the same mode every couple of seconds. Without | ||
| // the early return, each one would force the drawer back open and undo | ||
| // "Hide manual" a second after the user pressed it. | ||
| assert.match(app, /lastRevealedMode = null/); | ||
| assert.match(app, /if \(!mode \|\| mode === lastRevealedMode\) return;/); | ||
| }); | ||
|
|
||
| it("offers one way back to the planner on the Plan card", () => { | ||
| // Household prefs replaced Passive/Active as the primary buttons, so a | ||
| // house already in a manual mode had nothing left to press to start | ||
| // planning again. | ||
| const strategy = html.match(/class="plan-strategy"[\s\S]*?class="plan-help"/)?.[0] || ""; | ||
| assert.match(strategy, /id="plan-use-btn"/); | ||
| assert.match(strategy, /Use the plan/); | ||
| }); | ||
|
|
||
| it("shows Use the plan only while the planner is not driving", () => { | ||
| assert.match(app, /var plannerActive = \(data\.mode \|\| ""\)\.indexOf\("planner_"\) === 0;/); | ||
| assert.match(app, /planUseBtn\.hidden = plannerActive;/); | ||
| assert.match(app, /planUseRow\.hidden = plannerActive;/); | ||
| }); | ||
|
|
||
| it("takes the planner mode from the household's own prefs", () => { | ||
| // The server maps prefs to a planner key; reading mapped_mode keeps the | ||
| // dashboard from deciding whether this battery may sell. | ||
| assert.match(app, /apiFetch\("\/api\/planner\/prefs"/); | ||
| assert.match(app, /var mapped = prefs && prefs\.mapped_mode;/); | ||
| assert.match(app, /setMode\(typeof mapped === "string"/); | ||
| }); | ||
|
|
||
| it("falls back to the passive planner, never to selling", () => { | ||
| assert.match(app, /var PLANNER_FALLBACK_MODE = "planner_passive_arbitrage";/); | ||
| // Both the unusable answer and the failed read take that fallback. | ||
| assert.match(app, /\?\s*mapped\s*:\s*PLANNER_FALLBACK_MODE\);/); | ||
| assert.match(app, /\.catch\(function \(\) \{\s*setMode\(PLANNER_FALLBACK_MODE\);/); | ||
| // Whatever else the file grows, no path here may name the exporting | ||
| // mode outright: permission to sell comes from the household, through | ||
| // mapped_mode, or not at all. | ||
| const useBlock = app.slice(app.indexOf("PLANNER_FALLBACK_MODE"), app.indexOf("mode-advanced-btn")); | ||
| assert.doesNotMatch(useBlock, /"planner_arbitrage"/); | ||
| }); | ||
|
|
||
| it("marks the tapped mode before the POST returns", () => { | ||
| assert.match(app, /function markModeActive\(mode\)/); | ||
| assert.match( | ||
| app, | ||
| /function setMode\(mode\) \{\s*markModeActive\(mode\);\s*revealManualModes\(mode\);/s, | ||
| ); | ||
| // …and holds it, so a status read already in flight with the old mode | ||
| // can't flash the previous button back. | ||
| assert.match(app, /pendingMode = mode;\s*pendingModeUntil = Date\.now\(\)/); | ||
| assert.match(app, /var activeMode = pendingMode \|\| data\.mode;/); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disabled plan button lights up on hover
Low Severity
The
#plan-use-btn:hoverrule setsopacityto 0.9 with an ID, so it wins over the sharedbutton:disabledfade andbutton:disabled:hovergrey-out. When the planner cannot run, hoveringUse the planmakes the disabled control look tappable.Reviewed by Cursor Bugbot for commit 4754273. Configure here.