Skip to content

Commit cfc83c0

Browse files
fix(web): metrics dashboard SoC chart text freezes on first-load value
The battery SoC doughnut chart's center text (percentage and kWh figures) is drawn by a Chart.js afterDraw plugin registered once in mdInitSOCChart. That plugin closed over the pct/d values from the moment the chart was first created. On each 30s refresh, mdUpdateSOCChart only mutates the existing chart's data array and calls .update() - it never recreates the chart (mdRenderAll only calls mdInitSOCChart again if mdSocChart doesn't exist yet) - so the doughnut ring's fill proportion updated correctly but the on-screen number text stayed frozen at the first-load reading, while every other card on the page (Health, Cost, API, Solar) re-renders fresh from the latest data on every refresh. Fixes #4151. The afterDraw plugin now reads chart.data.datasets[0].data[0] for the percentage (already correctly updated by mdUpdateSOCChart) and the MD_DATA global for the kWh figures (already correctly reassigned on every refresh), instead of the stale closed-over values. No JS execution/browser test infra exists in this codebase, so this can't run the embedded JavaScript and check actual canvas output. Added a structural test instead, checking the generated JS source itself reads from chart.data/MD_DATA rather than the closure parameters, to catch a regression back to the same bug shape. 🤖 Implemented with Claude Code, disclosed per repo convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 378066d commit cfc83c0

3 files changed

Lines changed: 58 additions & 2 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# -----------------------------------------------------------------------------
2+
# Predbat Home Battery System
3+
# Copyright Trefor Southwell 2026 - All Rights Reserved
4+
# This application maybe used for personal use only and not for commercial use
5+
# -----------------------------------------------------------------------------
6+
# fmt off
7+
# pylint: disable=consider-using-f-string
8+
# pylint: disable=line-too-long
9+
# pylint: disable=attribute-defined-outside-init
10+
11+
"""
12+
Test that the Metrics Dashboard's battery SoC doughnut chart center text reads live data on
13+
refresh rather than being frozen at whatever it was on first page load - see issue #4151.
14+
15+
There's no JS execution/browser test infra in this codebase, so this can't run the embedded
16+
JavaScript and check actual canvas output. Instead it checks the generated JS source itself:
17+
the afterDraw plugin (Chart.js) must read the chart's own current data array and the always-
18+
current MD_DATA global, not the pct/d values that were closed over when the chart was first
19+
created in mdInitSOCChart - mdUpdateSOCChart (called on every 30s refresh once the chart
20+
already exists) only mutates the chart's data array, it never recreates the chart, so a closure
21+
over the original values would freeze the on-screen text at the first-load reading forever.
22+
"""
23+
24+
from web_metrics_dashboard import get_metrics_dashboard_body
25+
26+
27+
def test_soc_chart_center_text_reads_live_data(my_predbat):
28+
"""
29+
The afterDraw plugin body must not reference the pct/d parameters closed over at chart
30+
creation time for the values it draws - it must read chart.data.datasets[0].data (updated
31+
by mdUpdateSOCChart on every refresh) and the MD_DATA global (reassigned on every refresh).
32+
"""
33+
print("**** test_soc_chart_center_text_reads_live_data ****")
34+
35+
body = get_metrics_dashboard_body("{}")
36+
37+
# Isolate the md-center-text plugin's afterDraw function body, up to its closing "}]"
38+
marker = "id: 'md-center-text'"
39+
assert marker in body, "md-center-text plugin not found in dashboard body"
40+
start = body.index(marker)
41+
end = body.index("}]", start) + 2
42+
plugin_block = body[start:end]
43+
44+
assert "chart.data.datasets[0].data[0]" in plugin_block, "afterDraw should read the live percentage from the chart's own current data, not a closed-over value"
45+
assert "MD_DATA.battery_soc_kwh" in plugin_block, "afterDraw should read the live kWh figure from the MD_DATA global, not a closed-over value"
46+
47+
print("✓ afterDraw plugin reads live chart data and MD_DATA, not stale closure values")
48+
print("✓ Test passed")
49+
return False

‎apps/predbat/unit_test.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from tests.test_hainterface_websocket import run_hainterface_websocket_tests
6666
from tests.test_web_if import run_test_web_if
6767
from tests.test_web_chart_currency import test_rates_chart_series_names_use_currency_symbol
68+
from tests.test_metrics_dashboard_soc_refresh import test_soc_chart_center_text_reads_live_data
6869
from tests.test_web_functions import run_web_functions_tests
6970
from tests.test_window import run_window_sort_tests, run_intersect_window_tests
7071
from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve
@@ -277,6 +278,7 @@ def main():
277278
("manual_select", run_test_manual_select, "Manual select tests", False),
278279
("web_if", run_test_web_if, "Web interface tests", False),
279280
("web_chart_currency", test_rates_chart_series_names_use_currency_symbol, "Rates chart series names follow currency_symbols tests", False),
281+
("metrics_dashboard_soc_refresh", test_soc_chart_center_text_reads_live_data, "Metrics dashboard SoC chart live-refresh tests", False),
280282
("web_functions", run_web_functions_tests, "Web function unit tests", False),
281283
("nordpool", run_nordpool_test, "Nordpool tests", False),
282284
("futurerate_auto", test_futurerate_auto, "FutureRate auto Agile detection tests", False),

‎apps/predbat/web_metrics_dashboard.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,21 @@ def get_metrics_dashboard_body(data_json):
209209
options: { cutout: '72%', responsive: false, plugins: { legend: {display:false}, tooltip: {enabled:false} } },
210210
plugins: [{
211211
id: 'md-center-text',
212+
// Read live values (chart's own current data, and the always-current MD_DATA global) rather
213+
// than the pct/d closed over at chart creation time - mdUpdateSOCChart only mutates the
214+
// chart's data array on refresh, it doesn't recreate the chart, so a closure over the
215+
// original values here would freeze this text at whatever it was on first load.
212216
afterDraw: function (chart) {
213217
var w = chart.width, h = chart.height, c2 = chart.ctx;
218+
var livePct = chart.data.datasets[0].data[0] || 0;
214219
c2.save();
215220
c2.fillStyle = mdVar('--md-text');
216221
c2.font = 'bold 2rem sans-serif';
217222
c2.textAlign = 'center'; c2.textBaseline = 'middle';
218-
c2.fillText(mdFmt(pct, 0) + '%', w / 2, h / 2 - 10);
223+
c2.fillText(mdFmt(livePct, 0) + '%', w / 2, h / 2 - 10);
219224
c2.font = '0.85rem sans-serif';
220225
c2.fillStyle = mdVar('--md-muted');
221-
c2.fillText(mdFmt(d.battery_soc_kwh, 1) + ' / ' + mdFmt(d.battery_max_kwh, 1) + ' kWh', w / 2, h / 2 + 18);
226+
c2.fillText(mdFmt(MD_DATA.battery_soc_kwh, 1) + ' / ' + mdFmt(MD_DATA.battery_max_kwh, 1) + ' kWh', w / 2, h / 2 + 18);
222227
c2.restore();
223228
}
224229
}]

0 commit comments

Comments
 (0)