diff --git a/.changeset/keep-manual-mode-buttons.md b/.changeset/keep-manual-mode-buttons.md new file mode 100644 index 000000000..16ab5aace --- /dev/null +++ b/.changeset/keep-manual-mode-buttons.md @@ -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. diff --git a/web/app.css b/web/app.css index c72ffc582..e04662ce3 100644 --- a/web/app.css +++ b/web/app.css @@ -1025,6 +1025,11 @@ body.ftw-app .strategy-hint { color: var(--fg); line-height: 1.5; } +/* The hint describes the manual mode in use, so it is empty while the + planner drives — without this it leaves a bordered blank strip. */ +body.ftw-app .strategy-hint:empty { + display: none; +} body.ftw-app .forecast-trust { margin: 0 0 12px; } @@ -1124,6 +1129,45 @@ body.ftw-app .plan-export-sentence { font-size: 13px; line-height: 1.4; } +/* The Plan card is as wide as the chart below it. Controls stretched to + that width read as a banner, not as something to touch, and prose set + across it is hard to follow — so both get a comfortable measure and + the rest of the row stays empty. */ +body.ftw-app .plan-strategy .forecast-trust, +body.ftw-app .plan-strategy .plan-export, +body.ftw-app .plan-strategy .plan-export-banner, +body.ftw-app .plan-strategy .plan-export-row, +body.ftw-app .plan-strategy .mode-advanced-toggle, +body.ftw-app .plan-strategy #mode-buttons { + max-width: 560px; +} +body.ftw-app .plan-strategy .forecast-trust-help, +body.ftw-app .plan-strategy .forecast-trust-yaml, +body.ftw-app .plan-strategy .plan-export-help, +body.ftw-app .plan-strategy .plan-export-unknown, +body.ftw-app .plan-strategy .plan-export-sentence, +body.ftw-app .plan-strategy .strategy-hint { + max-width: 70ch; +} +/* "Use the plan" is the one action on this card while a manual mode + drives, so it takes the accent the mode buttons do not — and only the + width its label needs. */ +body.ftw-app #plan-use-row { + margin-top: 12px; +} +body.ftw-app .mode-buttons #plan-use-btn { + flex: 0 0 auto; + background: var(--accent-e); + border: 1px solid var(--accent-e); + color: var(--on-accent); + font-weight: 600; + padding: 8px 16px; +} +body.ftw-app .mode-buttons #plan-use-btn:hover { + background: var(--accent-e); + color: var(--on-accent); + opacity: 0.9; +} body.ftw-app .engine-details { margin-top: 16px; border-top: 1px solid var(--line); diff --git a/web/app.js b/web/app.js index 799bb0c97..6a6900b51 100644 --- a/web/app.js +++ b/web/app.js @@ -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); + 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 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"; + } + // ---- 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); + }); + }); + } var advBtn = document.getElementById("mode-advanced-btn"); if (advBtn) { advBtn.addEventListener("click", function () { diff --git a/web/index.html b/web/index.html index 4ca7cd248..0625cf3ea 100644 --- a/web/index.html +++ b/web/index.html @@ -69,9 +69,10 @@
+ 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. --> +
-
+ +
- +

Forecast-driven battery schedule for the next 48 h, recomputed every few minutes. diff --git a/web/mode-picker.test.mjs b/web/mode-picker.test.mjs new file mode 100644 index 000000000..89571bc57 --- /dev/null +++ b/web/mode-picker.test.mjs @@ -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;/); + }); +}); diff --git a/web/plan-brief.js b/web/plan-brief.js index ca69526d4..9fbfebb55 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -115,7 +115,7 @@ function manualBrief(status, hasBattery) { state: { key: "manual", label: "Manual", tone: "idle" }, next: { action: "Manual control is active", - time: "Choose a planning strategy to create a schedule", + time: "Use the plan to create a schedule", }, reason: "Planning is not controlling the battery", constraint: "FTW safety limits still apply to manual control", @@ -126,7 +126,7 @@ function manualBrief(status, hasBattery) { soc: batterySocNow(status, hasBattery, "Expected charge needs an active plan"), planner: { label: "Planner off", - detail: "Select a planning strategy to enable it", + detail: "Use the plan to enable it", }, }; } diff --git a/web/plan-brief.test.mjs b/web/plan-brief.test.mjs index 3fdb7d125..072ca13f0 100644 --- a/web/plan-brief.test.mjs +++ b/web/plan-brief.test.mjs @@ -30,7 +30,9 @@ describe("plan brief normalization", () => { tone: "idle", }); assert.equal(brief.next.action, "Manual control is active"); - assert.match(brief.next.time, /planning strategy/); + // Both manual sentences name the button that actually exists on the + // card. There is no strategy picker to send anyone to any more. + assert.match(brief.next.time, /Use the plan/); assert.equal(brief.soc, null); }); @@ -190,8 +192,10 @@ describe("plan brief normalization", () => { assert.equal(brief.state.label, "Cannot plan"); assert.match(brief.next.action, /controllable battery/); assert.match(brief.next.time, /Devices/); - assert.doesNotMatch(brief.next.time, /planning strategy/); - assert.doesNotMatch(brief.planner.detail, /Select a planning strategy/); + // A house with no controllable battery is not one button away from a + // plan, so it must not be told to press one. + assert.doesNotMatch(brief.next.time, /Use the plan/); + assert.doesNotMatch(brief.planner.detail, /Use the plan/); assert.equal(brief.soc.label, "40% now"); }); diff --git a/web/plan-unavailable.test.mjs b/web/plan-unavailable.test.mjs index a8cde43be..29bd0a18e 100644 --- a/web/plan-unavailable.test.mjs +++ b/web/plan-unavailable.test.mjs @@ -11,6 +11,7 @@ describe("plan unavailable reason", () => { assert.match(plan, /applyPlannerModeAvailability/); assert.match(plan, /btn\.disabled = !enabled/); assert.match(plan, /replan\.disabled = !enabled/); + assert.match(plan, /usePlan\.disabled = !enabled/); assert.doesNotMatch(plan, /MPC planner disabled/); }); }); diff --git a/web/plan.js b/web/plan.js index 0a56f94a4..95313899c 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1231,6 +1231,14 @@ import { replan.disabled = !enabled; replan.title = enabled ? 'Force a fresh plan' : copy.summary; } + // "Use the plan" carries no data-mode, so the loop above never sees it. + // Offering it while the planner cannot run would hand the house to a + // mode that then has to explain why it is not planning. + const usePlan = document.getElementById('plan-use-btn'); + if (usePlan) { + usePlan.disabled = !enabled; + usePlan.title = enabled ? 'Hand the battery back to the plan' : copy.detail; + } } function renderStrategyHint() {