fix widen mobile weather chart

Use concise timeframe labels and tighter card padding on phone layouts. Hide secondary axis chrome on compact screens while retaining datasets, legend, and tooltips. Add dependency-free Node tests for responsive scales and shared frontend primitives.

Commit
99229b9a02261779b2a0cfaf972c4725333467fc
Author
gpt-5.6-sol high <agent@openai.com>
Author date
Committer
gpt-5.6-sol high <agent@openai.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard/weather-page.js
index c46e458f..32429a29 100644..100644
@@ -1,41 +1,61 @@
1 Removed: import { TIMEFRAMES } from "./constants.js";
2 Removed: import { formatDateTime } from "./format.js";
3 Removed: import { createTimeframeState } from "./timeframe.js";
1 Added: import { fetchWeatherHourly } from "./api.js";
2 Added: import { tooltipOptions } from "./chart-options.js";
3 Added: import { formatChartTime } from "./format.js";
4 Added: import { createTimeframeState, timeframeToHours } from "./timeframe.js";
4 5 import { initialiseGraphControls } from "./graph-controls.js";
5 6
6 Removed: const TOOLTIP_DEFAULTS = {
7 Removed: mode: "index",
8 Removed: intersect: false,
9 Removed: backgroundColor: "rgba(30, 41, 59, 0.7)",
10 Removed: titleFont: { size: 11, weight: "normal" },
11 Removed: bodyFont: { size: 12 },
12 Removed: padding: { x: 8, y: 5 },
13 Removed: cornerRadius: 4,
14 Removed: caretSize: 0
15 Removed: };
16 Removed:
17 7 let chart = null;
18 8
19 Removed: async function fetchJson(url) {
20 Removed: const response = await fetch(url);
21 Removed: if (!response.ok) throw new Error(`HTTP ${response.status}`);
22 Removed: return response.json();
23 Removed: }
24 9
25 Removed: function timeframeToHours(timeframe) {
26 Removed: const tf = timeframe.selected();
27 Removed: const range = timeframe.range();
28 Removed: const ms = TIMEFRAMES[tf]?.ms || TIMEFRAMES.day.ms;
29 Removed: return Math.round((ms * range) / (60 * 60 * 1000));
10 Added: export function weatherScaleOptions(compact) {
11 Added: return {
12 Added: x: {
13 Added: ticks: {
14 Added: maxTicksLimit: compact ? 6 : 10,
15 Added: maxRotation: compact ? 0 : 45,
16 Added: minRotation: 0,
17 Added: font: { size: 10 }
18 Added: }
19 Added: },
20 Added: yTemp: {
21 Added: type: "linear",
22 Added: position: "left",
23 Added: title: { display: !compact, text: "°C", color: "#dc2626" },
24 Added: ticks: { color: "#dc2626", font: { size: 10 } },
25 Added: grid: { display: true }
26 Added: },
27 Added: yHumid: {
28 Added: type: "linear",
29 Added: position: "right",
30 Added: display: !compact,
31 Added: min: 0,
32 Added: max: 100,
33 Added: title: { display: true, text: "%", color: "#0ea5e9" },
34 Added: ticks: { color: "#0ea5e9", font: { size: 10 } },
35 Added: grid: { drawOnChartArea: false }
36 Added: },
37 Added: yRain: {
38 Added: type: "linear",
39 Added: position: "right",
40 Added: display: !compact,
41 Added: beginAtZero: true,
42 Added: title: { display: true, text: "mm", color: "#64748b" },
43 Added: ticks: { color: "#64748b", font: { size: 10 } },
44 Added: grid: { drawOnChartArea: false },
45 Added: afterFit(axis) { axis.paddingLeft = 10; }
46 Added: }
47 Added: };
30 48 }
31 Removed:
32 49 async function loadCombined(timeframe) {
33 50 const canvas = document.getElementById("chart-weather-combined");
34 51 if (!canvas) return;
35 52
36 53 const hours = timeframeToHours(timeframe);
37 Removed: const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
38 Removed: const labels = data.points.map(p => formatDateTime(p.timestamp));
54 Added: const data = await fetchWeatherHourly(hours);
55 Added: const labels = data.points.map(point =>
56 Added: formatChartTime(point.timestamp, timeframe.selected())
57 Added: );
58 Added: const compact = window.matchMedia("(max-width: 575.98px)").matches;
39 59 const temps = data.points.map(p => p.temperature);
40 60 const humids = data.points.map(p => p.humidity);
41 61 const rains = data.points.map(p => p.rain || 0);
@@ -88,38 +108,13 @@
88 108 maintainAspectRatio: false,
89 109 animation: false,
90 110 interaction: { mode: "index", intersect: false },
91 Removed: scales: {
92 Removed: x: { ticks: { maxTicksLimit: 10, font: { size: 10 } } },
93 Removed: yTemp: {
94 Removed: type: "linear",
95 Removed: position: "left",
96 Removed: title: { display: true, text: "°C", color: "#dc2626" },
97 Removed: ticks: { color: "#dc2626", font: { size: 10 } },
98 Removed: grid: { display: true }
99 Removed: },
100 Removed: yHumid: {
101 Removed: type: "linear",
102 Removed: position: "right",
103 Removed: min: 0,
104 Removed: max: 100,
105 Removed: title: { display: true, text: "%", color: "#0ea5e9" },
106 Removed: ticks: { color: "#0ea5e9", font: { size: 10 } },
107 Removed: grid: { drawOnChartArea: false }
108 Removed: },
109 Removed: yRain: {
110 Removed: type: "linear",
111 Removed: position: "right",
112 Removed: beginAtZero: true,
113 Removed: title: { display: true, text: "mm", color: "#64748b" },
114 Removed: ticks: { color: "#64748b", font: { size: 10 } },
115 Removed: grid: { drawOnChartArea: false },
116 Removed: // Offset to not overlap with humidity axis
117 Removed: afterFit(axis) { axis.paddingLeft = 10; }
118 Removed: }
119 Removed: },
111 Added: scales: weatherScaleOptions(compact),
120 112 plugins: {
121 Removed: legend: { display: true, labels: { font: { size: 10 } } },
122 Removed: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
113 Added: legend: {
114 Added: display: true,
115 Added: labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } }
116 Added: },
117 Added: tooltip: tooltipOptions({ title: () => "" })
123 118 }
124 119 }
125 120 });
roles/dashboard/t/11-javascript.t
index 00000000..0ff6f682 000000..100644
@@ -0,0 +1,21 @@
1 Added: use Mojo::Base -strict;
2 Added:
3 Added: use Test2::V0;
4 Added:
5 Added: use FindBin;
6 Added:
7 Added: my @command = (
8 Added: 'node', '--experimental-vm-modules',
9 Added: '--test', "${FindBin::Bin}/js/frontend.test.cjs",
10 Added: );
11 Added:
12 Added: open my $output, '-|', @command
13 Added: or plan skip_all => 'node is unavailable';
14 Added: my $diagnostics = do { local $/; <$output> };
15 Added: close $output;
16 Added: my $exit_status = $? >> 8;
17 Added:
18 Added: diag $diagnostics if $exit_status;
19 Added: is $exit_status, 0, 'frontend unit tests pass';
20 Added:
21 Added: done_testing;
roles/dashboard/t/js/frontend.test.cjs
index 00000000..9a9d58d5 000000..100644
@@ -0,0 +1,83 @@
1 Added: const assert = require("node:assert/strict");
2 Added: const { test } = require("node:test");
3 Added: const vm = require("node:vm");
4 Added: const fs = require("node:fs/promises");
5 Added: const path = require("node:path");
6 Added: const { fileURLToPath, pathToFileURL } = require("node:url");
7 Added:
8 Added: const moduleCache = new Map();
9 Added:
10 Added: async function loadModule(file) {
11 Added: const absolute = path.resolve(file);
12 Added: if (moduleCache.has(absolute)) return moduleCache.get(absolute);
13 Added:
14 Added: const source = await fs.readFile(absolute, "utf8");
15 Added: const module = new vm.SourceTextModule(source, {
16 Added: identifier: pathToFileURL(absolute).href
17 Added: });
18 Added: moduleCache.set(absolute, module);
19 Added:
20 Added: await module.link((specifier, referencingModule) => {
21 Added: const dependency = fileURLToPath(new URL(specifier, referencingModule.identifier));
22 Added: return loadModule(dependency);
23 Added: });
24 Added: await module.evaluate();
25 Added: return module;
26 Added: }
27 Added:
28 Added: function dashboardModule(name) {
29 Added: return loadModule(path.join(__dirname, "../../public/js/dashboard", name));
30 Added: }
31 Added:
32 Added: test("status state trusts API unreachable status", async () => {
33 Added: const module = await dashboardModule("status-state.js");
34 Added: const { stateForNodeStatus } = module.namespace;
35 Added: const current = new Date().toISOString();
36 Added:
37 Added: assert.equal(stateForNodeStatus(null), "unreachable");
38 Added: assert.equal(
39 Added: stateForNodeStatus({ status: "unreachable", received_at: current }),
40 Added: "unreachable"
41 Added: );
42 Added: assert.equal(stateForNodeStatus({ status: "ok", received_at: current }), "healthy");
43 Added: assert.equal(
44 Added: stateForNodeStatus({ status: "probe_error", timestamp: current }),
45 Added: "probe-error"
46 Added: );
47 Added: assert.equal(stateForNodeStatus({ status: "ok", timestamp: "invalid" }), "unreachable");
48 Added: });
49 Added:
50 Added: test("timeframeToHours includes the selected range", async () => {
51 Added: const module = await dashboardModule("timeframe.js");
52 Added: const { timeframeToHours } = module.namespace;
53 Added:
54 Added: assert.equal(timeframeToHours({ selected: () => "hour", range: () => 1 }), 1);
55 Added: assert.equal(timeframeToHours({ selected: () => "day", range: () => 2 }), 48);
56 Added: assert.equal(timeframeToHours({ selected: () => "week", range: () => 3 }), 504);
57 Added: assert.equal(timeframeToHours({ selected: () => "unknown", range: () => 1 }), 24);
58 Added: });
59 Added:
60 Added: test("tooltipOptions returns independent callback configuration", async () => {
61 Added: const module = await dashboardModule("chart-options.js");
62 Added: const { TOOLTIP_DEFAULTS, tooltipOptions } = module.namespace;
63 Added: const title = () => "";
64 Added: const options = tooltipOptions({ title });
65 Added:
66 Added: assert.equal(options.mode, "index");
67 Added: assert.equal(options.callbacks.title, title);
68 Added: assert.equal(TOOLTIP_DEFAULTS.callbacks, undefined);
69 Added: });
70 Added:
71 Added: test("compact weather scales reclaim secondary-axis width", async () => {
72 Added: const module = await dashboardModule("weather-page.js");
73 Added: const { weatherScaleOptions } = module.namespace;
74 Added: const compact = weatherScaleOptions(true);
75 Added: const desktop = weatherScaleOptions(false);
76 Added:
77 Added: assert.equal(compact.yHumid.display, false);
78 Added: assert.equal(compact.yRain.display, false);
79 Added: assert.equal(compact.yTemp.title.display, false);
80 Added: assert.equal(compact.x.ticks.maxRotation, 0);
81 Added: assert.equal(desktop.yHumid.display, true);
82 Added: assert.equal(desktop.yRain.display, true);
83 Added: });
roles/dashboard/templates/dashboard/weather.html.ep
index 39d99405..7f216515 100644..100644
@@ -15,7 +15,7 @@
15 15 <div class="col-12" id="weather-combined">
16 16 <div class="card">
17 17 %= include 'dashboard/chart-card-header', title => 'Temperature, Humidity &amp; Rainfall', info_id => 'info-weather', info_text => 'Outdoor air temperature (°C) and relative humidity (%) from the Météo France AROME model (1.5 km resolution). Rainfall shown as bars (mm/h). Relevant for estimating greenhouse conditions and water temperature trends.'
18 Removed: <div class="card-body">
18 Added: <div class="card-body p-2 p-sm-3">
19 19 <div class="chart-wrap">
20 20 <canvas id="chart-weather-combined" role="img" aria-label="Temperature, humidity and rainfall over time"></canvas>
21 21 </div>