"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 () { // P5: English or Dutch, by the browser's language. A word missing in Dutch // falls back to English, key by key (app/uitext.py does the same). const ALL = JSON.parse(document.getElementById("ui-text").textContent); const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en"; const merge = (a, b) => { if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a; const out = Object.assign({}, a); for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k]; return out; }; const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]); document.documentElement.lang = LANG; const T = WORDS.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 dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x); const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1)); const eur = (v) => "€" + dec(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; // `key` names Home's words in ui_text, or is {name, description, yes}. function ask(key, fn) { const w = typeof key === "string" ? t("dialog." + key) : key; $("#dlg-t").textContent = w.name; $("#dlg-b").textContent = w.description; $("#dlg-yes").textContent = w.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?lang=" + LANG, { 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(); } // `force`: redraw the battery cards even under a focused mode list, after // the person used it (P3). async function refresh(force) { if (busy) return; busy = true; try { S = await getStatus(); render(force === true); } 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", waiting: " warn" }[st.kind] ?? " warn") : (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn"); const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner; $("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words)); } function render(force) { renderPill(); const structured = S.now && S.plan && !S.home_error; // UX-15: setup-only mode commands nothing and has no control to offer. const setup = S.setup_only === true; banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", 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, setup); renderDetails(!structured && !setup); renderBatteries(force); document.dispatchEvent(new CustomEvent("ux13:status")); } /* ---------------- 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.classList.toggle("fine", p.prices.length > 48); 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, setup) { 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" : ""); btn.hidden = !!setup; $("#btn-setup").hidden = !setup; $("#c-ctl .more").hidden = !!setup; $("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : ""; $("#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 (UX-13 P3) ---------------- */ // One card per battery. With battery modes on (`mode_key` in /status) each // card has an On/Off switch and a mode list, sent as `pack_use` and // `pack_mode` through /action (OPT-11 P1). Selling has no switch while it // is off (owner decision 2026-09-29). The Now, Today and Why lines are // homeview.py's sentences. The Simple settings view draws the same switch // and list into its own card (simpleHost). const MODES = ["solar_first", "leftover_solar", "cheap_hours"]; const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text); let packBusy = false; let simpleHost = null; async function packAct(what, b, value) { if (packBusy) return; packBusy = true; try { const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: what, unit: b.name, value: value }) }); const j = await r.json().catch(() => ({})); if (!r.ok) toast(j.error || t("toast.failed")); else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name })); else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") })); } catch (e) { toast(t("toast.failed")); } finally { packBusy = false; } await refresh(true); } // MAINT-11: start or stop ONE battery's cycle. Two or more batteries only; // the add-on refuses a second cycle and says which battery is cycling. async function cycleAct(what, b) { if (packBusy) return; packBusy = true; try { const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: what, unit: b.name }) }); const j = await r.json().catch(() => ({})); const own = j.code && t("batteries.cycle." + j.code); if (!r.ok) toast(own ? say(own.description, { name: j.unit || b.name }) : (j.error || t("toast.failed"))); else toast(say(t("batteries.cycle.toast." + (what === "maint_start" ? "started" : "stopped")), { name: b.name })); } catch (e) { toast(t("toast.failed")); } finally { packBusy = false; } await refresh(true); } // A button that cannot work is disabled (the add-on still refuses it): Stop // needs this battery cycling; Start needs no battery cycling and this one on. function cycleState(b) { const cycling = (x) => x.maintenance !== "idle"; return { start: !S.batteries.some(cycling) && b.use !== false, abort: cycling(b) }; } function cycleButtons(b, i) { const can = cycleState(b); const ask1 = (key, what) => () => { const w = t("batteries.cycle." + key); ask({ name: say(w.name, { name: b.name }), description: say(w.description, { name: b.name }), yes: w.yes }, () => cycleAct(what, b)); }; return h("div", { class: "cyc", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px" }, h("button", { type: "button", class: "btn", id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog", "maint_start") }, t("batteries.cycle.start")), h("button", { type: "button", class: "btn danger", id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog", "maint_abort") }, t("batteries.cycle.abort"))); } function onSwitch(b, i, where, lid) { const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i, "aria-checked": String(b.use), "aria-labelledby": lid, onclick: () => packAct("pack_use", b, !b.use) }); return h("span", { class: "swrap" }, sw, h("span", { class: "swtext", "aria-hidden": "true" }, b.use ? t("batteries.on") : t("batteries.off"))); } function modeSelect(b, i, where, lid) { return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use, onchange: (e) => packAct("pack_mode", b, e.target.value) }, ...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name")))); } function todayLines(b) { if (!b.today || !b.today.length) return b.today_line || "–"; return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what)); } function batteryCard(b, i) { const modes = b.mode_key != null; const lid = "bname-" + i; const lvl = b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"; const stored = b.stored_kwh != null ? " · " + dec(b.stored_kwh.toFixed(1)) + " kWh " + t("batteries.stored").toLowerCase() : ""; return h("div", { class: "card bcard" + (modes && !b.use ? " off" : "") }, h("div", { class: "hd" }, h("div", { class: "grow" }, h("div", { class: "name", id: lid }, b.name), h("div", { class: "tiny muted" }, b.capacity_kwh ? dec(Number(b.capacity_kwh).toFixed(1)) + " kWh" : "")), modes ? onSwitch(b, i, "c", lid) : null), modes ? h("div", { class: "modebox" }, h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")), modeSelect(b, i, "c", null), h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null, levelBar(b.level_pct, b.name), h("dl", { class: "kv" }, h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored), h("dt", {}, t("batteries.now")), h("dd", {}, b.now), h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)), h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")), S.batteries.length > 1 ? cycleButtons(b, i) : null); } function simpleRow(b, i) { const lid = "bsname-" + i; return h("div", { class: "inv" }, h("div", { class: "hd" }, h("span", { class: "grow", id: lid }, b.name), onSwitch(b, i, "s", lid)), h("div", { class: "f" }, h("label", { class: "lbl", for: "bsel-s" + i }, t("batteries.mode")), h("div", { class: "ctl" }, modeSelect(b, i, "s", null)), h("div", { class: "help" }, b.now + " " + (b.why || "")))); } // Rebuild without losing the focused control, and never under an open list // or a request in flight. function keepFocus(fn) { const id = document.activeElement && document.activeElement.id; fn(); if (id && /^bs(w|el)-/.test(id)) { const e = document.getElementById(id); if (e) e.focus(); } } // The modes-off card's button opens Settings on the Installer view, where // the "Battery modes" switch is (settings.js sets window.UX13.settingsView). $("#batt-off a").addEventListener("click", () => { if (window.UX13.settingsView) window.UX13.settingsView("installer"); }); function renderBatteries(force) { if (!S || !S.batteries) { $("#bcards").replaceChildren(); return; } const a = document.activeElement; if (!force && (packBusy || (a && a.tagName === "SELECT" && /^bsel-/.test(a.id)))) return; const modes = S.modes_on === true || S.batteries.some((b) => b.mode_key != null); $("#batt-off").hidden = modes; $("#batt-how").hidden = !modes; const docs = $("#batt-docs"); if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true; if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null); else $("#batt-banner").replaceChildren(); keepFocus(() => { $("#bcards").replaceChildren(...S.batteries.map(batteryCard)); // OPT-11 P4: the solar forecast line under the cards. const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : ""; if (sl) { sl.textContent = line || ""; sl.hidden = !line; } if (simpleHost && simpleHost.isConnected) { simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow) : [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))])); } }); } /* ---------------- Forecast (UX-54) ---------------- */ // Today and tomorrow, per hour: the solar forecast and what was measured, // the house's expected use, and each battery's expected level with its // blocks. The data is built by app/dayview.py when the tab asks (GET // forecast), the sentences in the page's language. A part that is not // known yet comes as a note, so no chart is ever drawn empty. // The SVG namespace, read off the page's own icons: the page names no URL. const SVGNS = document.querySelector("svg").namespaceURI; function sv(tag, attrs) { const e = document.createElementNS(SVGNS, tag); for (const k in attrs || {}) e.setAttribute(k, attrs[k]); return e; } let FC = null; let fcDay = 0; let fcBusy = false; let fcTimer = null; const fmtKwh = (x) => dec(Number(x).toFixed(1)); const hoursOf = (d) => d.length_h || d.hours.length || 24; function endLabel(d, i) { return i + 1 < d.hours.length ? d.hours[i + 1].label : "24:00"; } function hourTip(d, i) { const x = d.hours[i]; const lines = []; if (x.forecast_kwh != null) lines.push(t("forecast.forecast") + ": " + fmtKwh(x.forecast_kwh) + " kWh"); if (x.measured_kwh != null) lines.push(t("forecast.measured") + ": " + fmtKwh(x.measured_kwh) + " kWh"); if (x.house_kwh != null) lines.push(t("forecast.house") + ": " + fmtKwh(x.house_kwh) + " kWh"); d.batteries.forEach((b, j) => { if (x.levels[j] != null) lines.push(b.name + ": " + dec(Math.round(x.levels[j]).toString()) + " %"); }); return [x.label + " " + t("plan.time_to") + " " + endLabel(d, i)].concat(lines); } function hourItem(tag, cls, d, i, kids) { const tip = hourTip(d, i); const e = h(tag, { class: cls, tabindex: "-1", role: "img", "aria-label": tip.join(". "), "data-tip": JSON.stringify(tip) }, kids); return e; } function renderSun(d) { const card = $("#fc-sun"); const any = d.sun.forecast || d.sun.measured || d.sun.house; card.hidden = !any; if (!any) return; const mx = Math.max(0.5, ...d.hours.map((x) => Math.max(x.forecast_kwh || 0, x.measured_kwh || 0, x.house_kwh || 0))); const pct = (v) => Math.max(0, Math.min(100, v / mx * 100)) + "%"; const bars = $("#fc-bars"); bars.setAttribute("aria-label", t("forecast.sun")); bars.replaceChildren(...d.hours.map((x, i) => hourItem("div", "fcol", d, i, [ x.forecast_kwh != null ? h("i", { class: "sun" + (d.sun.estimate ? " est" : ""), style: "height:" + pct(x.forecast_kwh) }) : null, x.measured_kwh != null ? h("i", { class: "got", style: "height:" + pct(x.measured_kwh) }) : null, x.house_kwh != null ? h("i", { class: "use", style: "bottom:calc(" + pct(x.house_kwh) + " - 1px)" }) : null]))); roving(bars); const chart = $("#fc-sunchart"); chart.querySelectorAll(".fcnow, .ymax").forEach((e) => e.remove()); chart.append(h("span", { class: "ymax", "aria-hidden": "true" }, fmtKwh(mx))); if (fcDay === 0) chart.append(nowMark(d)); const used = [["sun", d.sun.forecast, "forecast"], ["got", d.sun.measured, "measured"], ["use", d.sun.house, "house"]]; $("#fc-legend").replaceChildren(...used.filter((u) => u[1]).map((u) => h("span", {}, h("i", { class: u[0], "aria-hidden": "true" }), t("forecast." + u[2])))); } function nowMark(d) { return h("div", { class: "fcnow", "aria-hidden": "true", style: "left:" + Math.max(0, Math.min(100, FC.now_h / hoursOf(d) * 100)) + "%" }, h("span", {}, t("plan.now"))); } function levelChart(d, b, bi) { const L = hoursOf(d), W = L * 10; const x = (hr) => hr * 10; const box = h("div", { class: "chart" }); const area = h("div", { class: "lvlchart" }); const svg = sv("svg", { viewBox: "0 0 " + W + " 100", preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false" }); [0, 50, 100].forEach((p) => svg.append(sv("line", { x1: 0, x2: W, y1: 100 - p, y2: 100 - p, stroke: "var(--line)", "stroke-width": 1, "vector-effect": "non-scaling-stroke" }))); const fill = { grid: "var(--accent)", give: "var(--house)", cycle: "var(--warn)" }; for (const r of b.blocks) { if (r.end_h <= r.start_h) continue; svg.append(sv("rect", { x: x(r.start_h), y: 0, width: x(r.end_h) - x(r.start_h), height: 100, fill: fill[r.kind] || "var(--mid)", opacity: 0.18 })); } const pts = b.levels.map((p) => x(p[0]) + "," + (100 - p[1])); svg.append(sv("polygon", { points: x(b.levels[0][0]) + ",100 " + pts.join(" ") + " " + x(b.levels[b.levels.length - 1][0]) + ",100", fill: "var(--batt)", opacity: 0.12 })); svg.append(sv("polyline", { points: pts.join(" "), fill: "none", stroke: "var(--batt)", "stroke-width": 2.5, "stroke-linejoin": "round", "vector-effect": "non-scaling-stroke" })); area.append(svg); [["100", 0], ["50", 50], ["0", 100]].forEach((y) => area.append(h("span", { class: "yl", "aria-hidden": "true", style: "top:" + y[1] + "%;transform:translateY(" + (y[1] === 100 ? "-100%" : y[1] === 0 ? "0" : "-50%") + ")" }, y[0] + " %"))); area.append(h("div", { class: "fcols", role: "group", "aria-label": b.name }, ...d.hours.map((hx, i) => hourItem("i", "", d, i, null)))); roving(area.querySelector(".fcols")); if (fcDay === 0) area.append(nowMark(d)); box.append(area); // The blocks under the chart, drawn as Home draws them. if (b.blocks.length) { const lane = h("div", { class: "lane", role: "group", "aria-label": b.name }); for (const r of b.blocks) { if (r.end_h <= r.start_h) continue; lane.append(chartItem("b", r.kind, "left:" + (r.start_h / L * 100) + "%;width:" + ((r.end_h - r.start_h) / L * 100) + "%", b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount])); } box.append(lane); roving(lane); } box.append(h("div", { class: "axis", "aria-hidden": "true" }, h("span", {}, "00:00"), h("span", {}, "06:00"), h("span", {}, "12:00"), h("span", {}, "18:00"), h("span", {}, "24:00"))); return box; } function batteryCardFc(d, b, bi) { const drawn = b.levels.length > 1; const list = b.blocks.map((r) => h("li", {}, h("strong", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount]))); return h("div", { class: "card" }, h("div", { class: "row spread" }, h("h2", {}, b.name), h("span", { class: "tiny muted" }, (b.capacity_kwh ? fmtKwh(b.capacity_kwh) + " kWh · " : "") + t("forecast.levels"))), drawn ? levelChart(d, b, bi) : null, list.length ? h("ul", { class: "plain small" }, ...list) : null, b.line ? h("p", { class: "small muted", style: "margin:6px 0 0" }, b.line) : null); } function renderTable(d) { const more = $("#fc-more"); const any = d.sun.forecast || d.sun.measured || d.sun.house; more.hidden = !any; if (!any) return; const lv = d.batteries.filter((b) => b.levels.length > 1); const head = h("tr", {}, h("th", { scope: "col" }, t("forecast.hour")), d.sun.forecast ? h("th", { scope: "col" }, t("forecast.solar")) : null, d.sun.measured ? h("th", { scope: "col" }, t("forecast.got")) : null, d.sun.house ? h("th", { scope: "col" }, t("forecast.use")) : null, ...lv.map((b) => h("th", { scope: "col" }, b.name + " %"))); const cell = (v, f) => h("td", {}, v == null ? "–" : f(v)); const rows = d.hours.map((x) => h("tr", {}, h("td", {}, x.label), d.sun.forecast ? cell(x.forecast_kwh, fmtKwh) : null, d.sun.measured ? cell(x.measured_kwh, fmtKwh) : null, d.sun.house ? cell(x.house_kwh, fmtKwh) : null, ...lv.map((b) => cell(x.levels[d.batteries.indexOf(b)], (v) => dec(Math.round(v).toString()))))); $("#fc-table").replaceChildren(h("thead", {}, head), h("tbody", {}, ...rows)); } function renderForecast() { const loading = $("#fc-loading"); if (!FC || FC.ok !== true) { loading.hidden = false; loading.textContent = FC ? t("offline.name") : t("forecast.loading"); $("#fc-notes").replaceChildren(); $("#fc-sun").hidden = true; $("#fc-batts").replaceChildren(); $("#fc-more").hidden = true; $("#fc-line").textContent = ""; return; } loading.hidden = true; const d = FC.days[fcDay]; $("#fc-line").textContent = d.line || ""; $("#fc-notes").replaceChildren(...d.notes.map((n) => h("p", { class: "fcnote small" }, n))); renderSun(d); $("#fc-batts").replaceChildren(...d.batteries.map((b, i) => batteryCardFc(d, b, i))); renderTable(d); document.querySelectorAll("#fc-day button").forEach((b) => { const on = Number(b.dataset.d) === fcDay; b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1; }); } async function loadForecast() { if (fcBusy) return; fcBusy = true; try { const r = await fetch("forecast?lang=" + LANG, { cache: "no-store" }); if (!r.ok) throw new Error("forecast " + r.status); FC = await r.json(); } catch (e) { FC = { ok: false }; } finally { fcBusy = false; } renderForecast(); } document.querySelectorAll("#fc-day button").forEach((b, i, all) => { b.addEventListener("click", () => { fcDay = Number(b.dataset.d); renderForecast(); }); b.addEventListener("keydown", (e) => { const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + all.length }[e.key]; if (j === undefined) return; e.preventDefault(); fcDay = j % all.length; renderForecast(); all[fcDay].focus(); }); }); // Every 30 s while the screen shows: the data moves slowly, and each load // is built on request. function scheduleForecast() { const on = !document.hidden && $("#t-fc").getAttribute("aria-selected") === "true"; if (on && !fcTimer) { loadForecast(); fcTimer = setInterval(loadForecast, 30000); } if (!on && fcTimer) { clearInterval(fcTimer); fcTimer = null; } } /* ---------------- 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. // One request at a time: a second confirm while the first is in flight // would read the same state and toggle it back. let toggling = false; async function setControl(want) { if (toggling) return; toggling = true; 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")); } finally { toggling = false; } 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"))); // Shared with settings.js, which loads after this file. // `first`: the page opened with no route, so settings.js may open the setup // wizard on a new install (UX-13 P4). window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash, // P3: the Simple settings view's battery card, drawn from /status. batteries: (host) => { simpleHost = host; if (S) renderBatteries(); refresh(); }, status: () => S, kw }; /* ---------------- 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(); document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash })); } 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, Batteries or Forecast shows ---------------- */ let timer = null; function schedule() { scheduleForecast(); const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" || $("#t-batt").getAttribute("aria-selected") === "true" || $("#t-fc").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(); })();