UX-13 P1: page shell and Home screen on a structured /status
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
month_peak and control beside the old fields. The plan is what the add-on
does today: the arbitrage verdict (the same plan.arbitrage() call as the
cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"use strict";
|
||||
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
|
||||
external files. Every URL is relative, because ingress serves the add-on
|
||||
under a generated path prefix. Every word comes from app/ui_text (fixed
|
||||
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
|
||||
is checked in Python. Data is always set as text, never as HTML. */
|
||||
(function () {
|
||||
const T = JSON.parse(document.getElementById("ui-text").textContent).home;
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
|
||||
function h(tag, attrs, ...kids) {
|
||||
const e = document.createElement(tag);
|
||||
for (const k in attrs || {}) {
|
||||
const v = attrs[k];
|
||||
if (v === false || v == null) continue;
|
||||
if (k === "class") e.className = v;
|
||||
else if (k === "style") e.style.cssText = v;
|
||||
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
||||
else e.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const c of kids.flat()) {
|
||||
if (c == null || c === false) continue;
|
||||
e.append(c.nodeType ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
return e;
|
||||
}
|
||||
const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
|
||||
const eur = (v) => "€" + Number(v).toFixed(2);
|
||||
|
||||
// Fixed labels from ui_text.
|
||||
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
|
||||
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
|
||||
|
||||
let S = null; // the last /status
|
||||
let busy = false;
|
||||
|
||||
/* ---------------- toast and confirm dialog ---------------- */
|
||||
function toast(text) {
|
||||
const e = $("#toast"); e.textContent = text; e.classList.add("show");
|
||||
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
|
||||
}
|
||||
let onYes = null;
|
||||
function ask(key, fn) {
|
||||
$("#dlg-t").textContent = t("dialog." + key + ".name");
|
||||
$("#dlg-b").textContent = t("dialog." + key + ".description");
|
||||
$("#dlg-yes").textContent = t("dialog." + key + ".yes");
|
||||
onYes = fn;
|
||||
const d = $("#dlg");
|
||||
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
|
||||
}
|
||||
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
|
||||
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
|
||||
|
||||
/* ---------------- data ---------------- */
|
||||
async function getStatus() {
|
||||
const r = await fetch("status", { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function act(what) {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what }) });
|
||||
if (!r.ok) throw new Error("action " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function refresh() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { S = await getStatus(); render(); }
|
||||
catch (e) { offline(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- shell ---------------- */
|
||||
function banner(el, level, title, sub, role) {
|
||||
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, sub ? h("small", {}, sub) : null)));
|
||||
}
|
||||
function offline() {
|
||||
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
|
||||
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
|
||||
}
|
||||
function renderPill() {
|
||||
const st = S.state || null;
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
|
||||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderPill();
|
||||
const structured = S.now && S.plan && !S.home_error;
|
||||
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null);
|
||||
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
renderControl(structured);
|
||||
renderDetails(!structured);
|
||||
renderBatteries();
|
||||
renderSettings();
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
function val(id, w, show) {
|
||||
const e = $(id);
|
||||
if (w == null || !show) { e.textContent = "–"; return; }
|
||||
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
|
||||
}
|
||||
function levelBar(pct, label) {
|
||||
const known = pct != null;
|
||||
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
|
||||
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
|
||||
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
|
||||
}
|
||||
function renderNow() {
|
||||
const n = S.now;
|
||||
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
|
||||
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
|
||||
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
|
||||
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
|
||||
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
|
||||
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
|
||||
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
|
||||
}
|
||||
|
||||
/* ---------------- Home: today's plan ---------------- */
|
||||
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
|
||||
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
|
||||
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
|
||||
["peak", "background:var(--bad)"], ["house", null]];
|
||||
function tipText(lines) { return lines.filter(Boolean).join(" "); }
|
||||
function chartItem(tag, cls, style, title, lines) {
|
||||
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
|
||||
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
|
||||
}
|
||||
function roving(group) {
|
||||
const items = [...group.querySelectorAll("[data-tip]")];
|
||||
items.forEach((el, i) => {
|
||||
el.tabIndex = i === 0 ? 0 : -1;
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
|
||||
if (d === undefined) return;
|
||||
e.preventDefault();
|
||||
const j = Math.max(0, Math.min(items.length - 1, i + d));
|
||||
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
function renderPlan() {
|
||||
const p = S.plan;
|
||||
$("#verdict").textContent = p.verdict;
|
||||
$("#plan-head").textContent = p.head;
|
||||
const hours = p.hours || 24;
|
||||
const bars = $("#bars");
|
||||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||||
bars.setAttribute("aria-label", t("plan.name"));
|
||||
bars.replaceChildren(...p.prices.map((b) => {
|
||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||||
const est = b.estimate ? ", " + t("plan.estimate_tip") : "";
|
||||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||
}));
|
||||
roving(bars);
|
||||
$("#no-prices").hidden = known.length > 0;
|
||||
const lanes = $("#lanes");
|
||||
lanes.replaceChildren();
|
||||
S.batteries.forEach((b, i) => {
|
||||
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
|
||||
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
|
||||
for (const r of runs) {
|
||||
if (r.end_h <= r.start_h) continue;
|
||||
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
|
||||
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
|
||||
}
|
||||
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
|
||||
roving(lane);
|
||||
});
|
||||
const nl = $("#nowline");
|
||||
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
|
||||
const used = new Set(p.runs.map((r) => r.kind));
|
||||
known.forEach((b) => used.add(b.kind));
|
||||
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
|
||||
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
|
||||
const list = [];
|
||||
S.batteries.forEach((b, i) => {
|
||||
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
|
||||
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
|
||||
}
|
||||
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
|
||||
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
|
||||
});
|
||||
for (const line of p.lines) list.push(h("li", {}, line));
|
||||
$("#plan-list").replaceChildren(...list);
|
||||
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
|
||||
(function () {
|
||||
const tip = $("#tip"); let hot = null;
|
||||
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
|
||||
function show(el, x, y) {
|
||||
if (hot && hot !== el) hot.classList.remove("hot");
|
||||
hot = el; el.classList.add("hot");
|
||||
const lines = JSON.parse(el.dataset.tip);
|
||||
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
|
||||
tip.classList.add("show");
|
||||
const r = tip.getBoundingClientRect();
|
||||
let L = x + 12, T2 = y - r.height - 12;
|
||||
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
|
||||
if (L < 8) L = 8;
|
||||
if (T2 < 8) T2 = y + 16;
|
||||
tip.style.left = L + "px"; tip.style.top = T2 + "px";
|
||||
}
|
||||
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const el = target(e);
|
||||
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
|
||||
});
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
const el = target(e);
|
||||
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
|
||||
else if (!el) hide();
|
||||
});
|
||||
document.addEventListener("focusin", (e) => {
|
||||
const el = target(e);
|
||||
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
|
||||
});
|
||||
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
||||
addEventListener("scroll", hide, { passive: true });
|
||||
})();
|
||||
|
||||
/* ---------------- Home: peak, attention, control, details ---------------- */
|
||||
function renderPeak() {
|
||||
const m = S.month_peak;
|
||||
$("#peak-month").textContent = m.month;
|
||||
const covered = m.state !== "not_covered";
|
||||
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
|
||||
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
|
||||
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
|
||||
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
|
||||
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
|
||||
const bar = $("#peak-meter i");
|
||||
bar.style.width = pct + "%";
|
||||
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
|
||||
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
|
||||
$("#peak-line").textContent = m.line;
|
||||
}
|
||||
function renderAttention() {
|
||||
const list = S.attention;
|
||||
const icon = { alarm: "×", warning: "!", info: "i" };
|
||||
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
|
||||
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
|
||||
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
|
||||
h("div", { class: "small" }, a.body),
|
||||
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
|
||||
const n = list.filter((a) => a.severity !== "info").length;
|
||||
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
|
||||
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
|
||||
const badge = $("#attn-badge");
|
||||
badge.textContent = n; badge.hidden = !n;
|
||||
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
||||
}
|
||||
function renderControl(structured) {
|
||||
const running = structured ? S.control.running : null;
|
||||
const btn = $("#btn-ctl");
|
||||
btn.textContent = running === false ? t("control.start") : t("control.stop");
|
||||
btn.className = "btn" + (running === false ? " primary" : "");
|
||||
$("#ctl-text").textContent = structured ? S.control.text : "";
|
||||
$("#hint").textContent = S.hint || "";
|
||||
const docs = $("#docs-link");
|
||||
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
}
|
||||
function renderDetails(open) {
|
||||
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
|
||||
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
|
||||
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
|
||||
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
|
||||
return h("li", { class: cls }, mark + " " + c.text);
|
||||
}));
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */
|
||||
function renderBatteries() {
|
||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("dl", { class: "kv" },
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
|
||||
}
|
||||
function renderSettings() {
|
||||
const line = S.plan && S.plan.region_line;
|
||||
$("#set-region").textContent = line || ""; $("#set-region").hidden = !line;
|
||||
}
|
||||
|
||||
/* ---------------- control buttons ---------------- */
|
||||
// The action is a toggle, so the page reads the live state first and sends
|
||||
// nothing when control is already where the person asked it to be.
|
||||
async function setControl(want) {
|
||||
try {
|
||||
const now = await getStatus();
|
||||
if (!now.control || now.control.running !== want) await act("auto_toggle");
|
||||
toast(want ? t("toast.started") : t("toast.stopped"));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
refresh();
|
||||
}
|
||||
$("#btn-ctl").addEventListener("click", () => {
|
||||
const running = S && S.control ? S.control.running : true;
|
||||
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
|
||||
});
|
||||
async function simple(what) {
|
||||
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
|
||||
refresh();
|
||||
}
|
||||
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
|
||||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||||
|
||||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||
const TABS = [...document.querySelectorAll(".tab")];
|
||||
function select(tab, focus) {
|
||||
TABS.forEach((x) => {
|
||||
const on = x === tab;
|
||||
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
|
||||
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
|
||||
});
|
||||
if (focus) tab.focus();
|
||||
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
|
||||
schedule();
|
||||
}
|
||||
TABS.forEach((x, i) => {
|
||||
x.addEventListener("click", () => select(x, false));
|
||||
x.addEventListener("keydown", (e) => {
|
||||
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
|
||||
if (j === undefined) return;
|
||||
e.preventDefault(); select(TABS[j % TABS.length], true);
|
||||
});
|
||||
});
|
||||
function fromHash() {
|
||||
const want = (location.hash || "#home").slice(1);
|
||||
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
|
||||
}
|
||||
addEventListener("hashchange", fromHash);
|
||||
|
||||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-batt").getAttribute("aria-selected") === "true");
|
||||
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
|
||||
if (!live && timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
document.addEventListener("visibilitychange", schedule);
|
||||
fromHash();
|
||||
refresh();
|
||||
})();
|
||||
Reference in New Issue
Block a user