UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
A new tab beside Home and Batteries for today and tomorrow: the solar forecast, the solar measured so far, the house's expected use and each battery's expected level, with the plan's own charge and give blocks under it. - Blocks are Home's: today's are the plan's `runs`, tomorrow's come from the same builders (`homeview.runs_for()`), in the plan lines' words. - The sun is what solarfc reads; a daily total is spread over the bell and the tab says so. The house is HouseProfile.estimator(), as the plan reads it. - The level is new: `daycurve.curve()` gives each battery the role `modetrade.decide()` gives it and moves it hour by hour inside the plan's floor and ceiling. It is an estimate and counts on `solar_trust_pct`. - Built on request (`GET forecast`), no Home Assistant call. Measured solar is kept per hour in memory from the reading control already takes. - A part not known yet is a note: modes off, no forecast, learning, no forecast for tomorrow, a monthly cycle, no level, stopped, shadow. No decision, command, timing or option key changes. Found, not changed: `dayplan.plan()` over-counts a state-only forecast (DECISIONS/UX-54). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude
parent
a1d07030f8
commit
b6b2d1b184
@@ -246,3 +246,29 @@ select.sel:disabled{opacity:.6}
|
||||
.simple .card .lbl{font-weight:600;font-size:15px}
|
||||
.simple .inv .hd{border-bottom:0}
|
||||
.simple .inv .f{border-top:0;padding-top:0}
|
||||
/* UX-54: the Forecast screen */
|
||||
.fcline{text-align:right}
|
||||
.fcnote{background:var(--info-bg);border-radius:10px;padding:8px 10px;margin:0 0 8px}
|
||||
.fcbars{display:flex;align-items:flex-end;gap:2px;height:110px}
|
||||
.fcol{flex:1;position:relative;height:100%;min-width:0}
|
||||
.fcol i{position:absolute;display:block}
|
||||
.fcol .sun{bottom:0;left:0;right:0;background:var(--solar);border-radius:3px 3px 0 0;opacity:.85}
|
||||
.fcol .sun.est{opacity:.5}
|
||||
.fcol .got{bottom:0;left:30%;right:30%;background:repeating-linear-gradient(135deg,var(--ink) 0 3px,var(--card) 3px 5px);border:1px solid var(--ink);border-bottom:0}
|
||||
.fcol .use{left:-1px;right:-1px;height:3px;background:var(--house)}
|
||||
.fcols{position:absolute;inset:0;display:flex;gap:0}
|
||||
.fcols>i{flex:1;min-width:0;display:block}
|
||||
.lvlchart{position:relative;height:110px;margin:18px 0 4px}
|
||||
.lvlchart svg{position:absolute;inset:0;width:100%;height:100%;display:block}
|
||||
.lvlchart .yl{position:absolute;right:2px;font-size:10px;color:var(--faint);pointer-events:none}
|
||||
.fcnow{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
|
||||
.fcnow span{position:absolute;top:-15px;left:-10px;font-size:10px}
|
||||
.legend i.sun{background:var(--solar)}
|
||||
.legend i.got{background:repeating-linear-gradient(135deg,var(--ink) 0 2px,var(--card) 2px 4px);border:1px solid var(--ink)}
|
||||
.legend i.use{height:3px;background:var(--house)}
|
||||
.legend i.lv{height:3px;background:var(--batt)}
|
||||
table.fct{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}
|
||||
table.fct th,table.fct td{padding:4px 3px;text-align:right;border-bottom:1px solid var(--line)}
|
||||
table.fct th:first-child,table.fct td:first-child{text-align:left}
|
||||
table.fct th{color:var(--muted);font-weight:600}
|
||||
.ymax{position:absolute;top:-2px;left:2px;font-size:10px;color:var(--faint);pointer-events:none}
|
||||
|
||||
@@ -448,6 +448,175 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 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.
|
||||
@@ -511,11 +680,13 @@
|
||||
}
|
||||
addEventListener("hashchange", fromHash);
|
||||
|
||||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||||
/* ---------------- 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-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; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
|
||||
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
|
||||
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
|
||||
<button class="tab" role="tab" id="t-fc" aria-controls="s-fc" aria-selected="false" data-hash="forecast" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 20h18M4 16l5-6 4 4 7-9"/></svg><span data-t="tabs.forecast"></span></button>
|
||||
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
|
||||
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
|
||||
</nav>
|
||||
@@ -114,6 +115,32 @@
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-fc" role="tabpanel" aria-labelledby="t-fc" hidden>
|
||||
<div class="row spread" style="margin-bottom:8px">
|
||||
<div class="seg" role="radiogroup" id="fc-day" data-t-label="forecast.day">
|
||||
<button type="button" role="radio" aria-checked="true" data-d="0" data-t="forecast.today"></button>
|
||||
<button type="button" role="radio" aria-checked="false" data-d="1" tabindex="-1" data-t="forecast.tomorrow"></button>
|
||||
</div>
|
||||
<span class="tiny muted grow fcline" id="fc-line"></span>
|
||||
</div>
|
||||
<div id="fc-notes" aria-live="polite"></div>
|
||||
<p class="small muted" id="fc-loading" data-t="forecast.loading"></p>
|
||||
<div class="card" id="fc-sun" hidden>
|
||||
<div class="row spread"><h2 data-t="forecast.sun"></h2><span class="tiny muted" data-t="forecast.unit"></span></div>
|
||||
<div class="chart" id="fc-sunchart">
|
||||
<div class="fcbars" id="fc-bars" role="group"></div>
|
||||
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
|
||||
</div>
|
||||
<div class="legend" id="fc-legend"></div>
|
||||
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
|
||||
</div>
|
||||
<div id="fc-batts"></div>
|
||||
<details class="grp" id="fc-more" hidden>
|
||||
<summary><span class="grow"><strong data-t="forecast.table"></strong></span><span class="chev" aria-hidden="true">›</span></summary>
|
||||
<div class="body"><table class="fct" id="fc-table"></table></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
|
||||
<div class="row spread" style="margin-bottom:6px">
|
||||
<h2 style="margin:0" data-s="name"></h2>
|
||||
|
||||
Reference in New Issue
Block a user