feat consolidate weather page into single chart

Replace three separate charts (temperature, humidity, rainfall) with one combined multi-axis chart: - Left axis: temperature (°C, red line) - Right axis 1: humidity (%, blue line) - Right axis 2: rainfall (mm, grey bars) Easier to correlate weather patterns at a glance.

Commit
61dec9fd7c463608e3491df3095bb71bb1e02096
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard/weather-page.js
index 26afb1d3..c46e458f 100644..100644
@@ -14,7 +14,7 @@
14 14 caretSize: 0
15 15 };
16 16
17 Removed: let charts = {};
17 Added: let chart = null;
18 18
19 19 async function fetchJson(url) {
20 20 const response = await fetch(url);
@@ -29,158 +29,117 @@
29 29 return Math.round((ms * range) / (60 * 60 * 1000));
30 30 }
31 31
32 Removed: async function loadTemperature(timeframe, signal) {
33 Removed: const canvas = document.getElementById("chart-weather-temperature");
32 Added: async function loadCombined(timeframe) {
33 Added: const canvas = document.getElementById("chart-weather-combined");
34 34 if (!canvas) return;
35 35
36 36 const hours = timeframeToHours(timeframe);
37 37 const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
38 38 const labels = data.points.map(p => formatDateTime(p.timestamp));
39 Removed: const values = data.points.map(p => p.temperature);
39 Added: const temps = data.points.map(p => p.temperature);
40 Added: const humids = data.points.map(p => p.humidity);
41 Added: const rains = data.points.map(p => p.rain || 0);
40 42
41 Removed: if (!charts.temperature) {
42 Removed: charts.temperature = new Chart(canvas, {
43 Added: const datasets = [
44 Added: {
43 45 type: "line",
44 Removed: data: {
45 Removed: labels,
46 Removed: datasets: [{
47 Removed: label: "Temperature (°C)",
48 Removed: data: values,
49 Removed: borderColor: "#dc2626",
50 Removed: backgroundColor: "rgba(220, 38, 38, 0.08)",
51 Removed: tension: 0.3,
52 Removed: pointRadius: 0,
53 Removed: pointHoverRadius: 3,
54 Removed: fill: true
55 Removed: }]
56 Removed: },
57 Removed: options: {
58 Removed: responsive: true,
59 Removed: maintainAspectRatio: false,
60 Removed: animation: false,
61 Removed: scales: {
62 Removed: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
63 Removed: y: { title: { display: true, text: "°C" } }
64 Removed: },
65 Removed: plugins: {
66 Removed: legend: { display: false },
67 Removed: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
68 Removed: }
69 Removed: }
70 Removed: });
71 Removed: } else {
72 Removed: charts.temperature.data.labels = labels;
73 Removed: charts.temperature.data.datasets[0].data = values;
74 Removed: charts.temperature.update();
75 Removed: }
76 Removed: }
77 Removed:
78 Removed: async function loadHumidity(timeframe, signal) {
79 Removed: const canvas = document.getElementById("chart-weather-humidity");
80 Removed: if (!canvas) return;
81 Removed:
82 Removed: const hours = timeframeToHours(timeframe);
83 Removed: const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
84 Removed: const labels = data.points.map(p => formatDateTime(p.timestamp));
85 Removed: const values = data.points.map(p => p.humidity);
86 Removed:
87 Removed: if (!charts.humidity) {
88 Removed: charts.humidity = new Chart(canvas, {
46 Added: label: "Temperature (°C)",
47 Added: data: temps,
48 Added: borderColor: "#dc2626",
49 Added: backgroundColor: "rgba(220, 38, 38, 0.06)",
50 Added: tension: 0.3,
51 Added: pointRadius: 0,
52 Added: pointHoverRadius: 3,
53 Added: yAxisID: "yTemp",
54 Added: fill: true,
55 Added: order: 1
56 Added: },
57 Added: {
89 58 type: "line",
90 Removed: data: {
91 Removed: labels,
92 Removed: datasets: [{
93 Removed: label: "Humidity (%)",
94 Removed: data: values,
95 Removed: borderColor: "#0ea5e9",
96 Removed: backgroundColor: "rgba(14, 165, 233, 0.08)",
97 Removed: tension: 0.3,
98 Removed: pointRadius: 0,
99 Removed: pointHoverRadius: 3,
100 Removed: fill: true
101 Removed: }]
102 Removed: },
103 Removed: options: {
104 Removed: responsive: true,
105 Removed: maintainAspectRatio: false,
106 Removed: animation: false,
107 Removed: scales: {
108 Removed: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
109 Removed: y: { title: { display: true, text: "%" }, min: 0, max: 100 }
110 Removed: },
111 Removed: plugins: {
112 Removed: legend: { display: false },
113 Removed: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
114 Removed: }
115 Removed: }
116 Removed: });
117 Removed: } else {
118 Removed: charts.humidity.data.labels = labels;
119 Removed: charts.humidity.data.datasets[0].data = values;
120 Removed: charts.humidity.update();
121 Removed: }
122 Removed: }
59 Added: label: "Humidity (%)",
60 Added: data: humids,
61 Added: borderColor: "#0ea5e9",
62 Added: backgroundColor: "rgba(14, 165, 233, 0.06)",
63 Added: tension: 0.3,
64 Added: pointRadius: 0,
65 Added: pointHoverRadius: 3,
66 Added: yAxisID: "yHumid",
67 Added: fill: true,
68 Added: order: 2
69 Added: },
70 Added: {
71 Added: type: "bar",
72 Added: label: "Rain (mm)",
73 Added: data: rains,
74 Added: backgroundColor: "rgba(100, 116, 139, 0.4)",
75 Added: borderColor: "#64748b",
76 Added: borderWidth: 1,
77 Added: yAxisID: "yRain",
78 Added: order: 3
79 Added: }
80 Added: ];
123 81
124 Removed: async function loadRain(timeframe, signal) {
125 Removed: const canvas = document.getElementById("chart-weather-rain");
126 Removed: if (!canvas) return;
127 Removed:
128 Removed: const hours = timeframeToHours(timeframe);
129 Removed: const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
130 Removed: const labels = data.points.map(p => formatDateTime(p.timestamp));
131 Removed: const values = data.points.map(p => p.rain || 0);
132 Removed:
133 Removed: if (!charts.rain) {
134 Removed: charts.rain = new Chart(canvas, {
82 Added: if (!chart) {
83 Added: chart = new Chart(canvas, {
135 84 type: "bar",
136 Removed: data: {
137 Removed: labels,
138 Removed: datasets: [{
139 Removed: label: "Rain (mm)",
140 Removed: data: values,
141 Removed: backgroundColor: "rgba(14, 165, 233, 0.5)",
142 Removed: borderColor: "#0ea5e9",
143 Removed: borderWidth: 1
144 Removed: }]
145 Removed: },
85 Added: data: { labels, datasets },
146 86 options: {
147 87 responsive: true,
148 88 maintainAspectRatio: false,
149 89 animation: false,
90 Added: interaction: { mode: "index", intersect: false },
150 91 scales: {
151 Removed: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
152 Removed: y: { beginAtZero: true, title: { display: true, text: "mm" } }
92 Added: x: { ticks: { maxTicksLimit: 10, font: { size: 10 } } },
93 Added: yTemp: {
94 Added: type: "linear",
95 Added: position: "left",
96 Added: title: { display: true, text: "°C", color: "#dc2626" },
97 Added: ticks: { color: "#dc2626", font: { size: 10 } },
98 Added: grid: { display: true }
99 Added: },
100 Added: yHumid: {
101 Added: type: "linear",
102 Added: position: "right",
103 Added: min: 0,
104 Added: max: 100,
105 Added: title: { display: true, text: "%", color: "#0ea5e9" },
106 Added: ticks: { color: "#0ea5e9", font: { size: 10 } },
107 Added: grid: { drawOnChartArea: false }
108 Added: },
109 Added: yRain: {
110 Added: type: "linear",
111 Added: position: "right",
112 Added: beginAtZero: true,
113 Added: title: { display: true, text: "mm", color: "#64748b" },
114 Added: ticks: { color: "#64748b", font: { size: 10 } },
115 Added: grid: { drawOnChartArea: false },
116 Added: // Offset to not overlap with humidity axis
117 Added: afterFit(axis) { axis.paddingLeft = 10; }
118 Added: }
153 119 },
154 120 plugins: {
155 Removed: legend: { display: false },
121 Added: legend: { display: true, labels: { font: { size: 10 } } },
156 122 tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
157 123 }
158 124 }
159 125 });
160 126 } else {
161 Removed: charts.rain.data.labels = labels;
162 Removed: charts.rain.data.datasets[0].data = values;
163 Removed: charts.rain.update();
127 Added: chart.data.labels = labels;
128 Added: chart.data.datasets[0].data = temps;
129 Added: chart.data.datasets[1].data = humids;
130 Added: chart.data.datasets[2].data = rains;
131 Added: chart.update();
164 132 }
165 133 }
166 134
167 135 export function createWeatherCharts() {
168 Removed: const hasCanvas = document.getElementById("chart-weather-temperature");
136 Added: const hasCanvas = document.getElementById("chart-weather-combined");
169 137 if (!hasCanvas) return { reload: () => {} };
170 138
171 139 const timeframe = createTimeframeState();
172 Removed: let controller = null;
173 140
174 141 function loadAll() {
175 Removed: if (controller) controller.abort();
176 Removed: controller = new AbortController();
177 Removed: const signal = controller.signal;
178 Removed:
179 Removed: return Promise.allSettled([
180 Removed: loadTemperature(timeframe, signal),
181 Removed: loadHumidity(timeframe, signal),
182 Removed: loadRain(timeframe, signal)
183 Removed: ]);
142 Added: return loadCombined(timeframe);
184 143 }
185 144
186 145 initialiseGraphControls(timeframe, () => loadAll());
roles/dashboard/t/09-weather.t
index 094f1d07..1530c6a5 100644..100644
@@ -97,9 +97,7 @@
97 97 $t->get_ok('/weather')
98 98 ->status_is(200)
99 99 ->text_is('title', 'FAPG DAQ Weather')
100 Removed: ->element_exists('canvas#chart-weather-temperature')
101 Removed: ->element_exists('canvas#chart-weather-humidity')
102 Removed: ->element_exists('canvas#chart-weather-rain')
100 Added: ->element_exists('canvas#chart-weather-combined')
103 101 ->element_exists('[data-default-timeframe="day"]');
104 102 };
105 103
roles/dashboard/templates/dashboard/weather.html.ep
index 0c3ec69c..39d99405 100644..100644
@@ -12,34 +12,12 @@
12 12 </div>
13 13
14 14 <div class="row g-3">
15 Removed: <div class="col-12" id="weather-temperature">
15 Added: <div class="col-12" id="weather-combined">
16 16 <div class="card">
17 Removed: %= include 'dashboard/chart-card-header', title => 'Temperature', info_id => 'info-weather-temp', info_text => 'Outdoor air temperature at 2m above ground from the Météo France AROME model (1.5 km resolution). Relevant for estimating greenhouse and water temperature trends.'
17 Added: %= 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 18 <div class="card-body">
19 19 <div class="chart-wrap">
20 Removed: <canvas id="chart-weather-temperature" role="img" aria-label="Outdoor temperature over time"></canvas>
21 Removed: </div>
22 Removed: </div>
23 Removed: </div>
24 Removed: </div>
25 Removed:
26 Removed: <div class="col-12" id="weather-humidity">
27 Removed: <div class="card">
28 Removed: %= include 'dashboard/chart-card-header', title => 'Humidity', info_id => 'info-weather-humid', info_text => 'Outdoor relative humidity at 2m. High humidity reduces evaporation and plant transpiration; low humidity increases water loss from open systems.'
29 Removed: <div class="card-body">
30 Removed: <div class="chart-wrap">
31 Removed: <canvas id="chart-weather-humidity" role="img" aria-label="Outdoor relative humidity over time"></canvas>
32 Removed: </div>
33 Removed: </div>
34 Removed: </div>
35 Removed: </div>
36 Removed:
37 Removed: <div class="col-12" id="weather-rain">
38 Removed: <div class="card">
39 Removed: %= include 'dashboard/chart-card-header', title => 'Rainfall', info_id => 'info-weather-rain', info_text => 'Hourly rainfall in mm. Heavy rain can dilute outdoor systems, cause overflow, and affect nutrient balance. Indoor systems are less affected but ambient humidity rises.'
40 Removed: <div class="card-body">
41 Removed: <div class="chart-wrap">
42 Removed: <canvas id="chart-weather-rain" role="img" aria-label="Rainfall over time"></canvas>
20 Added: <canvas id="chart-weather-combined" role="img" aria-label="Temperature, humidity and rainfall over time"></canvas>
43 21 </div>
44 22 </div>
45 23 </div>