From 02b2996285204b73145f26106cd4d1e8cbf7180a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 16:54:07 +0200 Subject: [PATCH 1/8] feat(plan): five planning styles instead of the k slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A beta tester asked why the plan would not put the day's solar surplus into the battery. The answer was the forecast margin, but the Plan card showed it as "Follow the forecast" with a raw k from 0 to 2 that rose as trust fell, and a chart whose dashed and solid lines were hard to tell apart. The card now offers five planning styles, Very careful to Very bold, on the same stored safety_k. Under them, one line says how much of the spare sun the plan counts on for the window the chart shows, and another says whether extra sun goes into the battery or to the grid, from Core's per-slot live_pv_surplus_soc_cap when the box sends it. The chart shades the margin. Settings → Planner fine-tunes k and saves at once, and shows the margin as sun held back and use added. Balanced (k 0.3) is the new default. A closed-loop replay of a week of the home box's recorded plans found k up to 0.3 cost the same within noise, while k 1 cost 3.6–5.4 % more. A box that still runs the old default k 1 moves to Balanced once; any other stored value stays. Refs srcfl/ftw#1482 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/plan-planning-styles.md | 5 + go/cmd/ftw/main.go | 14 +- go/internal/config/planner_prefs.go | 33 ++- go/internal/config/planner_prefs_test.go | 42 +++- web/app.css | 106 ++++++++-- web/index.html | 39 ++-- web/plan-forecast.js | 20 +- web/plan-forecast.test.mjs | 32 ++- web/plan-prefs.js | 148 ++++++++++++-- web/plan-prefs.test.mjs | 183 +++++++++++++---- web/plan.js | 245 +++++++++++++++-------- web/settings.js | 10 + web/settings/tabs/planner.js | 129 ++++++++---- web/settings/tabs/planner.test.mjs | 33 ++- 14 files changed, 790 insertions(+), 249 deletions(-) create mode 100644 .changeset/plan-planning-styles.md diff --git a/.changeset/plan-planning-styles.md b/.changeset/plan-planning-styles.md new file mode 100644 index 000000000..7915bcd52 --- /dev/null +++ b/.changeset/plan-planning-styles.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The Plan card's forecast slider is now five planning styles, from Very careful to Very bold. A line under them says how much of the spare sun the plan counts on, and another says whether extra sun goes into the battery or to the grid. The default style, Balanced, holds back a smaller forecast margin than before (k 0.3 instead of 1), and a box that still runs the old default moves to it once. Settings → Planner fine-tunes the margin and saves at once. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 6a4215174..96ed1b770 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -589,12 +589,20 @@ func main() { yamlK = cfg.Planner.PVForecastSafetyK } trust, export, safetyK, missingPrefs := config.ResolvePlannerPrefs(storedTrust, storedExport, storedSafetyK, string(ctrl.Mode), yamlTrust, yamlExport, yamlK) + stylesDone, _ := st.LoadConfig(config.StateKeyPlanningStyles) + if k, first := config.MigrateOldDefaultSafetyK(safetyK, stylesDone); first { + if k != safetyK { + slog.Info("planner: old default forecast margin moved to the Balanced style", "from", safetyK, "to", k) + } + safetyK, trust, missingPrefs = k, config.TrustFromSafetyK(k), true + } plannerPrefs := config.NewPlannerPrefs(trust, export, safetyK) if missingPrefs { if err := st.SaveConfigValues(map[string]string{ - config.StateKeySafetyK: config.FormatSafetyK(safetyK), - config.StateKeyForecastTrust: string(trust), - config.StateKeyBatteryExport: string(export), + config.StateKeySafetyK: config.FormatSafetyK(safetyK), + config.StateKeyForecastTrust: string(trust), + config.StateKeyBatteryExport: string(export), + config.StateKeyPlanningStyles: "1", }); err != nil { slog.Error("save planner preferences", "err", err) os.Exit(1) diff --git a/go/internal/config/planner_prefs.go b/go/internal/config/planner_prefs.go index f32a7c95b..e8fe1e1cd 100644 --- a/go/internal/config/planner_prefs.go +++ b/go/internal/config/planner_prefs.go @@ -11,15 +11,24 @@ const ( StateKeyForecastTrust = "forecast_trust" StateKeyBatteryExport = "battery_export" StateKeySafetyK = "planner_safety_k" + // StateKeyPlanningStyles marks a box whose stored k has been through + // MigrateOldDefaultSafetyK, so the move happens once and never again. + StateKeyPlanningStyles = "planner_planning_styles" ) // SafetyK bounds. 0 plans against the raw forecast; 2 holds back twice each // slot's own forecast error. Above 2 the haircut erases the sunny shoulders // outright, which is a worse plan, not a safer one. +// +// SafetyKDefault is the Plan card's Balanced style. The card's five styles +// live in web/plan-prefs.js (PLAN_STYLES); keep this value in step with it. const ( SafetyKMin = 0.0 SafetyKMax = 2.0 - SafetyKDefault = 1.0 + SafetyKDefault = 0.3 + + // oldSafetyKDefault is the default every box stored before the styles. + oldSafetyKDefault = 1.0 ) // ForecastTrust is how hard the planner bets the PV/price forecast is right. @@ -64,7 +73,9 @@ func ParseBatteryExport(s string) (BatteryExport, bool) { } } -// SafetyK is the PV downside haircut scale for this trust level. +// SafetyK is the PV downside haircut scale for this trust level. Balanced is +// the Plan card's Balanced style, so an old client asking for balanced gets +// the same plan as the card's default. func (t ForecastTrust) SafetyK() float64 { switch t { case ForecastTrustCautious: @@ -72,7 +83,7 @@ func (t ForecastTrust) SafetyK() float64 { case ForecastTrustBold: return 0.0 default: - return 1.0 + return SafetyKDefault } } @@ -206,6 +217,22 @@ func ResolvePlannerPrefs(storedTrust, storedExport, storedK, persistedMode, yaml return trust, export, safetyK, missingStored } +// MigrateOldDefaultSafetyK moves a box that still runs the old default k=1 +// onto the Balanced style, once. k=1 was both the first-boot default and the +// old three-step "balanced", so a stored 1 means "the middle", and the middle +// is now SafetyKDefault. Any other value was chosen on the slider and stays. +// done is the StateKeyPlanningStyles marker; after the first call the caller +// stores it and this returns k unchanged forever. +func MigrateOldDefaultSafetyK(k float64, done string) (float64, bool) { + if done != "" { + return k, false + } + if k == oldSafetyKDefault { + return SafetyKDefault, true + } + return k, true +} + // EffectiveSafetyK is the haircut scale the planner runs with, clamped to the // slider's range. An explicit pv_forecast_safety_k in YAML does not win here: // it seeds the first boot (ResolvePlannerPrefs) and nothing else — the Plan diff --git a/go/internal/config/planner_prefs_test.go b/go/internal/config/planner_prefs_test.go index b011680a3..cef58a6db 100644 --- a/go/internal/config/planner_prefs_test.go +++ b/go/internal/config/planner_prefs_test.go @@ -9,14 +9,14 @@ func TestForecastTrustSafetyK(t *testing.T) { if got := ForecastTrustCautious.SafetyK(); got != 2 { t.Errorf("cautious k=%v, want 2", got) } - if got := ForecastTrustBalanced.SafetyK(); got != 1 { - t.Errorf("balanced k=%v, want 1", got) + if got := ForecastTrustBalanced.SafetyK(); got != SafetyKDefault { + t.Errorf("balanced k=%v, want the Balanced style %v", got, SafetyKDefault) } if got := ForecastTrustBold.SafetyK(); got != 0 { t.Errorf("bold k=%v, want 0", got) } - if got := ForecastTrust("").SafetyK(); got != 1 { - t.Errorf("empty k=%v, want 1", got) + if got := ForecastTrust("").SafetyK(); got != SafetyKDefault { + t.Errorf("empty k=%v, want %v", got, SafetyKDefault) } } @@ -67,12 +67,12 @@ func TestResolvePlannerPrefsStoredWins(t *testing.T) { func TestResolvePlannerPrefsEnumOnlySiteKeepsItsK(t *testing.T) { // A site upgraded from the three-step slider has no planner_safety_k - // row. Its plan must not move: k resolves to the step's own value and - // missingStored asks the caller to write the float. + // row. k resolves to the step's value, where balanced is the Plan card's + // Balanced style, and missingStored asks the caller to write the float. for _, tc := range []struct { trust string wantK float64 - }{{"cautious", 2}, {"balanced", 1}, {"bold", 0}} { + }{{"cautious", 2}, {"balanced", SafetyKDefault}, {"bold", 0}} { trust, _, k, missing := ResolvePlannerPrefs(tc.trust, "allowed", "", "planner_passive_arbitrage", "", "", nil) if k != tc.wantK { t.Errorf("%s → k=%v, want %v", tc.trust, k, tc.wantK) @@ -128,8 +128,8 @@ func TestResolvePlannerPrefsActiveUpgradeAsks(t *testing.T) { if trust != ForecastTrustBalanced { t.Errorf("trust=%s, want balanced", trust) } - if k != 1 { - t.Errorf("k=%v, want 1", k) + if k != SafetyKDefault { + t.Errorf("k=%v, want the Balanced style %v", k, SafetyKDefault) } if export != BatteryExportUnknown { t.Errorf("export=%s, want unknown (must confirm)", export) @@ -201,3 +201,27 @@ func TestResolvePlannerPrefsSeedsFromYAMLK(t *testing.T) { t.Fatalf("stored float must win, got %v", k) } } + +func TestMigrateOldDefaultSafetyKMovesOnlyTheOldDefaultOnce(t *testing.T) { + for _, tc := range []struct { + name string + k float64 + done string + wantK float64 + wantFirst bool + }{ + // k=1 was the first-boot default and the old "balanced": the middle. + {"old default", 1, "", SafetyKDefault, true}, + // A value picked on the slider stays where the household put it. + {"picked bold", 0.15, "", 0.15, true}, + {"picked careful", 2, "", 2, true}, + {"already balanced", SafetyKDefault, "", SafetyKDefault, true}, + // Once the marker is stored, a later k=1 is a choice and stays. + {"after the move", 1, "1", 1, false}, + } { + k, first := MigrateOldDefaultSafetyK(tc.k, tc.done) + if k != tc.wantK || first != tc.wantFirst { + t.Errorf("%s: got (%v, %v), want (%v, %v)", tc.name, k, first, tc.wantK, tc.wantFirst) + } + } +} diff --git a/web/app.css b/web/app.css index 3c3b102a4..c7eddb646 100644 --- a/web/app.css +++ b/web/app.css @@ -1067,19 +1067,52 @@ body.ftw-app .strategy-hint { body.ftw-app .strategy-hint:empty { display: none; } -body.ftw-app .forecast-trust { +body.ftw-app .plan-style { margin: 0 0 12px; } -body.ftw-app .forecast-trust input[type="range"] { - width: 100%; - accent-color: var(--accent-e); +/* Five equal steps, like a fund's risk scale: one row, the chosen step + filled. Names wrap to two lines on a phone rather than shrink. */ +body.ftw-app .plan-style-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; +} +body.ftw-app .plan-style-steps button { + min-height: 44px; margin: 0; + padding: 6px 4px; + border: 0; + border-left: 1px solid var(--line); + border-radius: 0; + background: var(--ink-sunken); + color: var(--fg-dim); + cursor: pointer; + font-family: var(--sans); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + hyphens: none; + word-break: keep-all; } -body.ftw-app .forecast-trust input[type="range"]:disabled { - opacity: 0.45; - cursor: not-allowed; +body.ftw-app .plan-style-steps button:first-child { + border-left: 0; +} +body.ftw-app .plan-style-steps button:hover { + background: var(--ink-elevated); + color: var(--fg); +} +body.ftw-app .plan-style-steps button[aria-checked="true"] { + background: oklch(0.3 0.1 var(--accent-hue) / 0.35); + box-shadow: inset 0 0 0 1px var(--accent-e); + color: var(--fg); +} +body.ftw-app .plan-style-steps button:focus-visible { + outline: 2px solid var(--accent-e); + outline-offset: -3px; } -body.ftw-app .forecast-trust-labels { +body.ftw-app .plan-style-ends { display: flex; justify-content: space-between; gap: 8px; @@ -1088,20 +1121,40 @@ body.ftw-app .forecast-trust-labels { font-family: var(--sans); font-size: 12px; } -body.ftw-app .forecast-trust-labels #forecast-trust-value { +body.ftw-app .plan-style-ends span:last-child { + text-align: right; +} +body.ftw-app .plan-style-text, +body.ftw-app .plan-style-sun, +body.ftw-app .plan-style-extra, +body.ftw-app .plan-style-note, +body.ftw-app .plan-style-status { + margin: 8px 0 0; + font-family: var(--sans); + font-size: 13px; + line-height: 1.45; +} +body.ftw-app .plan-style-text, +body.ftw-app .plan-style-sun { color: var(--fg); - font-family: var(--mono); - font-variant-numeric: tabular-nums; } -body.ftw-app .forecast-trust-hedge { - margin-top: 8px; +body.ftw-app .plan-style-extra, +body.ftw-app .plan-style-note { color: var(--fg-dim); - font-family: var(--mono); - font-size: 11px; - font-variant-numeric: tabular-nums; } -body.ftw-app .forecast-trust-help, -body.ftw-app .forecast-trust-yaml, +body.ftw-app .plan-style-note .btn-link { + font-family: var(--sans); + font-size: 13px; +} +body.ftw-app .plan-style-status { + color: var(--red-e); +} +/* The numbers describe the plan that is still running until the new one + lands, so they step back while the box replans. */ +body.ftw-app .plan-style.is-replanning .plan-style-sun, +body.ftw-app .plan-style.is-replanning .plan-style-extra { + opacity: 0.55; +} body.ftw-app .plan-export-help, body.ftw-app .plan-export-unknown { margin: 8px 0 0; @@ -1170,7 +1223,7 @@ body.ftw-app .plan-export-sentence { 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-style, body.ftw-app .plan-strategy .plan-export, body.ftw-app .plan-strategy .plan-export-banner, body.ftw-app .plan-strategy .plan-export-row, @@ -1178,8 +1231,6 @@ 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, @@ -2729,11 +2780,22 @@ body.ftw-app .diagnose-detail { } body.ftw-app .plan-actions { align-items: stretch; flex-wrap: wrap; } body.ftw-app .more-actions { grid-template-columns: 1fr; } - body.ftw-app .forecast-trust-help, + body.ftw-app .plan-style-text, + body.ftw-app .plan-style-sun, + body.ftw-app .plan-style-extra, + body.ftw-app .plan-style-note, body.ftw-app .plan-export-help, body.ftw-app .plan-export-sentence { font-size: 12px; } + /* Five names across a phone: smaller type keeps "Balanced" whole. */ + body.ftw-app .plan-style-steps button { + padding: 6px 2px; + font-size: 11px; + } + body.ftw-app .plan-style-ends { + font-size: 11px; + } body.ftw-app .plan-export-banner-actions { flex-direction: column; align-items: stretch; diff --git a/web/index.html b/web/index.html index a997f7907..7c52f39d3 100644 --- a/web/index.html +++ b/web/index.html @@ -540,21 +540,32 @@

Plan

Passive/Active are no longer primary buttons (HA/app keep those keys). Manual fallbacks stay behind Manual…. -->
- Follow the forecast - -
- -
- Trust forecast - k 1 - Hold reserve + Planning style + +
+
+ + + + +
- -

Left follows the forecast fully — if it is right, that earns more. Right keeps more in the battery in case the sun misses, closer to using the battery only for the house. Every notch changes how much of each slot's own forecast error the plan holds back.

+ + + + +

+ + Safety limits are the same in every style. + +

+
' + + fineHtml + '' + '
' + 'Engine controls — leave these unless you are debugging.' + @@ -101,7 +116,7 @@ engineSelect(planner.engine, help) + '
' + '

Energyplan uses a 500 ms solve limit. Core DP runs in the background for comparison and supplies a fallback if needed.

' + - '
' + kHtml + '
' + + seedHtml + '
' + field("Base load (W)", "planner.base_load_w", "number", 0, "Constant household load estimate used when the load twin has no data yet.") + @@ -155,29 +170,73 @@ }); } - // ---- Live σ/hedge readout under the k field ---- - var hedgeEl = document.getElementById("planner-hedge-line"); - var kInput = document.querySelector('input[data-path="planner.pv_forecast_safety_k"]'); - if (hedgeEl && kInput) { - apiFetch("/api/pvmodel") - .then(function (r) { return r.json(); }) - .then(function (d) { - if (!d || d.enabled === false) return; // pvmodel off → line stays hidden - var sigma = d.pv_residual_std_w; - function update() { - var text = hedgeLine(kInput.value, sigma); - if (text == null) return; - hedgeEl.textContent = text; - hedgeEl.style.display = ""; - } - update(); - kInput.addEventListener("input", update); + // ---- Forecast margin: read, fine-tune, save at once ---- + var kInput = document.getElementById("planner-style-k"); + var kValue = document.getElementById("planner-style-k-value"); + var kNote = document.getElementById("planner-style-k-note"); + var marginEl = document.getElementById("planner-margin-line"); + if (kInput && kValue && kNote) { + var lib = window.FTWPlanPrefs || null; + var exportPerm = "unknown"; + var show = function (k, prefix) { + kValue.textContent = "k " + formatK(k); + kNote.textContent = (prefix || "") + styleNote(k, lib); + }; + apiFetch("/api/planner/prefs") + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (p) { + exportPerm = p.battery_export || "unknown"; + var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; + kInput.value = String(k); + kInput.disabled = false; + show(k); + }) + .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); + kInput.addEventListener("input", function () { show(kInput.value); }); + kInput.addEventListener("change", function () { + var k = Number(kInput.value); + kNote.textContent = "Saving…"; + apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + // Export is sent unchanged: the margin never turns on battery sales. + body: JSON.stringify({ safety_k: k, battery_export: exportPerm }), }) - .catch(function () {}); // unreachable → line stays hidden + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (p) { + var saved = typeof p.safety_k === "number" ? p.safety_k : k; + kInput.value = String(saved); + show(saved, "Saved. "); + window.dispatchEvent(new CustomEvent("ftw-planner-prefs")); + }) + .catch(function () { kNote.textContent = "Not saved: the box did not answer. Try again."; }); + }); + // The same margin as the Plan card, split into sun and use. It + // follows each plan the Plan card fetches, so a save shows up here. + if (marginEl && lib) { + var showMargin = function (actions) { + if (!actions || !actions.length) return; + var last = actions[actions.length - 1]; + var text = lib.marginSplitLine(lib.forecastMargins(actions, Date.now() - 30 * 60 * 1000, + last.slot_start_ms + last.slot_len_min * 60 * 1000)); + marginEl.textContent = text || ""; + marginEl.hidden = !text; + }; + var tab = S.tabs.planner; + if (tab._onPlan) window.removeEventListener("ftw-plan-data", tab._onPlan); + tab._onPlan = function (e) { + if (marginEl.isConnected) showMargin(e.detail && e.detail.plan && e.detail.plan.actions); + }; + window.addEventListener("ftw-plan-data", tab._onPlan); + apiFetch("/api/mpc/plan") + .then(function (r) { return r.json(); }) + .then(function (m) { showMargin(m && m.plan && m.plan.actions); }) + .catch(function () {}); + } } }, }; // Escape hatch for node --test (planner.test.mjs); not a public API. - S.tabs.planner._pure = { strategyLabel: strategyLabel, hedgeLine: hedgeLine, engineSelect: engineSelect }; + S.tabs.planner._pure = { strategyLabel: strategyLabel, styleNote: styleNote, formatK: formatK, engineSelect: engineSelect }; })(); diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index d9861f22c..2de37a62c 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -10,8 +10,9 @@ import assert from "node:assert/strict"; globalThis.window = {}; await import("./planner.js"); +const { styleForK } = await import("../../plan-prefs.js"); const tab = globalThis.window.FTWSettings.tabs.planner; -const { strategyLabel, hedgeLine, engineSelect } = tab._pure; +const { strategyLabel, styleNote, formatK, engineSelect } = tab._pure; describe("strategyLabel", () => { it("maps every planner mode via the local fallback", () => { @@ -45,11 +46,16 @@ describe("strategyLabel", () => { }); }); -describe("hedgeLine", () => { - it("does not infer the active margin from the legacy residual", () => { - assert.match(hedgeLine("1", 0), /margin varies by interval/); - assert.match(hedgeLine("1", 432), /Plan chart/); - assert.equal(hedgeLine("0", 432), "No forecast margin requested."); +describe("styleNote", () => { + it("names the Plan card style a fine-tuned margin belongs to", () => { + assert.equal(styleNote(0.3, { styleForK }), "Balanced. Changes apply at once."); + assert.equal(styleNote("0.15", { styleForK }), "Bold. Changes apply at once."); + assert.equal(styleNote(0.7, { styleForK }), "Between two styles, nearest Careful. Changes apply at once."); + }); + + it("stays plain without the Plan card's style table", () => { + assert.equal(styleNote(0.3, null), "Changes apply at once."); + assert.equal(formatK("0.30000000000000004"), "0.3"); }); }); @@ -68,10 +74,15 @@ describe("render", () => { assert.ok(!html.includes("planner.mode"), "planner.mode must not be bound in the form"); }); - it("renders the active-strategy placeholder and hedge line containers", () => { + it("renders the active-strategy placeholder and the live forecast margin above the engine details", () => { const html = tab.render(stubCtx()); assert.ok(html.includes('id="planner-active-strategy"')); - assert.ok(html.includes('id="planner-hedge-line"')); + const margin = html.indexOf('id="planner-style-k"'); + assert.ok(margin > 0 && margin < html.indexOf(" { @@ -97,14 +108,16 @@ describe("render", () => { it("does not bind pv_forecast_safety_k when YAML left it unset", () => { const html = tab.render(stubCtx()); assert.ok(!html.includes("[field:planner.pv_forecast_safety_k]")); + assert.ok(!html.includes("config.yaml sets pv_forecast_safety_k")); }); - it("binds pv_forecast_safety_k inside engine details when YAML set it", () => { + it("says a YAML pv_forecast_safety_k only seeds the first start", () => { const ctx = stubCtx(); ctx.config.planner = { pv_forecast_safety_k: 0.25 }; const html = tab.render(ctx); const rest = html.slice(html.indexOf(" { From 6b8c7748aeb4c0d362160b0bc12ab4b3acf54d5a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 19:35:34 +0200 Subject: [PATCH 2/8] fix(plan): move only a stored old default k to Balanced On a new box, k can come from the settings seed this boot. That value is the operator's choice, not the old default, so the one-time move to Balanced now applies only to a k that was already stored. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/cmd/ftw/main.go | 2 +- go/internal/config/planner_prefs.go | 12 +++++++----- go/internal/config/planner_prefs_test.go | 15 +++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 96ed1b770..4d74b2a43 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -590,7 +590,7 @@ func main() { } trust, export, safetyK, missingPrefs := config.ResolvePlannerPrefs(storedTrust, storedExport, storedSafetyK, string(ctrl.Mode), yamlTrust, yamlExport, yamlK) stylesDone, _ := st.LoadConfig(config.StateKeyPlanningStyles) - if k, first := config.MigrateOldDefaultSafetyK(safetyK, stylesDone); first { + if k, first := config.MigrateOldDefaultSafetyK(safetyK, storedSafetyK, stylesDone); first { if k != safetyK { slog.Info("planner: old default forecast margin moved to the Balanced style", "from", safetyK, "to", k) } diff --git a/go/internal/config/planner_prefs.go b/go/internal/config/planner_prefs.go index e8fe1e1cd..9451f472d 100644 --- a/go/internal/config/planner_prefs.go +++ b/go/internal/config/planner_prefs.go @@ -220,14 +220,16 @@ func ResolvePlannerPrefs(storedTrust, storedExport, storedK, persistedMode, yaml // MigrateOldDefaultSafetyK moves a box that still runs the old default k=1 // onto the Balanced style, once. k=1 was both the first-boot default and the // old three-step "balanced", so a stored 1 means "the middle", and the middle -// is now SafetyKDefault. Any other value was chosen on the slider and stays. -// done is the StateKeyPlanningStyles marker; after the first call the caller -// stores it and this returns k unchanged forever. -func MigrateOldDefaultSafetyK(k float64, done string) (float64, bool) { +// is now SafetyKDefault. Any other stored value was chosen on the slider and +// stays. stored is the raw SQLite value: a k seeded from YAML on this boot is +// the operator's choice, not an old default. done is the +// StateKeyPlanningStyles marker; after the first call the caller stores it and +// this returns k unchanged forever. +func MigrateOldDefaultSafetyK(k float64, stored, done string) (float64, bool) { if done != "" { return k, false } - if k == oldSafetyKDefault { + if old, ok := ParseSafetyK(stored); ok && old == oldSafetyKDefault { return SafetyKDefault, true } return k, true diff --git a/go/internal/config/planner_prefs_test.go b/go/internal/config/planner_prefs_test.go index cef58a6db..a1337b80b 100644 --- a/go/internal/config/planner_prefs_test.go +++ b/go/internal/config/planner_prefs_test.go @@ -206,20 +206,23 @@ func TestMigrateOldDefaultSafetyKMovesOnlyTheOldDefaultOnce(t *testing.T) { for _, tc := range []struct { name string k float64 + stored string done string wantK float64 wantFirst bool }{ // k=1 was the first-boot default and the old "balanced": the middle. - {"old default", 1, "", SafetyKDefault, true}, + {"old default", 1, "1", "", SafetyKDefault, true}, // A value picked on the slider stays where the household put it. - {"picked bold", 0.15, "", 0.15, true}, - {"picked careful", 2, "", 2, true}, - {"already balanced", SafetyKDefault, "", SafetyKDefault, true}, + {"picked bold", 0.15, "0.15", "", 0.15, true}, + {"picked careful", 2, "2", "", 2, true}, + {"already balanced", SafetyKDefault, "0.3", "", SafetyKDefault, true}, + // A new box seeds k from config.yaml this boot: the operator's choice. + {"seeded from YAML", 1, "", "", 1, true}, // Once the marker is stored, a later k=1 is a choice and stays. - {"after the move", 1, "1", 1, false}, + {"after the move", 1, "1", "1", 1, false}, } { - k, first := MigrateOldDefaultSafetyK(tc.k, tc.done) + k, first := MigrateOldDefaultSafetyK(tc.k, tc.stored, tc.done) if k != tc.wantK || first != tc.wantFirst { t.Errorf("%s: got (%v, %v), want (%v, %v)", tc.name, k, first, tc.wantK, tc.wantFirst) } From 6dc62bd07fbb0d65c10c141cc30b56e5b29c0e4a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 20:09:12 +0200 Subject: [PATCH 3/8] fix(plan): keep newer choices and say where extra sun really goes Four findings from a local Codex review: - Settings fine-tuning sent the export permission seen when the tab opened, so a margin change could turn battery sales back on after another client turned them off. Each save now reads the box's current choice first. - Saves could overlap, and an older reply could reset the slider to an older value and call it saved. Saves now go one at a time, and a reply only updates the slider when no newer value waits and the slider still shows what was sent. - The extra-sun line ignored the operator's surplus cap, which dispatch prefers to the plan's per-slot cap. /api/status now reports pv_surplus_absorb_soc_cap, and the line follows dispatch's rule, including that a discharge slot never stores. - The Settings margin line kept old numbers when the plan went away. It now hides. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/api/api.go | 4 ++ go/internal/api/api_test.go | 23 ++++++++++ web/plan-prefs.js | 19 +++++--- web/plan-prefs.test.mjs | 33 +++++++++----- web/plan.js | 4 +- web/settings/tabs/planner.js | 72 ++++++++++++++++++++++-------- web/settings/tabs/planner.test.mjs | 47 ++++++++++++++++++- 7 files changed, 163 insertions(+), 39 deletions(-) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 4628be87b..10b043fc6 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1226,6 +1226,10 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "control_feedback": s.controlFeedback(time.Now()), "slot_delivery_stats": ctrl.SlotDeliveryStats, } + // The operator's cap for storing live PV surplus, 0 when unset. Dispatch + // prefers it to the plan's per-slot cap, so the Plan card needs it to say + // where extra sun goes. + resp["pv_surplus_absorb_soc_cap"] = ctrl.PVSurplusAbsorbSoCCap // A stale or missing site meter is not 0 W. Publishing zero made the // dashboard and the FTW app draw "balanced" / "0 W" as if the house // were idle. JSON null is what the flow mapping already treats as diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index 796053cda..a6b80c873 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -296,6 +296,29 @@ func TestHandleStatusKeepsFaultedSiteMeterReading(t *testing.T) { } } +func TestHandleStatusReportsOperatorSurplusCap(t *testing.T) { + for _, cap := range []float64{0, 0.88} { + srv := New(&Deps{ + Tel: telemetry.NewStore(), + Ctrl: &control.State{PVSurplusAbsorbSoCCap: cap}, + CtrlMu: &sync.Mutex{}, + CapMu: &sync.RWMutex{}, + Capacities: map[string]float64{}, + CfgMu: &sync.RWMutex{}, + Cfg: &config.Config{}, + }) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + var raw map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + if got, ok := raw["pv_surplus_absorb_soc_cap"]; !ok || got != cap { + t.Fatalf("pv_surplus_absorb_soc_cap = %v (sent %v), want %v", got, ok, cap) + } + } +} + func TestHandleStatusOmitsWattsWhenSiteMeterIsOffline(t *testing.T) { tel := telemetry.NewStore() ctrl := &control.State{SiteMeterDriver: "ferroamp"} diff --git a/web/plan-prefs.js b/web/plan-prefs.js index d7b08efeb..90e364f44 100644 --- a/web/plan-prefs.js +++ b/web/plan-prefs.js @@ -56,6 +56,8 @@ const SALE_W = 100; const KWH_EPS = 0.05; // A slot counts as sunny when the forecast expects more than this. const SUNNY_W = 200; +// Planned battery power below this is a discharge slot (mpc.IdleGateThresholdW). +const DISCHARGE_W = 100; // safetyK is the legacy enum→k mapping, kept for servers that answer with // forecast_trust and no safety_k. Mirrors config.ForecastTrust.SafetyK. @@ -135,11 +137,14 @@ export function marginSplitLine(margins) { return `The current plan counts on ${kwh(margins.sunHeldKWh)} less sun and ${kwh(margins.useAddedKWh)} more use than forecast.`; } -// extraSunLine says where sun beyond the plan goes, from Core's per-slot -// live_pv_surplus_soc_cap: above the slot's planned charge, live surplus -// may go into the battery; otherwise it is exported. Silent when the box -// does not send the field or the window has no sunny slot. -export function extraSunLine(actions, from, until) { +// extraSunLine says where sun beyond the plan goes, by dispatch's rule: +// live surplus may charge the battery up to the operator's cap +// (site.pv_surplus_absorb_soc_cap) when one is set, otherwise up to Core's +// per-slot live_pv_surplus_soc_cap, and never during a discharge slot. Above +// the slot's planned charge it is stored; otherwise it is exported. Silent +// when the box does not send either cap or the window has no sunny slot. +export function extraSunLine(actions, from, until, operatorCap) { + if (!Number.isFinite(operatorCap)) return null; let sunny = 0; let stored = 0; for (const a of actions || []) { @@ -150,7 +155,9 @@ export function extraSunLine(actions, from, until) { const pv = Number.isFinite(a.forecast_pv_w) ? a.forecast_pv_w : a.pv_w; if (!(Math.max(0, -pv) > SUNNY_W)) continue; sunny++; - if (a.live_pv_surplus_soc_cap > (Number(a.soc) || 0) + 0.005) stored++; + const cap = operatorCap > 0 ? operatorCap : a.live_pv_surplus_soc_cap; + const discharging = (Number(a.battery_w) || 0) < -DISCHARGE_W; + if (!discharging && cap > (Number(a.soc) || 0) + 0.005) stored++; } if (!sunny) return null; if (stored === sunny) { diff --git a/web/plan-prefs.test.mjs b/web/plan-prefs.test.mjs index 01334acce..0cd1bd934 100644 --- a/web/plan-prefs.test.mjs +++ b/web/plan-prefs.test.mjs @@ -120,32 +120,41 @@ describe("plan lines", () => { }); describe("extra sun", () => { - const slot = (start, pv, soc, cap) => ({ slot_start_ms: start, slot_len_min: 15, - forecast_pv_w: pv, pv_w: pv, soc, live_pv_surplus_soc_cap: cap }); + const slot = (start, pv, soc, cap, battery = 0) => ({ slot_start_ms: start, slot_len_min: 15, + forecast_pv_w: pv, pv_w: pv, soc, live_pv_surplus_soc_cap: cap, battery_w: battery }); const q = 15 * 60_000; + const stores = "If more sun comes than planned, FTW stores it in the battery instead of buying power later."; + const exports = "If more sun comes than planned, it goes to the grid."; - it("says nothing when the box does not send the capture field", () => { + it("says nothing when the box does not send either cap", () => { const actions = [slot(0, -3000, 0.4, 0.8)]; + assert.equal(extraSunLine(actions, 0, q, undefined), null); delete actions[0].live_pv_surplus_soc_cap; - assert.equal(extraSunLine(actions, 0, q), null); + assert.equal(extraSunLine(actions, 0, q, 0), null); }); it("says nothing for a window without sun", () => { - assert.equal(extraSunLine([slot(0, 0, 0.4, 0.8)], 0, q), null); + assert.equal(extraSunLine([slot(0, 0, 0.4, 0.8)], 0, q, 0), null); }); it("stores, sends to the grid, or splits, from Core's per-slot permission", () => { - assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0.6)], 0, 2 * q), - "If more sun comes than planned, FTW stores it in the battery instead of buying power later."); - assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), slot(q, -2000, 0.4, 0)], 0, 2 * q), - "If more sun comes than planned, it goes to the grid."); - assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0)], 0, 2 * q), + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0.6)], 0, 2 * q, 0), stores); + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), slot(q, -2000, 0.4, 0)], 0, 2 * q, 0), exports); + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0)], 0, 2 * q, 0), "If more sun comes than planned, FTW stores some of it and the rest goes to the grid."); }); it("needs room above the planned charge to call it stored", () => { - assert.equal(extraSunLine([slot(0, -3000, 0.8, 0.8)], 0, q), - "If more sun comes than planned, it goes to the grid."); + assert.equal(extraSunLine([slot(0, -3000, 0.8, 0.8)], 0, q, 0), exports); + }); + + it("follows the operator's cap ahead of the plan's, as dispatch does", () => { + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0)], 0, q, 0.88), stores); + assert.equal(extraSunLine([slot(0, -3000, 0.9, 0.95)], 0, q, 0.88), exports); + }); + + it("never stores during a planned discharge", () => { + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0, -2000)], 0, q, 0.88), exports); }); }); }); diff --git a/web/plan.js b/web/plan.js index b01bd650a..a9c9b6fec 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1193,7 +1193,9 @@ import { // rules; other modes keep their own, so say nothing there. const mode = String((state.status && state.status.mode) || ""); const household = mode === "planner_arbitrage" || mode === "planner_passive_arbitrage"; - showLine("plan-style-extra", household ? extraSunLine(actions, bounds.tMin, bounds.tMax) : null); + showLine("plan-style-extra", household + ? extraSunLine(actions, bounds.tMin, bounds.tMax, state.status && state.status.pv_surplus_absorb_soc_cap) + : null); root.classList.toggle("is-replanning", replanPending || pickedK != null); } diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 027ef2589..a1bf7926c 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -49,6 +49,38 @@ " Changes apply at once."; } + // marginSaver sends one margin save at a time. Each save first reads the + // box's current battery-export choice, so a margin change never restores + // an older one. done(err, sent, saved) runs only when no newer value waits. + function marginSaver(apiFetch, done) { + var sending = false; + var wanted = null; + function next() { + if (sending || wanted === null) return; + var k = wanted; + wanted = null; + sending = true; + apiFetch("/api/planner/prefs") + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (p) { + if (!p.battery_export) throw new Error("no export permission"); + return apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ safety_k: k, battery_export: p.battery_export }), + }); + }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (p) { + if (wanted === null) done(null, k, typeof p.safety_k === "number" ? p.safety_k : k); + }, function (err) { + if (wanted === null) done(err, k); + }) + .then(function () { sending = false; next(); }); + } + return function (k) { wanted = k; next(); }; + } + function engineSelect(engine, help) { var selected = String(engine == null ? "" : engine).trim().toLowerCase(); if (selected === "go" || selected === "dp") selected = "core"; @@ -177,7 +209,6 @@ var marginEl = document.getElementById("planner-margin-line"); if (kInput && kValue && kNote) { var lib = window.FTWPlanPrefs || null; - var exportPerm = "unknown"; var show = function (k, prefix) { kValue.textContent = "k " + formatK(k); kNote.textContent = (prefix || "") + styleNote(k, lib); @@ -185,37 +216,39 @@ apiFetch("/api/planner/prefs") .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (p) { - exportPerm = p.battery_export || "unknown"; var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; kInput.value = String(k); kInput.disabled = false; show(k); }) .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); + var save = marginSaver(apiFetch, function (err, sent, saved) { + if (err) { + kNote.textContent = "Not saved: the box did not answer. Try again."; + return; + } + // A slider moved again since this save keeps its own position. + if (Number(kInput.value) === sent) { + kInput.value = String(saved); + show(saved, "Saved. "); + } + window.dispatchEvent(new CustomEvent("ftw-planner-prefs")); + }); kInput.addEventListener("input", function () { show(kInput.value); }); kInput.addEventListener("change", function () { - var k = Number(kInput.value); kNote.textContent = "Saving…"; - apiFetch("/api/planner/prefs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - // Export is sent unchanged: the margin never turns on battery sales. - body: JSON.stringify({ safety_k: k, battery_export: exportPerm }), - }) - .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) - .then(function (p) { - var saved = typeof p.safety_k === "number" ? p.safety_k : k; - kInput.value = String(saved); - show(saved, "Saved. "); - window.dispatchEvent(new CustomEvent("ftw-planner-prefs")); - }) - .catch(function () { kNote.textContent = "Not saved: the box did not answer. Try again."; }); + save(Number(kInput.value)); }); // The same margin as the Plan card, split into sun and use. It // follows each plan the Plan card fetches, so a save shows up here. if (marginEl && lib) { var showMargin = function (actions) { - if (!actions || !actions.length) return; + if (!actions || !actions.length) { + // No plan now: an old margin must not read as the current one. + marginEl.textContent = ""; + marginEl.hidden = true; + return; + } var last = actions[actions.length - 1]; var text = lib.marginSplitLine(lib.forecastMargins(actions, Date.now() - 30 * 60 * 1000, last.slot_start_ms + last.slot_len_min * 60 * 1000)); @@ -238,5 +271,6 @@ }; // Escape hatch for node --test (planner.test.mjs); not a public API. - S.tabs.planner._pure = { strategyLabel: strategyLabel, styleNote: styleNote, formatK: formatK, engineSelect: engineSelect }; + S.tabs.planner._pure = { strategyLabel: strategyLabel, styleNote: styleNote, formatK: formatK, engineSelect: engineSelect, + marginSaver: marginSaver }; })(); diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index 2de37a62c..b4819d8be 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -12,7 +12,7 @@ globalThis.window = {}; await import("./planner.js"); const { styleForK } = await import("../../plan-prefs.js"); const tab = globalThis.window.FTWSettings.tabs.planner; -const { strategyLabel, styleNote, formatK, engineSelect } = tab._pure; +const { strategyLabel, styleNote, formatK, engineSelect, marginSaver } = tab._pure; describe("strategyLabel", () => { it("maps every planner mode via the local fallback", () => { @@ -176,3 +176,48 @@ describe("engine selection", () => { }); } }); + +describe("marginSaver", () => { + const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); }; + + it("reads the current export before each save and reports only the newest value", async () => { + let exportNow = "allowed"; + const sent = []; + const replies = []; + const apiFetch = (url, opts) => { + if (!opts) return Promise.resolve({ ok: true, json: async () => ({ battery_export: exportNow }) }); + const body = JSON.parse(opts.body); + sent.push(body); + return new Promise((resolve) => replies.push(() => resolve({ ok: true, json: async () => ({ safety_k: body.safety_k }) }))); + }; + const done = []; + const save = marginSaver(apiFetch, (err, k, saved) => done.push([err, k, saved])); + save(0.6); + await settle(); + exportNow = "not_allowed"; // another client turns battery sales off + save(1); // while the first save is on its way + await settle(); + assert.equal(sent.length, 1, "a second save started before the first finished"); + replies.shift()(); + await settle(); + assert.deepEqual(done, [], "an older reply reached the page"); + replies.shift()(); + await settle(); + assert.deepEqual(sent, [{ safety_k: 0.6, battery_export: "allowed" }, { safety_k: 1, battery_export: "not_allowed" }]); + assert.deepEqual(done, [[null, 1, 1]]); + }); + + it("does not save when the box does not say its export choice", async () => { + const posts = []; + const apiFetch = (url, opts) => { + if (opts) posts.push(opts); + return Promise.resolve({ ok: true, json: async () => ({}) }); + }; + const done = []; + marginSaver(apiFetch, (err) => done.push(err))(0.3); + await settle(); + assert.equal(posts.length, 0); + assert.equal(done.length, 1); + assert.ok(done[0] instanceof Error); + }); +}); From 115d46a5a7c47fdc61e0524a6ddb8b43423a2e41 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 20:26:44 +0200 Subject: [PATCH 4/8] fix(plan): send only the changed preference; one save queue per page Four findings from a second local Codex review: - Reading the export choice before a margin save still left a race with another client. POST /api/planner/prefs now accepts safety_k alone or battery_export alone, and keeps the other under the same write lock, so two clients changing different preferences never undo each other. The Plan card and Settings send only what they change. - Reopening the Planner tab started a second save queue that could race the first. Settings now keeps one queue for the page. - Where the plan caps the panels (pv_curtail_active or pv_limit_w), extra sun may be held back rather than exported. The extra-sun line now says nothing for such windows. - Before the box answered, arrow keys moved from Balanced rather than the focused style. They now move from the focused style. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/api/api_planner_prefs.go | 53 +++++++++++++--- .../api/api_planner_prefs_margin_test.go | 61 +++++++++++++++++++ web/plan-prefs.js | 5 +- web/plan-prefs.test.mjs | 18 ++++-- web/plan.js | 24 +++++--- web/settings/tabs/planner.js | 36 ++++++----- web/settings/tabs/planner.test.mjs | 21 ++----- 7 files changed, 162 insertions(+), 56 deletions(-) create mode 100644 go/internal/api/api_planner_prefs_margin_test.go diff --git a/go/internal/api/api_planner_prefs.go b/go/internal/api/api_planner_prefs.go index 9af54681a..4e3f812a3 100644 --- a/go/internal/api/api_planner_prefs.go +++ b/go/internal/api/api_planner_prefs.go @@ -46,25 +46,37 @@ func (s *Server) handleSetPlannerPrefs(w http.ResponseWriter, r *http.Request) { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } - var safetyK float64 + // Each control sends only what it changes; the box keeps the rest. + var safetyK *float64 if req.SafetyK != nil { // New client: k wins and forecast_trust is whatever k derives to, // so the two can never be posted into disagreement. - safetyK = config.ClampSafetyK(*req.SafetyK) - } else { + k := config.ClampSafetyK(*req.SafetyK) + safetyK = &k + } else if req.ForecastTrust != "" { trust, ok := config.ParseForecastTrust(req.ForecastTrust) - if !ok || req.ForecastTrust == "" { + if !ok { writeJSON(w, 400, map[string]string{"error": "forecast_trust must be cautious, balanced, or bold, or send safety_k"}) return } - safetyK = trust.SafetyK() + k := trust.SafetyK() + safetyK = &k + } + var export *config.BatteryExport + if req.BatteryExport != "" { + e, ok := config.ParseBatteryExport(req.BatteryExport) + if !ok { + writeJSON(w, 400, map[string]string{"error": "battery_export must be unknown, not_allowed, or allowed"}) + return + } + export = &e } - export, ok := config.ParseBatteryExport(req.BatteryExport) - if !ok { - writeJSON(w, 400, map[string]string{"error": "battery_export must be unknown, not_allowed, or allowed"}) + if safetyK == nil && export == nil { + writeJSON(w, 400, map[string]string{"error": "send safety_k, forecast_trust or battery_export"}) return } - if err := s.applyPlannerPrefs(safetyK, export); err != nil { + saved, err := s.applyPlannerChange(safetyK, export) + if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } @@ -72,16 +84,37 @@ func (s *Server) handleSetPlannerPrefs(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{ "status": "ok", "forecast_trust": trust, - "battery_export": export, + "battery_export": saved, "safety_k": resolvedK, "mapped_k": resolvedK, "mapped_mode": mappedMode, }) } +// applyPlannerChange writes what a client sent and keeps the other +// preference as the box holds it when the write runs. It reads under the same +// lock as every preference write, so two clients changing different +// preferences never undo each other. +func (s *Server) applyPlannerChange(safetyK *float64, export *config.BatteryExport) (config.BatteryExport, error) { + s.configWriteMu.Lock() + defer s.configWriteMu.Unlock() + _, e, k, _ := s.plannerPrefsSnapshot() + if safetyK != nil { + k = *safetyK + } + if export != nil { + e = *export + } + return e, s.applyPlannerPrefsLocked(k, e) +} + func (s *Server) applyPlannerPrefs(safetyK float64, export config.BatteryExport) error { s.configWriteMu.Lock() defer s.configWriteMu.Unlock() + return s.applyPlannerPrefsLocked(safetyK, export) +} + +func (s *Server) applyPlannerPrefsLocked(safetyK float64, export config.BatteryExport) error { safetyK = config.ClampSafetyK(safetyK) trust := config.TrustFromSafetyK(safetyK) mapped := control.Mode(export.PlannerModeKey()) diff --git a/go/internal/api/api_planner_prefs_margin_test.go b/go/internal/api/api_planner_prefs_margin_test.go new file mode 100644 index 000000000..faa89ef18 --- /dev/null +++ b/go/internal/api/api_planner_prefs_margin_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" +) + +// A margin-only save must keep the export choice the box holds when the write +// runs, even if another client changed it after this one read; and the +// reverse for an export-only save. +func TestPostPlannerPrefsChangesOnlyWhatIsSent(t *testing.T) { + srv, ctrl, _ := plannerPrefsServer(t, control.ModePlannerArbitrage) + request := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/planner/prefs", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + return rr + } + post := func(body string) map[string]any { + t.Helper() + rr := request(body) + if rr.Code != http.StatusOK { + t.Fatalf("POST %s: status=%d body=%s", body, rr.Code, rr.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got + } + post(`{"safety_k": 0.3, "battery_export": "allowed"}`) + post(`{"safety_k": 0.3, "battery_export": "not_allowed"}`) // another client turns sales off + got := post(`{"safety_k": 0.6}`) + if got["battery_export"] != "not_allowed" || got["safety_k"] != 0.6 { + t.Fatalf("margin-only save = %v", got) + } + if _, export, k := srv.deps.PlannerPrefs.Get(); export != config.BatteryExportNotAllowed || k != 0.6 { + t.Fatalf("stored export=%s k=%v", export, k) + } + if ctrl.Mode != control.ModePlannerPassiveArbitrage { + t.Fatalf("mode = %s, want battery sales to stay off", ctrl.Mode) + } + // The export switch on another client keeps this client's margin. + got = post(`{"battery_export": "allowed"}`) + if got["battery_export"] != "allowed" || got["safety_k"] != 0.6 { + t.Fatalf("export-only save = %v", got) + } + if ctrl.Mode != control.ModePlannerArbitrage { + t.Fatalf("mode = %s, want battery sales back on", ctrl.Mode) + } + if rr := request(`{}`); rr.Code != http.StatusBadRequest { + t.Fatalf("an empty change got %d, want 400", rr.Code) + } +} diff --git a/web/plan-prefs.js b/web/plan-prefs.js index 90e364f44..899894e0d 100644 --- a/web/plan-prefs.js +++ b/web/plan-prefs.js @@ -142,7 +142,9 @@ export function marginSplitLine(margins) { // (site.pv_surplus_absorb_soc_cap) when one is set, otherwise up to Core's // per-slot live_pv_surplus_soc_cap, and never during a discharge slot. Above // the slot's planned charge it is stored; otherwise it is exported. Silent -// when the box does not send either cap or the window has no sunny slot. +// when the box does not send either cap, the window has no sunny slot, or +// the plan caps the panels in a sunny slot: there extra sun may be held back +// rather than exported, and the line cannot say which. export function extraSunLine(actions, from, until, operatorCap) { if (!Number.isFinite(operatorCap)) return null; let sunny = 0; @@ -154,6 +156,7 @@ export function extraSunLine(actions, from, until, operatorCap) { if (!Number.isFinite(a.live_pv_surplus_soc_cap)) return null; const pv = Number.isFinite(a.forecast_pv_w) ? a.forecast_pv_w : a.pv_w; if (!(Math.max(0, -pv) > SUNNY_W)) continue; + if (a.pv_curtail_active || Number(a.pv_limit_w) > 0) return null; sunny++; const cap = operatorCap > 0 ? operatorCap : a.live_pv_surplus_soc_cap; const discharging = (Number(a.battery_w) || 0) < -DISCHARGE_W; diff --git a/web/plan-prefs.test.mjs b/web/plan-prefs.test.mjs index 0cd1bd934..d5174413d 100644 --- a/web/plan-prefs.test.mjs +++ b/web/plan-prefs.test.mjs @@ -156,6 +156,12 @@ describe("plan lines", () => { it("never stores during a planned discharge", () => { assert.equal(extraSunLine([slot(0, -3000, 0.4, 0, -2000)], 0, q, 0.88), exports); }); + + it("says nothing where the plan caps the panels", () => { + const capped = { ...slot(q, -2000, 0.9, 0), pv_curtail_active: true, pv_limit_w: 500 }; + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), capped], 0, 2 * q, 0), null); + assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), { ...slot(q, -2000, 0.9, 0), pv_limit_w: 500 }], 0, 2 * q, 0), null); + }); }); }); @@ -290,11 +296,15 @@ describe("Plan card markup and wiring", () => { for (const s of PLAN_STYLES) assert.doesNotMatch(s.text, /\brisk\b/i); }); - it("POSTs safety_k, keeps export unchanged, and marks the plan replanning", () => { + it("moves the style keys from the focused style", () => { + assert.match(plan, /const focused = e\.target\.closest\("\[data-style\]"\);/); + assert.match(plan, /PLAN_STYLES\.findIndex\(function \(s\) \{ return s\.key === focused\.dataset\.style; \}\)/); + }); + + it("POSTs only the changed preference and marks the plan replanning", () => { assert.match(plan, /\/api\/planner\/prefs/); - assert.match(plan, /safety_k: clampSafetyK\(k\)/); - assert.match(plan, /postPlannerPrefs\(k, currentPrefs\(\)\.battery_export\)/); - assert.match(plan, /postPlannerPrefs\(shownK\(\), exportPerm\)/); + assert.match(plan, /postPlannerPrefs\(\{ safety_k: clampSafetyK\(k\) \}\)/); + assert.match(plan, /postPlannerPrefs\(\{ battery_export: exportPerm \}\)/); assert.match(plan, /setReplanPending\(true\)/); assert.match(plan, /setReplanPending\(false\)/); assert.match(plan, /Replanning…/); diff --git a/web/plan.js b/web/plan.js index a9c9b6fec..692a937c3 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1222,18 +1222,17 @@ import { } } - // postPlannerPrefs stores both prefs and says whether the box took them. - async function postPlannerPrefs(k, exportPerm) { + // postPlannerPrefs sends one change, the style's k or the export + // permission, and says whether the box took it. The box keeps the other + // as it holds it, so a change made elsewhere is never undone. + async function postPlannerPrefs(change) { prefsPosting = true; setReplanPending(true); try { const r = await apiFetch("/api/planner/prefs", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - safety_k: clampSafetyK(k), - battery_export: exportPerm, - }), + body: JSON.stringify(change), }); if (!r.ok) throw new Error("HTTP " + r.status); const j = await r.json(); @@ -1270,7 +1269,7 @@ import { return; } const k = pickedK; - const ok = await postPlannerPrefs(k, currentPrefs().battery_export); + const ok = await postPlannerPrefs({ safety_k: clampSafetyK(k) }); if (pickedK === k) { pickedK = null; if (!ok) showStyleStatus("Not saved: the box did not answer. Try again."); @@ -1280,7 +1279,7 @@ import { } async function setExport(exportPerm) { - const ok = await postPlannerPrefs(shownK(), exportPerm); + const ok = await postPlannerPrefs({ battery_export: exportPerm }); syncPrefsUI(); if (ok) await fetchAll(); } @@ -1292,10 +1291,15 @@ import { const btn = e.target.closest("[data-style]"); if (btn) pickStyle(btn.dataset.style); }); - // Radio-group keys: arrows move and pick, Home and End jump to an end. + // Radio-group keys: arrows move from the focused style and pick, Home + // and End jump to an end. Before the box answers no style is checked, + // so the focused one is the only honest origin. steps.addEventListener("keydown", function (e) { const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }; - let i = PLAN_STYLES.indexOf(styleForK(shownK()).style); + const focused = e.target.closest("[data-style]"); + let i = focused + ? PLAN_STYLES.findIndex(function (s) { return s.key === focused.dataset.style; }) + : PLAN_STYLES.indexOf(styleForK(shownK()).style); if (e.key in moves) i = Math.min(PLAN_STYLES.length - 1, Math.max(0, i + moves[e.key])); else if (e.key === "Home") i = 0; else if (e.key === "End") i = PLAN_STYLES.length - 1; diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index a1bf7926c..72cdf0718 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -49,9 +49,14 @@ " Changes apply at once."; } - // marginSaver sends one margin save at a time. Each save first reads the - // box's current battery-export choice, so a margin change never restores - // an older one. done(err, sent, saved) runs only when no newer value waits. + // One margin queue for the page. Reopening the tab must not start a second + // queue that races the first; marginReply is the open tab's handler. + var saveMargin = null; + var marginReply = null; + + // marginSaver sends one margin save at a time, with only safety_k, so the + // box keeps the export choice it holds. done(err, sent, saved) runs only + // when no newer value waits. function marginSaver(apiFetch, done) { var sending = false; var wanted = null; @@ -60,16 +65,11 @@ var k = wanted; wanted = null; sending = true; - apiFetch("/api/planner/prefs") - .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) - .then(function (p) { - if (!p.battery_export) throw new Error("no export permission"); - return apiFetch("/api/planner/prefs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ safety_k: k, battery_export: p.battery_export }), - }); - }) + apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ safety_k: k }), + }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (p) { if (wanted === null) done(null, k, typeof p.safety_k === "number" ? p.safety_k : k); @@ -222,7 +222,8 @@ show(k); }) .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); - var save = marginSaver(apiFetch, function (err, sent, saved) { + // Replies go to the tab as it is drawn now. + marginReply = function (err, sent, saved) { if (err) { kNote.textContent = "Not saved: the box did not answer. Try again."; return; @@ -233,11 +234,14 @@ show(saved, "Saved. "); } window.dispatchEvent(new CustomEvent("ftw-planner-prefs")); - }); + }; + if (!saveMargin) { + saveMargin = marginSaver(apiFetch, function (err, sent, saved) { marginReply(err, sent, saved); }); + } kInput.addEventListener("input", function () { show(kInput.value); }); kInput.addEventListener("change", function () { kNote.textContent = "Saving…"; - save(Number(kInput.value)); + saveMargin(Number(kInput.value)); }); // The same margin as the Plan card, split into sun and use. It // follows each plan the Plan card fetches, so a save shows up here. diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index b4819d8be..e425dc88e 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -180,12 +180,10 @@ describe("engine selection", () => { describe("marginSaver", () => { const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); }; - it("reads the current export before each save and reports only the newest value", async () => { - let exportNow = "allowed"; + it("sends only the margin, one save at a time, and reports only the newest value", async () => { const sent = []; const replies = []; const apiFetch = (url, opts) => { - if (!opts) return Promise.resolve({ ok: true, json: async () => ({ battery_export: exportNow }) }); const body = JSON.parse(opts.body); sent.push(body); return new Promise((resolve) => replies.push(() => resolve({ ok: true, json: async () => ({ safety_k: body.safety_k }) }))); @@ -193,9 +191,8 @@ describe("marginSaver", () => { const done = []; const save = marginSaver(apiFetch, (err, k, saved) => done.push([err, k, saved])); save(0.6); - await settle(); - exportNow = "not_allowed"; // another client turns battery sales off - save(1); // while the first save is on its way + save(0.8); // replaced before it is sent + save(1); await settle(); assert.equal(sent.length, 1, "a second save started before the first finished"); replies.shift()(); @@ -203,20 +200,14 @@ describe("marginSaver", () => { assert.deepEqual(done, [], "an older reply reached the page"); replies.shift()(); await settle(); - assert.deepEqual(sent, [{ safety_k: 0.6, battery_export: "allowed" }, { safety_k: 1, battery_export: "not_allowed" }]); + assert.deepEqual(sent, [{ safety_k: 0.6 }, { safety_k: 1 }]); assert.deepEqual(done, [[null, 1, 1]]); }); - it("does not save when the box does not say its export choice", async () => { - const posts = []; - const apiFetch = (url, opts) => { - if (opts) posts.push(opts); - return Promise.resolve({ ok: true, json: async () => ({}) }); - }; + it("reports a save the box did not take", async () => { const done = []; - marginSaver(apiFetch, (err) => done.push(err))(0.3); + marginSaver(() => Promise.resolve({ ok: false, status: 503 }), (err) => done.push(err))(0.3); await settle(); - assert.equal(posts.length, 0); assert.equal(done.length, 1); assert.ok(done[0] instanceof Error); }); From f3d2425a137a109220cac8c4254cb0e4d6cb3b75 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 20:29:49 +0200 Subject: [PATCH 5/8] docs(changeset): say that each device changes only its own preference Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/plan-planning-styles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/plan-planning-styles.md b/.changeset/plan-planning-styles.md index 7915bcd52..d9250f951 100644 --- a/.changeset/plan-planning-styles.md +++ b/.changeset/plan-planning-styles.md @@ -2,4 +2,4 @@ "ftw": minor --- -The Plan card's forecast slider is now five planning styles, from Very careful to Very bold. A line under them says how much of the spare sun the plan counts on, and another says whether extra sun goes into the battery or to the grid. The default style, Balanced, holds back a smaller forecast margin than before (k 0.3 instead of 1), and a box that still runs the old default moves to it once. Settings → Planner fine-tunes the margin and saves at once. +The Plan card's forecast slider is now five planning styles, from Very careful to Very bold. A line under them says how much of the spare sun the plan counts on, and another says whether extra sun goes into the battery or to the grid. The default style, Balanced, holds back a smaller forecast margin than before (k 0.3 instead of 1), and a box that still runs the old default moves to it once. Settings → Planner fine-tunes the margin and saves at once. Changing the style or battery sales on one device no longer undoes a change made on another. From 14d8b13c01100867a9ddfd923bd36fe0069ea0c4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 20:42:30 +0200 Subject: [PATCH 6/8] fix(plan): one write queue for the page; say only what extra sun does Three findings from a third local Codex review: - The Plan card and Settings had separate save queues, so two writes could reach the box out of order. All preference writes on the page now go through one queue (prefsQueue), and each confirmed write is announced with the box's answer. Settings follows a style picked on the card. - Reopening Settings while a margin save was on its way showed the old stored value and then ignored the reply. The pending value now survives the redraw, and the reply updates the open tab unless a drag is under way. - In a slot that imports, extra sun lowers the import before anything is exported. The line now speaks only of sun beyond what the home and the plan need. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- web/plan-prefs.js | 29 +++++++++--- web/plan-prefs.test.mjs | 42 +++++++++++++++-- web/plan.js | 35 +++++++++----- web/settings/tabs/planner.js | 76 +++++++++++++++++++++--------- web/settings/tabs/planner.test.mjs | 11 ++--- 5 files changed, 144 insertions(+), 49 deletions(-) diff --git a/web/plan-prefs.js b/web/plan-prefs.js index 899894e0d..797651fcc 100644 --- a/web/plan-prefs.js +++ b/web/plan-prefs.js @@ -137,8 +137,9 @@ export function marginSplitLine(margins) { return `The current plan counts on ${kwh(margins.sunHeldKWh)} less sun and ${kwh(margins.useAddedKWh)} more use than forecast.`; } -// extraSunLine says where sun beyond the plan goes, by dispatch's rule: -// live surplus may charge the battery up to the operator's cap +// extraSunLine says where sun beyond what the home and the plan need goes; +// extra sun first covers planned use and imports. By dispatch's rule, live +// surplus may charge the battery up to the operator's cap // (site.pv_surplus_absorb_soc_cap) when one is set, otherwise up to Core's // per-slot live_pv_surplus_soc_cap, and never during a discharge slot. Above // the slot's planned charge it is stored; otherwise it is exported. Silent @@ -163,11 +164,9 @@ export function extraSunLine(actions, from, until, operatorCap) { if (!discharging && cap > (Number(a.soc) || 0) + 0.005) stored++; } if (!sunny) return null; - if (stored === sunny) { - return "If more sun comes than planned, FTW stores it in the battery instead of buying power later."; - } - if (stored === 0) return "If more sun comes than planned, it goes to the grid."; - return "If more sun comes than planned, FTW stores some of it and the rest goes to the grid."; + if (stored === sunny) return "Sun beyond what your home and the plan need goes into the battery."; + if (stored === 0) return "Sun beyond what your home and the plan need goes to the grid."; + return "Sun beyond what your home and the plan need goes partly into the battery and partly to the grid."; } export function isBatterySale(action) { @@ -222,6 +221,22 @@ export function exportSentence({ return "Battery sale blocked: permission is off or not checked."; } +// prefsQueue runs preference writes one after another, in the order they +// were made, whichever control made them. send(change) performs one write and +// returns the box's answer; announce(answer, source) runs after each one the +// box confirmed. +export function prefsQueue(send, announce) { + let tail = Promise.resolve(); + return function save(change, source) { + const run = tail.then(() => send(change)).then((answer) => { + announce(answer, source); + return answer; + }); + tail = run.catch(() => {}); + return run; + }; +} + // prefsKnown is true once the box has said which margin it runs. Until then // the card checks no style rather than show a default as the box's choice. export function prefsKnown(status) { diff --git a/web/plan-prefs.test.mjs b/web/plan-prefs.test.mjs index d5174413d..9afee00f2 100644 --- a/web/plan-prefs.test.mjs +++ b/web/plan-prefs.test.mjs @@ -18,6 +18,7 @@ import { exportSentence, prefsFromStatus, SAFETY_K_STEP, + prefsQueue, } from "./plan-prefs.js"; const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); @@ -123,8 +124,8 @@ describe("plan lines", () => { const slot = (start, pv, soc, cap, battery = 0) => ({ slot_start_ms: start, slot_len_min: 15, forecast_pv_w: pv, pv_w: pv, soc, live_pv_surplus_soc_cap: cap, battery_w: battery }); const q = 15 * 60_000; - const stores = "If more sun comes than planned, FTW stores it in the battery instead of buying power later."; - const exports = "If more sun comes than planned, it goes to the grid."; + const stores = "Sun beyond what your home and the plan need goes into the battery."; + const exports = "Sun beyond what your home and the plan need goes to the grid."; it("says nothing when the box does not send either cap", () => { const actions = [slot(0, -3000, 0.4, 0.8)]; @@ -141,7 +142,7 @@ describe("plan lines", () => { assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0.6)], 0, 2 * q, 0), stores); assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), slot(q, -2000, 0.4, 0)], 0, 2 * q, 0), exports); assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0)], 0, 2 * q, 0), - "If more sun comes than planned, FTW stores some of it and the rest goes to the grid."); + "Sun beyond what your home and the plan need goes partly into the battery and partly to the grid."); }); it("needs room above the planned charge to call it stored", () => { @@ -315,3 +316,38 @@ describe("Plan card markup and wiring", () => { assert.doesNotMatch(plan, /Solid: forecast · dashed: used by plan/); }); }); + +describe("prefsQueue", () => { + const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); }; + + it("keeps writes in the order they were made, even when the first answers late", async () => { + const order = []; + const answers = []; + const send = (change) => new Promise((resolve) => answers.push(() => { + order.push(change.safety_k); + resolve({ safety_k: change.safety_k }); + })); + const announced = []; + const save = prefsQueue(send, (answer, source) => announced.push([answer.safety_k, source])); + const first = save({ safety_k: 1 }, "card"); + const second = save({ safety_k: 0.45 }, "settings"); + await settle(); + assert.equal(answers.length, 1, "the second write left before the first was answered"); + answers.shift()(); + await first; + await settle(); + answers.shift()(); + await second; + assert.deepEqual(order, [1, 0.45]); + assert.deepEqual(announced, [[1, "card"], [0.45, "settings"]]); + }); + + it("goes on after a failed write and announces only confirmed ones", async () => { + const announced = []; + const save = prefsQueue((c) => (c.fail ? Promise.reject(new Error("HTTP 503")) : Promise.resolve(c)), + (answer) => announced.push(answer)); + await assert.rejects(save({ fail: true })); + assert.deepEqual(await save({ safety_k: 0.3 }), { safety_k: 0.3 }); + assert.deepEqual(announced, [{ safety_k: 0.3 }]); + }); +}); diff --git a/web/plan.js b/web/plan.js index 692a937c3..c5228ed11 100644 --- a/web/plan.js +++ b/web/plan.js @@ -19,6 +19,7 @@ import { extraSunLine, exportSentence, prefsFromStatus, + prefsQueue, } from "./plan-prefs.js"; (function () { @@ -1116,6 +1117,7 @@ import { window.FTWPlanPrefs = { PLAN_STYLES, styleForK, formatSafetyK, clampSafetyK, marginSplitLine, forecastMargins: (actions, from, until) => forecastMargins(actions, from, until), + savePlannerPrefs: (change, source) => savePlannerPrefs(change, source), }; function currentPrefs() { @@ -1222,20 +1224,29 @@ import { } } - // postPlannerPrefs sends one change, the style's k or the export - // permission, and says whether the box took it. The box keeps the other - // as it holds it, so a change made elsewhere is never undone. + // Every preference write on the page goes through one queue, from the card + // and from Settings, so the box applies them in the order they were made. + // Each change carries only what it changes; the box keeps the rest. A + // confirmed write is announced as "ftw-planner-prefs" with the box's answer. + const savePlannerPrefs = prefsQueue(async function (change) { + const r = await apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(change), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }, function (answer, source) { + window.dispatchEvent(new CustomEvent("ftw-planner-prefs", { detail: Object.assign({}, answer, { source }) })); + }); + + // postPlannerPrefs sends one change from the card and says whether the box + // took it. async function postPlannerPrefs(change) { prefsPosting = true; setReplanPending(true); try { - const r = await apiFetch("/api/planner/prefs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(change), - }); - if (!r.ok) throw new Error("HTTP " + r.status); - const j = await r.json(); + const j = await savePlannerPrefs(change, "card"); state.prefs = { forecast_trust: j.forecast_trust, battery_export: j.battery_export, @@ -1318,7 +1329,9 @@ import { }); } // A fine-tune in Settings → Planner changes the same number. - window.addEventListener("ftw-planner-prefs", function () { fetchAll(); }); + window.addEventListener("ftw-planner-prefs", function (e) { + if (e.detail && e.detail.source !== "card") fetchAll(); + }); const check = document.getElementById("plan-export-check"); if (check) { check.addEventListener("change", function () { diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 72cdf0718..1107d9210 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -49,15 +49,17 @@ " Changes apply at once."; } - // One margin queue for the page. Reopening the tab must not start a second - // queue that races the first; marginReply is the open tab's handler. - var saveMargin = null; - var marginReply = null; + // Page state for the margin slider. It outlives a redraw of the tab, so a + // save started before the tab was reopened still lands in the open tab. + var saveMargin = null; // one queue for the page + var marginReply = null; // the open tab's reply handler + var marginWaiting = false; // a slider value the box has not answered yet + var marginPendingK = null; // that value // marginSaver sends one margin save at a time, with only safety_k, so the - // box keeps the export choice it holds. done(err, sent, saved) runs only - // when no newer value waits. - function marginSaver(apiFetch, done) { + // box keeps the export choice it holds. post(change) returns the box's + // answer. done(err, sent, saved) runs only when no newer value waits. + function marginSaver(post, done) { var sending = false; var wanted = null; function next() { @@ -65,12 +67,7 @@ var k = wanted; wanted = null; sending = true; - apiFetch("/api/planner/prefs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ safety_k: k }), - }) - .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + post({ safety_k: k }) .then(function (p) { if (wanted === null) done(null, k, typeof p.safety_k === "number" ? p.safety_k : k); }, function (err) { @@ -213,36 +210,71 @@ kValue.textContent = "k " + formatK(k); kNote.textContent = (prefix || "") + styleNote(k, lib); }; + var dragging = false; + var showStored = function (p) { + var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; + kInput.value = String(k); + show(k); + }; apiFetch("/api/planner/prefs") .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (p) { - var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; - kInput.value = String(k); kInput.disabled = false; - show(k); + // A save still on its way answers with the newer value. + if (marginWaiting) { + kInput.value = String(marginPendingK); + show(marginPendingK, "Saving… "); + } else { + showStored(p); + } }) .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); // Replies go to the tab as it is drawn now. marginReply = function (err, sent, saved) { + marginWaiting = false; if (err) { kNote.textContent = "Not saved: the box did not answer. Try again."; return; } - // A slider moved again since this save keeps its own position. - if (Number(kInput.value) === sent) { + // A drag under way keeps the slider where the finger is. + if (!dragging) { kInput.value = String(saved); show(saved, "Saved. "); } - window.dispatchEvent(new CustomEvent("ftw-planner-prefs")); }; if (!saveMargin) { - saveMargin = marginSaver(apiFetch, function (err, sent, saved) { marginReply(err, sent, saved); }); + // Through the Plan card's queue when it is loaded, so writes from + // the card and from here reach the box in the order they were made. + saveMargin = marginSaver(function (change) { + var lib = window.FTWPlanPrefs; + if (lib && typeof lib.savePlannerPrefs === "function") return lib.savePlannerPrefs(change, "settings"); + return apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(change), + }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }); + }, function (err, sent, saved) { marginReply(err, sent, saved); }); } - kInput.addEventListener("input", function () { show(kInput.value); }); + kInput.addEventListener("input", function () { + dragging = true; + show(kInput.value); + }); kInput.addEventListener("change", function () { + dragging = false; + marginWaiting = true; + marginPendingK = Number(kInput.value); kNote.textContent = "Saving…"; - saveMargin(Number(kInput.value)); + saveMargin(marginPendingK); }); + // A style picked on the Plan card changes the same number. + var tabState = S.tabs.planner; + if (tabState._onPrefs) window.removeEventListener("ftw-planner-prefs", tabState._onPrefs); + tabState._onPrefs = function (e) { + var p = e.detail; + if (!p || p.source === "settings" || !kInput.isConnected || marginWaiting || dragging) return; + if (typeof p.safety_k === "number" || typeof p.mapped_k === "number") showStored(p); + }; + window.addEventListener("ftw-planner-prefs", tabState._onPrefs); // The same margin as the Plan card, split into sun and use. It // follows each plan the Plan card fetches, so a save shows up here. if (marginEl && lib) { diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index e425dc88e..d7212f5b3 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -183,13 +183,12 @@ describe("marginSaver", () => { it("sends only the margin, one save at a time, and reports only the newest value", async () => { const sent = []; const replies = []; - const apiFetch = (url, opts) => { - const body = JSON.parse(opts.body); - sent.push(body); - return new Promise((resolve) => replies.push(() => resolve({ ok: true, json: async () => ({ safety_k: body.safety_k }) }))); + const post = (change) => { + sent.push(change); + return new Promise((resolve) => replies.push(() => resolve({ safety_k: change.safety_k }))); }; const done = []; - const save = marginSaver(apiFetch, (err, k, saved) => done.push([err, k, saved])); + const save = marginSaver(post, (err, k, saved) => done.push([err, k, saved])); save(0.6); save(0.8); // replaced before it is sent save(1); @@ -206,7 +205,7 @@ describe("marginSaver", () => { it("reports a save the box did not take", async () => { const done = []; - marginSaver(() => Promise.resolve({ ok: false, status: 503 }), (err) => done.push(err))(0.3); + marginSaver(() => Promise.reject(new Error("HTTP 503")), (err) => done.push(err))(0.3); await settle(); assert.equal(done.length, 1); assert.ok(done[0] instanceof Error); From 5a697134c170ca114903bea8858a1f2a53d7ebd0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 20:53:00 +0200 Subject: [PATCH 7/8] fix(plan): Settings slider follows the box's confirmed value Two findings from a fourth local Codex review, both from Settings keeping its own save queue and reads on top of the page's queue: - A pending Settings value could reach the box after a newer style picked on the Plan card. Each released slider value now goes straight into the page's queue, so writes reach the box in the order they were made. - A read started before a confirmed write could replace it. The slider now paints one page model (marginModel): the value the box last confirmed, from any control, unless a save from here is on its way. A read older than a confirmed write changes nothing. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- web/settings/tabs/planner.js | 153 ++++++++++++++--------------- web/settings/tabs/planner.test.mjs | 75 +++++++------- 2 files changed, 118 insertions(+), 110 deletions(-) diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 1107d9210..06baeb400 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -49,33 +49,50 @@ " Changes apply at once."; } - // Page state for the margin slider. It outlives a redraw of the tab, so a - // save started before the tab was reopened still lands in the open tab. - var saveMargin = null; // one queue for the page - var marginReply = null; // the open tab's reply handler - var marginWaiting = false; // a slider value the box has not answered yet - var marginPendingK = null; // that value + // marginModel holds what the margin slider shows, for the whole page: the + // value the box last confirmed, from any control, unless a save from here + // is on its way. A read that started before a confirmed write is older + // than it and changes nothing. + function marginModel() { + var confirmed = null; + var rev = 0; + var pending = 0; + var requested = null; + var outcome = ""; // "saved" or "failed" after this page's last save + return { + confirm: function (k) { confirmed = k; rev++; outcome = ""; }, + beginRead: function () { return rev; }, + endRead: function (readRev, k) { if (readRev === rev && pending === 0) confirmed = k; }, + request: function (k) { pending++; requested = k; outcome = ""; }, + settle: function (ok) { pending--; if (pending === 0) outcome = ok ? "saved" : "failed"; }, + view: function () { + if (pending > 0) return { k: requested, state: "saving" }; + return { k: confirmed, state: outcome }; + }, + }; + } - // marginSaver sends one margin save at a time, with only safety_k, so the - // box keeps the export choice it holds. post(change) returns the box's - // answer. done(err, sent, saved) runs only when no newer value waits. - function marginSaver(post, done) { - var sending = false; - var wanted = null; - function next() { - if (sending || wanted === null) return; - var k = wanted; - wanted = null; - sending = true; - post({ safety_k: k }) - .then(function (p) { - if (wanted === null) done(null, k, typeof p.safety_k === "number" ? p.safety_k : k); - }, function (err) { - if (wanted === null) done(err, k); - }) - .then(function () { sending = false; next(); }); - } - return function (k) { wanted = k; next(); }; + var margin = marginModel(); // outlives a redraw of the tab + var paintMargin = null; // the open tab's painter + var marginListening = false; + + // savePlannerMargin sends only the margin, through the Plan card's queue so + // writes from the card and from here reach the box in the order they were + // made. The queue announces the box's answer; without the card, so does + // this. + function savePlannerMargin(apiFetch, k) { + var lib = window.FTWPlanPrefs; + if (lib && typeof lib.savePlannerPrefs === "function") return lib.savePlannerPrefs({ safety_k: k }, "settings"); + return apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ safety_k: k }), + }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (p) { + window.dispatchEvent(new CustomEvent("ftw-planner-prefs", { detail: Object.assign({}, p, { source: "settings" }) })); + return p; + }); } function engineSelect(engine, help) { @@ -211,70 +228,52 @@ kNote.textContent = (prefix || "") + styleNote(k, lib); }; var dragging = false; - var showStored = function (p) { - var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; - kInput.value = String(k); - show(k); + // Paints the page's margin state, unless a drag is under way. + paintMargin = function () { + if (!kInput.isConnected || dragging) return; + var v = margin.view(); + if (v.k == null) return; + kInput.value = String(v.k); + show(v.k, v.state === "saving" ? "Saving… " : v.state === "saved" ? "Saved. " : ""); + if (v.state === "failed") kNote.textContent = "Not saved: the box did not answer. Try again."; }; + if (!marginListening) { + marginListening = true; + // Every write the box confirms, from the Plan card or from here. + window.addEventListener("ftw-planner-prefs", function (e) { + var p = e.detail || {}; + var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; + if (typeof k !== "number") return; + margin.confirm(k); + if (paintMargin) paintMargin(); + }); + } + paintMargin(); // what the page already knows, before the read answers + var readRev = margin.beginRead(); apiFetch("/api/planner/prefs") .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (p) { kInput.disabled = false; - // A save still on its way answers with the newer value. - if (marginWaiting) { - kInput.value = String(marginPendingK); - show(marginPendingK, "Saving… "); - } else { - showStored(p); - } + margin.endRead(readRev, typeof p.safety_k === "number" ? p.safety_k : p.mapped_k); + paintMargin(); }) .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); - // Replies go to the tab as it is drawn now. - marginReply = function (err, sent, saved) { - marginWaiting = false; - if (err) { - kNote.textContent = "Not saved: the box did not answer. Try again."; - return; - } - // A drag under way keeps the slider where the finger is. - if (!dragging) { - kInput.value = String(saved); - show(saved, "Saved. "); - } - }; - if (!saveMargin) { - // Through the Plan card's queue when it is loaded, so writes from - // the card and from here reach the box in the order they were made. - saveMargin = marginSaver(function (change) { - var lib = window.FTWPlanPrefs; - if (lib && typeof lib.savePlannerPrefs === "function") return lib.savePlannerPrefs(change, "settings"); - return apiFetch("/api/planner/prefs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(change), - }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }); - }, function (err, sent, saved) { marginReply(err, sent, saved); }); - } kInput.addEventListener("input", function () { dragging = true; show(kInput.value); }); kInput.addEventListener("change", function () { dragging = false; - marginWaiting = true; - marginPendingK = Number(kInput.value); - kNote.textContent = "Saving…"; - saveMargin(marginPendingK); + var k = Number(kInput.value); + margin.request(k); + paintMargin(); + savePlannerMargin(apiFetch, k) + .then(function () { return true; }, function () { return false; }) + .then(function (ok) { + margin.settle(ok); + if (paintMargin) paintMargin(); + }); }); - // A style picked on the Plan card changes the same number. - var tabState = S.tabs.planner; - if (tabState._onPrefs) window.removeEventListener("ftw-planner-prefs", tabState._onPrefs); - tabState._onPrefs = function (e) { - var p = e.detail; - if (!p || p.source === "settings" || !kInput.isConnected || marginWaiting || dragging) return; - if (typeof p.safety_k === "number" || typeof p.mapped_k === "number") showStored(p); - }; - window.addEventListener("ftw-planner-prefs", tabState._onPrefs); // The same margin as the Plan card, split into sun and use. It // follows each plan the Plan card fetches, so a save shows up here. if (marginEl && lib) { @@ -308,5 +307,5 @@ // Escape hatch for node --test (planner.test.mjs); not a public API. S.tabs.planner._pure = { strategyLabel: strategyLabel, styleNote: styleNote, formatK: formatK, engineSelect: engineSelect, - marginSaver: marginSaver }; + marginModel: marginModel }; })(); diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index d7212f5b3..edbf40e57 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -12,7 +12,7 @@ globalThis.window = {}; await import("./planner.js"); const { styleForK } = await import("../../plan-prefs.js"); const tab = globalThis.window.FTWSettings.tabs.planner; -const { strategyLabel, styleNote, formatK, engineSelect, marginSaver } = tab._pure; +const { strategyLabel, styleNote, formatK, engineSelect, marginModel } = tab._pure; describe("strategyLabel", () => { it("maps every planner mode via the local fallback", () => { @@ -177,37 +177,46 @@ describe("engine selection", () => { } }); -describe("marginSaver", () => { - const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); }; - - it("sends only the margin, one save at a time, and reports only the newest value", async () => { - const sent = []; - const replies = []; - const post = (change) => { - sent.push(change); - return new Promise((resolve) => replies.push(() => resolve({ safety_k: change.safety_k }))); - }; - const done = []; - const save = marginSaver(post, (err, k, saved) => done.push([err, k, saved])); - save(0.6); - save(0.8); // replaced before it is sent - save(1); - await settle(); - assert.equal(sent.length, 1, "a second save started before the first finished"); - replies.shift()(); - await settle(); - assert.deepEqual(done, [], "an older reply reached the page"); - replies.shift()(); - await settle(); - assert.deepEqual(sent, [{ safety_k: 0.6 }, { safety_k: 1 }]); - assert.deepEqual(done, [[null, 1, 1]]); - }); - - it("reports a save the box did not take", async () => { - const done = []; - marginSaver(() => Promise.reject(new Error("HTTP 503")), (err) => done.push(err))(0.3); - await settle(); - assert.equal(done.length, 1); - assert.ok(done[0] instanceof Error); +describe("marginModel", () => { + const stored = (k) => { + const m = marginModel(); + m.endRead(m.beginRead(), k); + return m; + }; + + it("ignores a read that started before a confirmed write", () => { + const m = marginModel(); + const read = m.beginRead(); + m.request(0.6); + m.confirm(0.6); + m.settle(true); + m.endRead(read, 0.3); // the redraw's read answers late with the old value + assert.deepEqual(m.view(), { k: 0.6, state: "saved" }); + }); + + it("shows the newest value on its way until every save has answered", () => { + const m = stored(0.3); + m.request(0.8); + m.request(1); + assert.deepEqual(m.view(), { k: 1, state: "saving" }); + m.confirm(0.8); + m.settle(true); + assert.deepEqual(m.view(), { k: 1, state: "saving" }); + m.confirm(1); + m.settle(true); + assert.deepEqual(m.view(), { k: 1, state: "saved" }); + }); + + it("follows a style picked on the Plan card", () => { + const m = stored(0.3); + m.confirm(0.15); + assert.deepEqual(m.view(), { k: 0.15, state: "" }); + }); + + it("keeps the box's value after a failed save", () => { + const m = stored(0.3); + m.request(0.6); + m.settle(false); + assert.deepEqual(m.view(), { k: 0.3, state: "failed" }); }); }); From d5c616cdf53f9224ce0f3e1108977e54f63a37c3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 1 Oct 2026 21:04:58 +0200 Subject: [PATCH 8/8] fix(plan): latest choice wins in the write queue; end drags on release Two findings from a fifth local Codex review: - A style picked while another card write was on its way waited outside the page queue, so a later Settings choice could be sent first and then lose to the older pick. Card picks now go straight into the queue, and the queue lets a newer change to the same preference replace one still waiting; both callers get the newer answer. This also replaces the card's 400 ms delay. - Chrome sends no change when a drag ends where it began, so the slider stayed in drag mode and hid a failed save. A drag now ends when the pointer is released. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- web/plan-prefs.js | 42 ++++++++++++++++++++++++++++-------- web/plan-prefs.test.mjs | 39 +++++++++++++++++++++++++++++++++ web/plan.js | 25 ++++++++------------- web/settings/tabs/planner.js | 15 ++++++++++--- 4 files changed, 93 insertions(+), 28 deletions(-) diff --git a/web/plan-prefs.js b/web/plan-prefs.js index 797651fcc..097782151 100644 --- a/web/plan-prefs.js +++ b/web/plan-prefs.js @@ -222,18 +222,42 @@ export function exportSentence({ } // prefsQueue runs preference writes one after another, in the order they -// were made, whichever control made them. send(change) performs one write and -// returns the box's answer; announce(answer, source) runs after each one the -// box confirmed. +// were made, whichever control made them. A newer change to the same +// preferences replaces one still waiting to be sent, and both callers get the +// answer to the newer one, so the latest choice always wins. send(change) +// performs one write and returns the box's answer; announce(answer, source) +// runs after each write the box confirmed. export function prefsQueue(send, announce) { - let tail = Promise.resolve(); + let running = false; + const waiting = []; + const keys = (change) => Object.keys(change).sort().join(); + function next() { + if (running || waiting.length === 0) return; + running = true; + const job = waiting.shift(); + Promise.resolve() + .then(() => send(job.change)) + .then((answer) => { + announce(answer, job.source); + job.callers.forEach((c) => c.resolve(answer)); + }, (err) => job.callers.forEach((c) => c.reject(err))) + .then(() => { + running = false; + next(); + }); + } return function save(change, source) { - const run = tail.then(() => send(change)).then((answer) => { - announce(answer, source); - return answer; + return new Promise((resolve, reject) => { + const last = waiting[waiting.length - 1]; + if (last && keys(last.change) === keys(change)) { + last.change = change; + last.source = source; + last.callers.push({ resolve, reject }); + } else { + waiting.push({ change, source, callers: [{ resolve, reject }] }); + } + next(); }); - tail = run.catch(() => {}); - return run; }; } diff --git a/web/plan-prefs.test.mjs b/web/plan-prefs.test.mjs index 9afee00f2..abf10f3e8 100644 --- a/web/plan-prefs.test.mjs +++ b/web/plan-prefs.test.mjs @@ -342,6 +342,45 @@ describe("prefsQueue", () => { assert.deepEqual(announced, [[1, "card"], [0.45, "settings"]]); }); + it("lets a newer change to the same preference replace one still waiting", async () => { + const sent = []; + const answers = []; + const send = (change) => { + sent.push(change); + return new Promise((resolve) => answers.push(() => resolve(change))); + }; + const save = prefsQueue(send, () => {}); + const veryCareful = save({ safety_k: 1 }, "card"); // on its way + const bold = save({ safety_k: 0.15 }, "card"); // waits + const fine = save({ safety_k: 0.8 }, "settings"); // the latest choice replaces Bold + await settle(); + answers.shift()(); + await veryCareful; + await settle(); + answers.shift()(); + assert.deepEqual(await bold, { safety_k: 0.8 }, "the replaced caller did not get the newer answer"); + assert.deepEqual(await fine, { safety_k: 0.8 }); + assert.deepEqual(sent, [{ safety_k: 1 }, { safety_k: 0.8 }]); + }); + + it("keeps the order of changes to different preferences", async () => { + const sent = []; + const answers = []; + const save = prefsQueue((change) => { + sent.push(change); + return new Promise((resolve) => answers.push(() => resolve(change))); + }, () => {}); + save({ safety_k: 1 }, "card"); + save({ battery_export: "allowed" }, "card"); + save({ safety_k: 0.15 }, "settings"); + for (let i = 0; i < 3; i++) { + await settle(); + answers.shift()(); + } + await settle(); + assert.deepEqual(sent, [{ safety_k: 1 }, { battery_export: "allowed" }, { safety_k: 0.15 }]); + }); + it("goes on after a failed write and announces only confirmed ones", async () => { const announced = []; const save = prefsQueue((c) => (c.fail ? Promise.reject(new Error("HTTP 503")) : Promise.resolve(c)), diff --git a/web/plan.js b/web/plan.js index c5228ed11..9d1142474 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1107,8 +1107,7 @@ import { // A style POSTs safety_k only; export is sent unchanged so picking a // style never turns on battery export. let pickedK = null; // picked here, not yet confirmed by the box - let postTimer = null; - let prefsPosting = false; + let prefsPosts = 0; // card writes the box has not answered let replanPending = false; let statusTimer = null; @@ -1243,7 +1242,7 @@ import { // postPlannerPrefs sends one change from the card and says whether the box // took it. async function postPlannerPrefs(change) { - prefsPosting = true; + prefsPosts++; setReplanPending(true); try { const j = await savePlannerPrefs(change, "card"); @@ -1256,30 +1255,24 @@ import { } catch (e) { return false; } finally { - prefsPosting = false; - setReplanPending(false); + prefsPosts--; + if (prefsPosts === 0) setReplanPending(false); } } - // A pick waits a moment, so tapping across the scale sends one request; - // the latest pick wins. + // A pick goes straight into the page's write queue, so it keeps its place + // among choices made in Settings. Picks made while one is on its way + // collapse into one write, and the latest wins. function pickStyle(key) { const style = PLAN_STYLES.find(function (s) { return s.key === key; }); if (!style) return; pickedK = style.k; showStyleStatus(""); renderStyle(); - clearTimeout(postTimer); - postTimer = setTimeout(sendPickedStyle, 400); + sendPickedStyle(style.k); } - async function sendPickedStyle() { - if (pickedK == null) return; - if (prefsPosting) { - postTimer = setTimeout(sendPickedStyle, 300); - return; - } - const k = pickedK; + async function sendPickedStyle(k) { const ok = await postPlannerPrefs({ safety_k: clampSafetyK(k) }); if (pickedK === k) { pickedK = null; diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 06baeb400..e3fd11c8c 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -258,10 +258,19 @@ paintMargin(); }) .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); - kInput.addEventListener("input", function () { - dragging = true; - show(kInput.value); + // A drag is a pointer held on the slider. Chrome sends no change when + // a drag ends where it began, so the release ends it too. The repaint + // waits a turn, after the change a moved thumb sends. + var endDrag = function () { + if (!dragging) return; + dragging = false; + setTimeout(function () { if (paintMargin) paintMargin(); }, 0); + }; + kInput.addEventListener("pointerdown", function () { dragging = true; }); + ["pointerup", "pointercancel", "lostpointercapture", "blur"].forEach(function (type) { + kInput.addEventListener(type, endDrag); }); + kInput.addEventListener("input", function () { show(kInput.value); }); kInput.addEventListener("change", function () { dragging = false; var k = Number(kInput.value);