Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/keep-manual-mode-buttons.md
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.
44 changes: 44 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Copy link
Copy Markdown
Contributor

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:hover rule sets opacity to 0.9 with an ID, so it wins over the shared button:disabled fade and button:disabled:hover grey-out. When the planner cannot run, hovering Use the plan makes the disabled control look tappable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4754273. Configure here.

body.ftw-app .engine-details {
margin-top: 16px;
border-top: 1px solid var(--line);
Expand Down
82 changes: 81 additions & 1 deletion web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -2306,6 +2321,10 @@
}

function setMode(mode) {
markModeActive(mode);
revealManualModes(mode);
Comment thread
cursor[bot] marked this conversation as resolved.
pendingMode = mode;
pendingModeUntil = Date.now() + 4000;
apiFetch("/api/mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -2317,10 +2336,42 @@
fetchStatus();
})
.catch(function () {
pendingMode = null; // the write failed — show server truth again

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed tap clears newer pending mode

Low Severity

The setMode failure handler always sets pendingMode to null, even when a later tap has already stored a different mode there. An in-flight status poll can then paint the old live mode over the newer optimistic selection until that second POST settles.

Fix in Cursor Fix in Web

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";
}
Comment thread
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,
Expand Down Expand Up @@ -2365,6 +2416,7 @@
advanced.replaceChildren(frags.advanced);
primary.hidden = !primary.childElementCount;
modeCatalogRendered = true;
revealManualModes(currentMode);
return true;
})
.catch(function () {
Expand Down Expand Up @@ -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);
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
}
var advBtn = document.getElementById("mode-advanced-btn");
if (advBtn) {
advBtn.addEventListener("click", function () {
Expand Down
26 changes: 21 additions & 5 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">&#9790;</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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Complete the required browser inspection

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&nbsp;h, recomputed every few minutes.
Expand Down
83 changes: 83 additions & 0 deletions web/mode-picker.test.mjs
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;/);
});
});
4 changes: 2 additions & 2 deletions web/plan-brief.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Comment thread
cursor[bot] marked this conversation as resolved.
},
};
}
Expand Down
10 changes: 7 additions & 3 deletions web/plan-brief.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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");
});

Expand Down
1 change: 1 addition & 0 deletions web/plan-unavailable.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
8 changes: 8 additions & 0 deletions web/plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down