[Perl] DAQ system for the FAPG.
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.
Changed files
roles/dashboard/public/js/dashboard/weather-page.js
@@ -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
@@ -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
@@ -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
@@ -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 & 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>