"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(); } 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.setup_only === true ? t("setup_only.name") : 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; // 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(); } /* ---------------- 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.points > 1 ? ", " + t("plan.average_tip") : "") + (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 (placeholder until 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", {}, dec(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))))); } /* ---------------- 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 }; /* ---------------- 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 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(); })();