UX-13 P2 review fixes (PR #145, comment 1880)
checks / unit (pull_request) Successful in 10m15s
infra-pin / fresh (pull_request) Successful in 2s

1. Discard reloads /config/model: after a stale save the page kept the
   new fingerprint with the old values, so Discard + Save overwrote the
   other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
   flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
   while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
   "Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
   peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
   static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
UX-13 P2 review
2026-09-28 03:43:47 +02:00
parent 1752adf6e6
commit 7be09197d4
6 changed files with 64 additions and 17 deletions
+6 -4
View File
@@ -511,10 +511,12 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
# Batteries, attention, month peak, control
# --------------------------------------------------------------------------- #
def at_zero(inv) -> bool:
"""Has this inverter's last accepted power request reached 0 W? A stop or
an alarm asks for 0 W at once, but the inverter gets it with the next
write, so until then the page says "Going to 0 W" (UX-13 P1 test, a)."""
return abs(inv.standing_w()) < 1.0
"""Has this inverter accepted a 0 W power request? A stop or an alarm asks
for 0 W at once, but the inverter gets it with the next write, so until
then the page says "Going to 0 W" (UX-13 P1 test, a). Before the first
accepted write nothing is known to be held; the heartbeat writes every
`heartbeat_s`, stopped or not, so this does not stick."""
return inv.last_write is not None and abs(inv.last_write) < 1.0
def battery_rows(c, shadow, running, held=False):
+14 -8
View File
@@ -87,8 +87,10 @@
}
/* ---------------- controls ---------------- */
function choicesOf(f, cur) {
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur));
// A choice the page can only keep stays offered while it is the stored value.
function choicesOf(f, card) {
const cur = get(f, card), was = valueIn(ORIG, f, card);
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
return out;
}
@@ -106,7 +108,7 @@
function radios(f, card, lid, cards) {
const cur = get(f, card);
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
const items = choicesOf(f, cur);
const items = choicesOf(f, card);
const buttons = items.map(([v, label], i) => {
const on = same(v, cur);
const hint = cards && f.hints ? f.hints[v] : "";
@@ -132,7 +134,7 @@
function select(f, card, lid) {
const cur = get(f, card);
const s = h("select", { class: "sel", "aria-labelledby": lid });
for (const [v, label] of choicesOf(f, cur)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
const preset = f.presets && f.presets[cur];
@@ -324,7 +326,10 @@
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"),
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
V[f.key].splice(i, 1); V[f.count] = V[f.key].length; put(f, null, V[f.key], true);
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
// Back to one card, the stored shape returns: a flat inverter stays flat.
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
put(f, null, V[f.key], true);
} }, W.remove) : null));
for (const s of f.fields) box.append(fieldRow(s, i, gid));
wrap.append(box);
@@ -496,6 +501,7 @@
return;
}
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
UNLOCKED.clear();
$("#set-search").hidden = false;
FILLED = {};
LIST = null;
@@ -541,9 +547,9 @@
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
}
$("#sb-save").addEventListener("click", save);
$("#sb-discard").addEventListener("click", () => {
V = clone(ORIG); UNLOCKED.clear(); render(); validate(); toast(W.toast.discarded);
if ($("#set-banner .b-bad")) banner();
// Discard reloads what is stored: after a stale save ORIG is no longer it.
$("#sb-discard").addEventListener("click", async () => {
await load(); toast(W.toast.discarded);
});
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
document.addEventListener("ux13:tab", (e) => {
+6 -1
View File
@@ -91,6 +91,10 @@ def _choices(fld, node, T, value_type):
def _field(fld, schema_ui, translations, lang, T):
node = _node(schema_ui, fld.key)
label, text = _words(translations, lang, fld.key, T)
if fld.unit:
# The page shows the unit beside the control, in its own scale (kW),
# so the translation's "(W)" goes; "(optional)" stays.
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
else node.get("type") or "")
out = {"key": fld.key, "control": fld.control, "label": label,
@@ -98,8 +102,9 @@ def _field(fld, schema_ui, translations, lang, T):
"step": fld.step, "kind": fld.kind,
"show": [list(c) for c in fld.show], "applies": fld.applies,
"type": value_type, "optional": not node.get("required", False),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()
if how == fld.key)}
if how in (fld.key, fld.mirror))}
if node.get("lengthMin") is not None:
out["min"] = node["lengthMin"]
if node.get("lengthMax") is not None: