Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7efb265
fix(ui): theme-aware charts + heat-pump card auto re-discovery
claude Jun 26, 2026
6e30681
fix(ui): theme the LIVE dashboard charts (next-app.js) for light mode…
claude Jun 26, 2026
62c0d4b
feat(ui): add a Register column to the heat-pump all-signals detail view
claude Jun 26, 2026
39e99ec
fix(ui): theme the heat-pump card in light mode
claude Jun 29, 2026
45f352f
feat(ui): grouped heat-pump card — 28 signals, sensor IDs + info tool…
claude Jun 29, 2026
8f91071
feat(ui): heat-pump card to English + word priority, '?' help icons, …
claude Jun 29, 2026
cb68784
feat(ui): render heat-pump groups as grey sub-cards (--ink-sunken)
claude Jun 29, 2026
de8c6bd
feat(ui): NIBE local-API driver fully configurable in the Add-driver …
claude Jun 29, 2026
5893099
feat(ui): heat-pump month temp chart + energy-per-period on the card
claude Jun 29, 2026
84f3717
feat(ui): explain every signal in the heat-pump all-signals view
claude Jun 29, 2026
4fb2596
fix(ui): heat-pump ? icons are tooltip-only + reveal local-API creds …
claude Jun 30, 2026
4b27307
fix(ui): force-hide battery capacity for non-battery drivers
claude Jun 30, 2026
d8e3ef2
fix(ui): secret badge says 'Set — not shown here' instead of 'Not saved'
claude Jun 30, 2026
aa46acc
fix(ui): honour has_password flag so a saved password reads as set
claude Jun 30, 2026
d296d07
feat(ui): add temperature + date axes to the heat-pump chart
claude Jun 30, 2026
8d2e094
feat(ui): give the compressor 24h strip real axes (kW + time)
claude Jun 30, 2026
3540525
feat(ui): min/max value scale + 24h context on all-signals sparklines
claude Jul 1, 2026
61579e9
feat(ui): power breakdown chart, extract-air line, BT labels + data t…
claude Jul 1, 2026
125cbeb
fix: bound heat-pump dashboard history requests
frahlg Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/dashboard-light-dark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"forty-two-watts": patch
---

Dashboard light-mode + heat-pump card fixes.

The live power/energy **charts now render correctly in light mode**. The canvas chrome (axis labels, gridlines, zero/now/hover lines, both tooltips' background+border+text, and the neutral "Load" line + legend swatch) was hard-coded for the dark theme, so it went invisible or wrong on a light background. The charts now resolve the CSS theme tokens (`--fg`, `--fg-dim`, `--fg-muted`, `--line`, `--ink-raised`, `--accent-e`) into concrete canvas colors per draw — cached and re-read when `data-theme` changes. Saturated data-series hues are unchanged.

The **heat-pump card now re-discovers newly-added drivers** without a manual reload. `heating.js` cached discovery once and never re-checked — and an empty result is truthy, so a site that discovered before its pump reported `hp_power_w` stayed blank. It now re-scans on first load and every 5 minutes, while steady-state polling still only touches already-known heat-pump drivers.

The heat-pump **"all signals" detail view now shows a Register column** — each signal's source Modbus register id (read from the metric snapshot the driver reports). Signals with no Modbus mapping show "—".

The **heat-pump card itself now themes in light mode**. It renders into a bare `.card`, whose base style uses the `--surface` token — which the light theme doesn't flip — so the card stayed dark on a light page while every other dashboard card themed correctly. It now uses the same `--ink-raised` palette as the rest.
95 changes: 68 additions & 27 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,8 @@
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

var C = chartColors(); // theme-aware chrome colors, re-read each draw

var pad = { top: 20, right: 10, bottom: 25, left: 55 };
var plotW = w - pad.left - pad.right;
var plotH = h - pad.top - pad.bottom;
Expand All @@ -529,13 +531,13 @@
{ data: toKwh(chartHistory.e_pv), color: "#10b981", width: 2, dash: [], name: "PV", fill: true },
{ data: toKwh(chartHistory.e_charged), color: "#3b82f6", width: 2, dash: [], name: "Charged", fill: false },
{ data: toKwh(chartHistory.e_discharged), color: "#f59e0b", width: 2, dash: [], name: "Discharged", fill: false },
{ data: toKwh(chartHistory.e_load), color: "#e2e8f0", width: 2, dash: [], name: "Load", fill: false },
{ data: toKwh(chartHistory.e_load), color: C.load, width: 2, dash: [], name: "Load", fill: false },
];
} else {
series = [
{ data: chartHistory.grid, color: "#ef4444", width: 2, dash: [], name: "Grid", fill: true, toggle: "grid" },
{ data: chartHistory.pv, color: "#22c55e", width: 2, dash: [], name: "PV", fill: true, toggle: "pv" },
{ data: chartHistory.load, color: "#e2e8f0", width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" },
{ data: chartHistory.load, color: C.load, width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" },
];
// Append one actual/target pair per discovered battery driver.
// Stable order so chart colors don't jump as the driver set grows.
Expand Down Expand Up @@ -567,7 +569,7 @@
if (visibleVals.length === 0) {
// Empty state — draw axes + "waiting for data" hint
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#666";
ctx.fillStyle = C.muted;
ctx.font = "12px monospace";
ctx.textAlign = "center";
ctx.fillText("waiting for data...", w / 2, h / 2);
Expand Down Expand Up @@ -615,7 +617,7 @@
// Grid lines (drawn inside clip so they only appear in the plot area).
// Walk yMin..yMax in yStep increments so every line lands on a round
// number — that's what lets the y-axis labels stay readable.
ctx.strokeStyle = "#2a2a2a";
ctx.strokeStyle = C.grid;
ctx.lineWidth = 0.5;
ctx.font = "11px monospace";
var steps = Math.round(yRange / yStep);
Expand All @@ -630,7 +632,7 @@
// Zero line
if (yMin < 0 && yMax > 0) {
var zeroY = pad.top + plotH * (1 - (0 - yMin) / yRange);
ctx.strokeStyle = "#444";
ctx.strokeStyle = C.muted;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
Expand Down Expand Up @@ -795,15 +797,15 @@

// Subtle vertical "now" line — anchor point so the eye knows where present is
var nowX = pad.left + plotW;
ctx.strokeStyle = "rgba(255,255,255,0.12)";
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(nowX, pad.top);
ctx.lineTo(nowX, pad.top + plotH);
ctx.stroke();

// Y-axis labels (outside clip so they're fully visible)
ctx.fillStyle = "#888";
ctx.fillStyle = C.dim;
ctx.font = "11px monospace";
for (var i2 = 0; i2 <= steps; i2++) {
var yVal = yMin + (yRange * i2 / steps);
Expand All @@ -812,7 +814,7 @@
}

// Time labels
ctx.fillStyle = "#666";
ctx.fillStyle = C.muted;
ctx.fillText(chartRange + " ago", pad.left, h - 5);
ctx.textAlign = "right";
ctx.fillText("now", w - pad.right, h - 5);
Expand All @@ -838,7 +840,7 @@
ctx.arc(w - pad.right - 78, pad.top + 4, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.font = "10px monospace";
ctx.fillStyle = fresh ? "#aaa" : "#f59e0b";
ctx.fillStyle = fresh ? C.dim : "#f59e0b";
ctx.fillText(ageStr, w - pad.right - 70, pad.top + 8);
}

Expand Down Expand Up @@ -870,8 +872,46 @@
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
}

// Resolve a CSS custom property to a concrete color string for <canvas>,
// which can't read var(). Re-read per draw so the charts follow the active
// theme (html[data-theme]) without needing an explicit redraw on toggle —
// a hidden probe element inherits :root, and getComputedStyle resolves the
// var() + oklch token to a value canvas can paint.
var _colorProbe = null;
function cssColor(name, fallback) {
if (!_colorProbe) {
_colorProbe = document.createElement("span");
_colorProbe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none";
document.body.appendChild(_colorProbe);
}
_colorProbe.style.color = "var(" + name + ", " + (fallback || "#888") + ")";
return getComputedStyle(_colorProbe).color || fallback || "#888";
}
// Theme-aware chart chrome colors. Series hues stay fixed (they read on
// both themes); only text / gridlines / tooltip surface / the neutral
// "load" line flip — those are what go invisible on a light background.
// Cached so the animation loop doesn't getComputedStyle every frame —
// only recomputed when the active theme actually changes.
var _chartColors = null, _chartColorsTheme = null;
function chartColors() {
var theme = document.documentElement.getAttribute("data-theme") || "";
if (_chartColors && _chartColorsTheme === theme) return _chartColors;
_chartColorsTheme = theme;
_chartColors = {
text: cssColor("--fg", "#e6e6e6"),
dim: cssColor("--fg-dim", "#aaaaaa"),
muted: cssColor("--fg-muted", "#888888"),
grid: cssColor("--line", "#2a2a2a"),
surface: cssColor("--ink-raised", "#14141f"),
accent: cssColor("--accent-e", "#fbbf24"),
load: cssColor("--fg", "#e2e8f0"),
};
return _chartColors;
}

function drawHoverOverlay(ctx) {
if (!chartLayout) return;
var C = chartColors();
var l = chartLayout;
var i = hoverIndex;
// Map by timestamp (matches the time-anchored line drawing)
Expand All @@ -880,7 +920,7 @@
var x = l.pad.left + l.plotW * (ts - l.windowStart) / l.totalMs;

// Vertical line
ctx.strokeStyle = "rgba(255,255,255,0.3)";
ctx.strokeStyle = C.muted;
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.beginPath();
Expand All @@ -906,12 +946,12 @@
{ name: "PV", data: chartHistory.e_pv, color: "#10b981" },
{ name: "Charged", data: chartHistory.e_charged, color: "#3b82f6" },
{ name: "Discharged", data: chartHistory.e_discharged, color: "#f59e0b" },
{ name: "Load", data: chartHistory.e_load, color: "#e2e8f0" },
{ name: "Load", data: chartHistory.e_load, color: C.load },
] : (function () {
var rows = [
{ name: "Grid", data: chartHistory.grid, color: "#ef4444" },
{ name: "PV", data: chartHistory.pv, color: "#22c55e" },
{ name: "Load", data: chartHistory.load, color: "#e2e8f0" },
{ name: "Load", data: chartHistory.load, color: C.load },
];
// Battery rows render their target inline as "actual W (→ target W)"
// so it's visually obvious the two numbers are the same metric — one
Expand All @@ -934,36 +974,36 @@
if (boxX + boxW > l.w - 5) boxX = x - boxW - 10;
var boxY = l.pad.top + 5;

ctx.fillStyle = "rgba(20,20,35,0.95)";
ctx.strokeStyle = "#444";
ctx.fillStyle = C.surface;
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);

ctx.font = "10px monospace";
ctx.fillStyle = "#888";
ctx.fillStyle = C.dim;
ctx.fillText(timeStr, boxX + 6, boxY + lineHeight - 2);

labels.forEach(function (lab, idx) {
if (i >= lab.data.length) return;
var y = boxY + (idx + 2) * lineHeight - 4;
ctx.fillStyle = lab.color;
ctx.fillRect(boxX + 6, y - 8, 8, 8);
ctx.fillStyle = lab.dim ? "#888" : "#ddd";
ctx.fillStyle = lab.dim ? C.muted : C.text;
ctx.fillText(lab.name, boxX + 18, y);
ctx.textAlign = "right";
if (chartView === "energy") {
ctx.fillStyle = "#fff";
ctx.fillStyle = C.text;
ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y);
} else {
var actual = formatW(lab.data[i]);
ctx.fillStyle = "#fff";
ctx.fillStyle = C.text;
ctx.fillText(actual, boxX + boxW - 6, y);
// Inline target as dim "(→ -674 W)" so user sees commanded vs actual
// in one glance. Skip when target is 0 to reduce visual noise.
if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) {
var actualW = ctx.measureText(actual).width;
ctx.fillStyle = "#888";
ctx.fillStyle = C.dim;
ctx.font = "9px monospace";
ctx.fillText("→ " + formatW(lab.target[i]), boxX + boxW - 10 - actualW, y);
ctx.font = "10px monospace";
Expand All @@ -975,6 +1015,7 @@

function drawForecastHoverOverlay(ctx) {
if (!chartLayout || !hoverForecast) return;
var C = chartColors();
var l = chartLayout;
var a = hoverForecast.action;
var ts = hoverForecast.ts;
Expand Down Expand Up @@ -1007,25 +1048,25 @@
if (boxX + boxW > l.w - 5) boxX = x - boxW - 10;
var boxY = l.pad.top + 5;

ctx.fillStyle = "rgba(20,20,35,0.96)";
ctx.strokeStyle = "rgba(251,191,36,0.6)";
ctx.fillStyle = C.surface;
ctx.strokeStyle = C.accent;
ctx.lineWidth = 1;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);

ctx.font = "10px monospace";
var d = new Date(ts);
var hh = d.getHours().toString().padStart(2, "0") + ":" + d.getMinutes().toString().padStart(2, "0");
ctx.fillStyle = "#fbbf24";
ctx.fillStyle = C.accent;
ctx.fillText(hh + " predicted", boxX + 6, boxY + lineHeight - 2);

labels.forEach(function (lab, idx) {
var y = boxY + (idx + 2) * lineHeight - 4;
ctx.fillStyle = lab.color;
ctx.fillRect(boxX + 6, y - 8, 8, 8);
ctx.fillStyle = "#ddd";
ctx.fillStyle = C.text;
ctx.fillText(lab.name, boxX + 18, y);
ctx.fillStyle = "#fff";
ctx.fillStyle = C.text;
ctx.textAlign = "right";
var val = lab.literal ? lab.val : formatW(lab.val);
ctx.fillText(val, boxX + boxW - 6, y);
Expand All @@ -1034,7 +1075,7 @@

if (a.reason) {
var ry = boxY + (labels.length + 2) * lineHeight + 2;
ctx.fillStyle = "#86efac";
ctx.fillStyle = C.dim;
ctx.font = "italic 10px monospace";
// Truncate if too long for box
var reason = a.reason.length > 28 ? a.reason.substring(0, 27) + "…" : a.reason;
Expand Down Expand Up @@ -2078,9 +2119,9 @@
if (!legend) return;
var items = chartView === "energy" ? [
["#ef4444", "Import"], ["#22c55e", "Export"], ["#10b981", "PV"],
["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["#e2e8f0", "Load"],
["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["var(--fg)", "Load"],
] : [
["#ef4444", "Grid"], ["#22c55e", "PV"], ["#e2e8f0", "Load"],
["#ef4444", "Grid"], ["#22c55e", "PV"], ["var(--fg)", "Load"],
["#f59e0b", "Ferroamp"], ["#8b5cf6", "Sungrow"],
];
legend.innerHTML = items.map(function(it) {
Expand Down
20 changes: 20 additions & 0 deletions web/heating-request-budget.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const source = readFileSync(new URL('./heating.js', import.meta.url), 'utf8');

test('heat-pump history is cached instead of range-scanned every live refresh', () => {
assert.match(source, /HISTORY_REFRESH_MS\s*=\s*300000/);
assert.match(source, /historyCache\[name\]\s*=\s*\{\s*at:/);
assert.match(source, /fetchPumpHistory\(n\)/);
});

test('all-signals view does not fetch one history series per NIBE point', () => {
assert.match(source, /metrics\.filter\(function \(m\) \{ return !!infoForKey\(m\.name\); \}\)\.forEach/);
});

test('overlapping dashboard refreshes are suppressed', () => {
assert.match(source, /if \(refreshInFlight\) return;/);
assert.match(source, /refreshInFlight = false;/);
});
Loading
Loading