feat add solar, cloud, soil, pressure and sun times

Fetch shortwave_radiation, cloud_cover, soil_temperature_6cm, surface_pressure hourly and sunrise/sunset daily from Open-Meteo. Auto-migrate existing weather tables. Add solar/cloud and soil/pressure charts to the weather page. Night shading now uses actual sunrise/sunset instead of fixed 21:00-06:00. Index weather summary includes sky condition for outfit decisions. Rate-limit manual refresh to 1 per 5 minutes (24 timer calls/day vs 10k limit).

Commit
2a86de456246aee6fb07bfd0232ac91ef6cdee61
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/lib/FAPG/DAQ/Dashboard/Controller/Weather.pm
index 4ee503a9..444e7974 100644..100644
@@ -42,25 +42,40 @@
42 42 unless ($latest) {
43 43 return $self->render(
44 44 json => {
45 Removed: temperature => undef,
46 Removed: humidity => undef,
47 Removed: rain => undef,
48 Removed: timestamp => undef
45 Added: temperature => undef,
46 Added: humidity => undef,
47 Added: rain => undef,
48 Added: solar_radiation => undef,
49 Added: cloud_cover => undef,
50 Added: soil_temperature => undef,
51 Added: pressure => undef,
52 Added: sunrise => undef,
53 Added: sunset => undef,
54 Added: timestamp => undef,
49 55 },
50 56 );
51 57 }
52 58
53 59 $self->render(
54 60 json => {
55 Removed: temperature => $latest->{temperature},
56 Removed: humidity => $latest->{humidity},
57 Removed: rain => $latest->{rain},
58 Removed: timestamp => utc_timestamp( $latest->{epoch} ),
61 Added: temperature => $latest->{temperature},
62 Added: humidity => $latest->{humidity},
63 Added: rain => $latest->{rain},
64 Added: solar_radiation => $latest->{solar_radiation},
65 Added: cloud_cover => $latest->{cloud_cover},
66 Added: soil_temperature => $latest->{soil_temperature},
67 Added: pressure => $latest->{pressure},
68 Added: sunrise => $latest->{sunrise},
69 Added: sunset => $latest->{sunset},
70 Added: timestamp => utc_timestamp( $latest->{epoch} ),
59 71 },
60 72 );
61 73 }
62 74
63 Removed: # POST /api/weather/refresh (manual trigger)
75 Added: my $REFRESH_COOLDOWN_SECONDS = 300;
76 Added: my $last_refresh_epoch = 0;
77 Added:
78 Added: # POST /api/weather/refresh (manual trigger, rate-limited)
64 79 sub refresh ($self) {
65 80 my $config = $self->app->config->{weather} // {};
66 81
@@ -73,6 +88,20 @@
73 88 );
74 89 }
75 90
91 Added: my $now = time;
92 Added: my $wait = $REFRESH_COOLDOWN_SECONDS - ( $now - $last_refresh_epoch );
93 Added: if ( $wait > 0 ) {
94 Added: return $self->render(
95 Added: status => 429,
96 Added: json => {
97 Added: status => 'rate_limited',
98 Added: message => "Please wait ${wait}s before refreshing again",
99 Added: retry_after => $wait,
100 Added: },
101 Added: );
102 Added: }
103 Added: $last_refresh_epoch = $now;
104 Added:
76 105 my ( $rows, $stored );
77 106 my $success = eval {
78 107 $rows = $self->app->weather_fetcher->fetch;
@@ -104,10 +133,16 @@
104 133
105 134 sub _format_point ($row) {
106 135 return {
107 Removed: timestamp => utc_timestamp( $row->{epoch} ),
108 Removed: temperature => $row->{temperature},
109 Removed: humidity => $row->{humidity},
110 Removed: rain => $row->{rain},
136 Added: timestamp => utc_timestamp( $row->{epoch} ),
137 Added: temperature => $row->{temperature},
138 Added: humidity => $row->{humidity},
139 Added: rain => $row->{rain},
140 Added: solar_radiation => $row->{solar_radiation},
141 Added: cloud_cover => $row->{cloud_cover},
142 Added: soil_temperature => $row->{soil_temperature},
143 Added: pressure => $row->{pressure},
144 Added: sunrise => $row->{sunrise},
145 Added: sunset => $row->{sunset},
111 146 };
112 147 }
113 148
roles/dashboard/lib/FAPG/DAQ/Dashboard/Model/Weather.pm
index 665bfeb7..0ad60937 100644..100644
@@ -9,17 +9,47 @@
9 9 $self->sqlite->db->query(
10 10 q{
11 11 CREATE TABLE IF NOT EXISTS weather_hourly (
12 Removed: epoch INTEGER PRIMARY KEY,
13 Removed: temperature REAL,
14 Removed: humidity REAL,
15 Removed: rain REAL,
16 Removed: fetched_at TEXT NOT NULL
12 Added: epoch INTEGER PRIMARY KEY,
13 Added: temperature REAL,
14 Added: humidity REAL,
15 Added: rain REAL,
16 Added: solar_radiation REAL,
17 Added: cloud_cover REAL,
18 Added: soil_temperature REAL,
19 Added: pressure REAL,
20 Added: sunrise TEXT,
21 Added: sunset TEXT,
22 Added: fetched_at TEXT NOT NULL
17 23 )
18 24 }
19 25 );
26 Added: $self->_migrate_columns;
20 27 return $self;
21 28 }
22 29
30 Added: sub _migrate_columns ($self) {
31 Added: my $db = $self->sqlite->db;
32 Added: my $info = $db->query('PRAGMA table_info(weather_hourly)')->hashes;
33 Added: my %cols = map { $_->{name} => 1 } @$info;
34 Added:
35 Added: my @new = (
36 Added: [ solar_radiation => 'REAL' ],
37 Added: [ cloud_cover => 'REAL' ],
38 Added: [ soil_temperature => 'REAL' ],
39 Added: [ pressure => 'REAL' ],
40 Added: [ sunrise => 'TEXT' ],
41 Added: [ sunset => 'TEXT' ],
42 Added: );
43 Added:
44 Added: for my $col (@new) {
45 Added: next if $cols{ $col->[0] };
46 Added: $db->query(
47 Added: "ALTER TABLE weather_hourly ADD COLUMN $col->[0] $col->[1]");
48 Added: }
49 Added:
50 Added: return;
51 Added: }
52 Added:
23 53 sub store_batch ( $self, $rows ) {
24 54 return unless $rows && @$rows;
25 55
@@ -30,12 +60,20 @@
30 60 for my $row (@$rows) {
31 61 $db->query(
32 62 q{INSERT OR REPLACE INTO weather_hourly
33 Removed: (epoch, temperature, humidity, rain, fetched_at)
34 Removed: VALUES (?, ?, ?, ?, ?)},
63 Added: (epoch, temperature, humidity, rain,
64 Added: solar_radiation, cloud_cover, soil_temperature, pressure,
65 Added: sunrise, sunset, fetched_at)
66 Added: VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)},
35 67 $row->{epoch},
36 68 $row->{temperature},
37 69 $row->{humidity},
38 70 $row->{rain},
71 Added: $row->{solar_radiation},
72 Added: $row->{cloud_cover},
73 Added: $row->{soil_temperature},
74 Added: $row->{pressure},
75 Added: $row->{sunrise},
76 Added: $row->{sunset},
39 77 $fetched_at,
40 78 );
41 79 }
@@ -46,7 +84,9 @@
46 84
47 85 sub get_range ( $self, $from_epoch, $to_epoch ) {
48 86 return $self->sqlite->db->query(
49 Removed: q{SELECT epoch, temperature, humidity, rain
87 Added: q{SELECT epoch, temperature, humidity, rain,
88 Added: solar_radiation, cloud_cover, soil_temperature, pressure,
89 Added: sunrise, sunset
50 90 FROM weather_hourly
51 91 WHERE epoch >= ? AND epoch <= ?
52 92 ORDER BY epoch},
@@ -57,7 +97,9 @@
57 97
58 98 sub get_forecast ( $self, $from_epoch ) {
59 99 return $self->sqlite->db->query(
60 Removed: q{SELECT epoch, temperature, humidity, rain
100 Added: q{SELECT epoch, temperature, humidity, rain,
101 Added: solar_radiation, cloud_cover, soil_temperature, pressure,
102 Added: sunrise, sunset
61 103 FROM weather_hourly
62 104 WHERE epoch >= ?
63 105 ORDER BY epoch},
@@ -67,7 +109,9 @@
67 109
68 110 sub latest_at ( $self, $epoch ) {
69 111 return $self->sqlite->db->query(
70 Removed: q{SELECT epoch, temperature, humidity, rain
112 Added: q{SELECT epoch, temperature, humidity, rain,
113 Added: solar_radiation, cloud_cover, soil_temperature, pressure,
114 Added: sunrise, sunset
71 115 FROM weather_hourly
72 116 WHERE epoch <= ?
73 117 ORDER BY epoch DESC
@@ -78,7 +122,9 @@
78 122
79 123 sub latest ($self) {
80 124 return $self->sqlite->db->query(
81 Removed: q{SELECT epoch, temperature, humidity, rain
125 Added: q{SELECT epoch, temperature, humidity, rain,
126 Added: solar_radiation, cloud_cover, soil_temperature, pressure,
127 Added: sunrise, sunset
82 128 FROM weather_hourly
83 129 ORDER BY epoch DESC
84 130 LIMIT 1},
roles/dashboard/lib/FAPG/DAQ/Dashboard/Service/WeatherFetcher.pm
index 1a9736c2..a839e82b 100644..100644
@@ -15,9 +15,14 @@
15 15 sub fetch ($self) {
16 16 my $url = Mojo::URL->new(BASE_URL);
17 17 $url->query(
18 Removed: latitude => $self->latitude,
19 Removed: longitude => $self->longitude,
20 Removed: hourly => 'temperature_2m,relative_humidity_2m,rain',
18 Added: latitude => $self->latitude,
19 Added: longitude => $self->longitude,
20 Added: hourly => join( ',',
21 Added: 'temperature_2m', 'relative_humidity_2m',
22 Added: 'rain', 'shortwave_radiation',
23 Added: 'cloud_cover', 'soil_temperature_6cm',
24 Added: 'surface_pressure', ),
25 Added: daily => 'sunrise,sunset',
21 26 past_days => 7,
22 27 forecast_days => 2,
23 28 timezone => 'UTC',
@@ -39,23 +44,50 @@
39 44 sub _parse_response ( $self, $json ) {
40 45 return [] unless $json && $json->{hourly};
41 46
42 Removed: my $hourly = $json->{hourly};
43 Removed: my $times = $hourly->{time} || [];
44 Removed: my $temps = $hourly->{temperature_2m} || [];
45 Removed: my $humids = $hourly->{relative_humidity_2m} || [];
46 Removed: my $rains = $hourly->{rain} || [];
47 Added: my $hourly = $json->{hourly};
48 Added: my $times = $hourly->{time} || [];
49 Added: my $temps = $hourly->{temperature_2m} || [];
50 Added: my $humids = $hourly->{relative_humidity_2m} || [];
51 Added: my $rains = $hourly->{rain} || [];
52 Added: my $radiation = $hourly->{shortwave_radiation} || [];
53 Added: my $clouds = $hourly->{cloud_cover} || [];
54 Added: my $soil_temp = $hourly->{soil_temperature_6cm} || [];
55 Added: my $pressure = $hourly->{surface_pressure} || [];
47 56
57 Added: # Build a date→sunrise/sunset lookup from daily data
58 Added: my %sun;
59 Added: if ( $json->{daily} ) {
60 Added: my $dates = $json->{daily}{time} || [];
61 Added: my $sunrises = $json->{daily}{sunrise} || [];
62 Added: my $sunsets = $json->{daily}{sunset} || [];
63 Added: for my $i ( 0 .. $#$dates ) {
64 Added: $sun{ $dates->[$i] } = {
65 Added: sunrise => $sunrises->[$i],
66 Added: sunset => $sunsets->[$i],
67 Added: };
68 Added: }
69 Added: }
70 Added:
48 71 my @rows;
49 72 for my $i ( 0 .. $#$times ) {
50 73 my $epoch = _iso_to_epoch( $times->[$i] );
51 74 next unless defined $epoch;
52 75
76 Added: my $date = substr( $times->[$i], 0, 10 );
77 Added: my $day = $sun{$date} // {};
78 Added:
53 79 push @rows,
54 80 {
55 Removed: epoch => $epoch,
56 Removed: temperature => $temps->[$i],
57 Removed: humidity => $humids->[$i],
58 Removed: rain => $rains->[$i],
81 Added: epoch => $epoch,
82 Added: temperature => $temps->[$i],
83 Added: humidity => $humids->[$i],
84 Added: rain => $rains->[$i],
85 Added: solar_radiation => $radiation->[$i],
86 Added: cloud_cover => $clouds->[$i],
87 Added: soil_temperature => $soil_temp->[$i],
88 Added: pressure => $pressure->[$i],
89 Added: sunrise => $day->{sunrise},
90 Added: sunset => $day->{sunset},
59 91 };
60 92 }
61 93
roles/dashboard/public/js/dashboard/weather-forecast.js
index d41c929d..a5216d1c 100644..100644
@@ -28,13 +28,28 @@
28 28
29 29 const temps = dayPoints.map(p => p.temperature).filter(t => t != null);
30 30 const rains = dayPoints.map(p => p.rain || 0);
31 Added: const clouds = dayPoints.map(p => p.cloud_cover).filter(c => c != null);
31 32 const totalRain = rains.reduce((a, b) => a + b, 0);
32 33 const minTemp = Math.round(Math.min(...temps));
33 34 const maxTemp = Math.round(Math.max(...temps));
35 Added: const avgCloud = clouds.length ? Math.round(clouds.reduce((a, b) => a + b, 0) / clouds.length) : null;
34 36
35 37 const label = now.getHours() >= 16 ? "Tomorrow" : "Today";
38 Added:
39 Added: // Build a natural sentence with outfit-relevant info
36 40 let sentence = `${label} ${minTemp}–${maxTemp}°C`;
37 Removed: if (totalRain > 0.5) {
41 Added:
42 Added: // Sky condition
43 Added: if (avgCloud !== null) {
44 Added: if (avgCloud < 25) sentence += ", sunny";
45 Added: else if (avgCloud < 60) sentence += ", partly cloudy";
46 Added: else sentence += ", overcast";
47 Added: }
48 Added:
49 Added: // Rain
50 Added: if (totalRain > 5) {
51 Added: sentence += `, heavy rain (${totalRain.toFixed(0)} mm).`;
52 Added: } else if (totalRain > 0.5) {
38 53 sentence += `, ${totalRain.toFixed(1)} mm rain.`;
39 54 } else {
40 55 sentence += ", dry.";
@@ -43,53 +58,69 @@
43 58 summaryEl.textContent = sentence;
44 59 }
45 60
46 Removed: // Chart.js plugin to shade night periods (21:00–06:00)
61 Added: // Chart.js plugin to shade night periods using sunrise/sunset data
47 62 const nightShadePlugin = {
48 63 id: "nightShade",
49 64 beforeDraw(chart) {
50 65 const { ctx, chartArea, scales } = chart;
51 66 const xScale = scales.x;
52 67 const timestamps = chart._weatherTimestamps;
68 Added: const sunEvents = chart._weatherSunEvents;
53 69 if (!xScale || !timestamps || !timestamps.length) return;
54 70
55 Removed: ctx.save();
56 Removed: ctx.fillStyle = "rgba(100, 116, 139, 0.14)";
71 Added: // Build night intervals from sunrise/sunset pairs
72 Added: const nights = [];
73 Added: if (sunEvents && sunEvents.length) {
74 Added: // Each point carries the sunrise/sunset for its day
75 Added: let nightStart = null;
76 Added: for (let i = 0; i < timestamps.length; i++) {
77 Added: const t = timestamps[i];
78 Added: const sun = sunEvents[i];
79 Added: if (!sun || !sun.sunrise || !sun.sunset) continue;
80 Added: const sunrise = new Date(sun.sunrise).getTime();
81 Added: const sunset = new Date(sun.sunset).getTime();
82 Added: const isNight = t < sunrise || t >= sunset;
57 83
58 Removed: let inNight = false;
59 Removed: let nightStart = 0;
60 Removed:
61 Removed: for (let i = 0; i <= timestamps.length; i++) {
62 Removed: const hour = i < timestamps.length ? new Date(timestamps[i]).getHours() : -1;
63 Removed: const isNight = hour >= 21 || (hour >= 0 && hour < 6);
64 Removed:
65 Removed: if (isNight && !inNight) {
66 Removed: nightStart = i;
67 Removed: inNight = true;
68 Removed: } else if (!isNight && inNight) {
69 Removed: // Draw the night block
70 Removed: const x1 = xScale.getPixelForValue(nightStart);
71 Removed: const x2 = xScale.getPixelForValue(i);
72 Removed: ctx.fillRect(
73 Removed: Math.max(x1, chartArea.left),
74 Removed: chartArea.top,
75 Removed: Math.min(x2, chartArea.right) - Math.max(x1, chartArea.left),
76 Removed: chartArea.bottom - chartArea.top
77 Removed: );
78 Removed: inNight = false;
84 Added: if (isNight && nightStart === null) {
85 Added: nightStart = i;
86 Added: } else if (!isNight && nightStart !== null) {
87 Added: nights.push([nightStart, i]);
88 Added: nightStart = null;
89 Added: }
79 90 }
91 Added: if (nightStart !== null) {
92 Added: nights.push([nightStart, timestamps.length]);
93 Added: }
94 Added: } else {
95 Added: // Fallback to fixed 21:00–06:00 if no sun data
96 Added: let nightStart = null;
97 Added: for (let i = 0; i <= timestamps.length; i++) {
98 Added: const hour = i < timestamps.length ? new Date(timestamps[i]).getHours() : -1;
99 Added: const isNight = hour >= 21 || (hour >= 0 && hour < 6);
100 Added: if (isNight && nightStart === null) {
101 Added: nightStart = i;
102 Added: } else if (!isNight && nightStart !== null) {
103 Added: nights.push([nightStart, i]);
104 Added: nightStart = null;
105 Added: }
106 Added: }
107 Added: if (nightStart !== null) {
108 Added: nights.push([nightStart, timestamps.length]);
109 Added: }
80 110 }
81 111
82 Removed: // If still in night at the end
83 Removed: if (inNight) {
84 Removed: const x1 = xScale.getPixelForValue(nightStart);
112 Added: ctx.save();
113 Added: ctx.fillStyle = "rgba(100, 116, 139, 0.14)";
114 Added: for (const [start, end] of nights) {
115 Added: const x1 = xScale.getPixelForValue(start);
116 Added: const x2 = xScale.getPixelForValue(Math.min(end, timestamps.length - 1));
85 117 ctx.fillRect(
86 118 Math.max(x1, chartArea.left),
87 119 chartArea.top,
88 Removed: chartArea.right - Math.max(x1, chartArea.left),
120 Added: Math.min(x2, chartArea.right) - Math.max(x1, chartArea.left),
89 121 chartArea.bottom - chartArea.top
90 122 );
91 123 }
92 Removed:
93 124 ctx.restore();
94 125 }
95 126 };
@@ -247,13 +278,21 @@
247 278 },
248 279 plugins: [nightShadePlugin, nowLinePlugin]
249 280 });
250 Removed: // Store timestamps for the now-line plugin
281 Added: // Store timestamps and sun events for plugins
251 282 chart._weatherTimestamps = timestamps;
283 Added: chart._weatherSunEvents = points.map(p => ({
284 Added: sunrise: p.sunrise,
285 Added: sunset: p.sunset
286 Added: }));
252 287 } else {
253 288 chart.data.labels = labels;
254 289 chart.data.datasets[0].data = temps;
255 290 chart.data.datasets[1].data = rains;
256 291 chart._weatherTimestamps = timestamps;
292 Added: chart._weatherSunEvents = points.map(p => ({
293 Added: sunrise: p.sunrise,
294 Added: sunset: p.sunset
295 Added: }));
257 296 chart.update();
258 297 }
259 298 }
roles/dashboard/public/js/dashboard/weather-page.js
index 32429a29..de7c8280 100644..100644
@@ -4,58 +4,25 @@
4 4 import { createTimeframeState, timeframeToHours } from "./timeframe.js";
5 5 import { initialiseGraphControls } from "./graph-controls.js";
6 6
7 Removed: let chart = null;
7 Added: let charts = {};
8 8
9 Removed:
10 Removed: export function weatherScaleOptions(compact) {
11 Removed: return {
12 Removed: x: {
13 Removed: ticks: {
14 Removed: maxTicksLimit: compact ? 6 : 10,
15 Removed: maxRotation: compact ? 0 : 45,
16 Removed: minRotation: 0,
17 Removed: font: { size: 10 }
18 Removed: }
19 Removed: },
20 Removed: yTemp: {
21 Removed: type: "linear",
22 Removed: position: "left",
23 Removed: title: { display: !compact, text: "°C", color: "#dc2626" },
24 Removed: ticks: { color: "#dc2626", font: { size: 10 } },
25 Removed: grid: { display: true }
26 Removed: },
27 Removed: yHumid: {
28 Removed: type: "linear",
29 Removed: position: "right",
30 Removed: display: !compact,
31 Removed: min: 0,
32 Removed: max: 100,
33 Removed: title: { display: true, text: "%", color: "#0ea5e9" },
34 Removed: ticks: { color: "#0ea5e9", font: { size: 10 } },
35 Removed: grid: { drawOnChartArea: false }
36 Removed: },
37 Removed: yRain: {
38 Removed: type: "linear",
39 Removed: position: "right",
40 Removed: display: !compact,
41 Removed: beginAtZero: true,
42 Removed: title: { display: true, text: "mm", color: "#64748b" },
43 Removed: ticks: { color: "#64748b", font: { size: 10 } },
44 Removed: grid: { drawOnChartArea: false },
45 Removed: afterFit(axis) { axis.paddingLeft = 10; }
46 Removed: }
47 Removed: };
48 Removed: }
49 Removed: async function loadCombined(timeframe) {
50 Removed: const canvas = document.getElementById("chart-weather-combined");
51 Removed: if (!canvas) return;
52 Removed:
9 Added: async function loadAll(timeframe) {
53 10 const hours = timeframeToHours(timeframe);
54 11 const data = await fetchWeatherHourly(hours);
55 12 const labels = data.points.map(point =>
56 13 formatChartTime(point.timestamp, timeframe.selected())
57 14 );
58 15 const compact = window.matchMedia("(max-width: 575.98px)").matches;
16 Added:
17 Added: loadCombined(data, labels, compact);
18 Added: loadSolar(data, labels, compact);
19 Added: loadGround(data, labels, compact);
20 Added: }
21 Added:
22 Added: function loadCombined(data, labels, compact) {
23 Added: const canvas = document.getElementById("chart-weather-combined");
24 Added: if (!canvas) return;
25 Added:
59 26 const temps = data.points.map(p => p.temperature);
60 27 const humids = data.points.map(p => p.humidity);
61 28 const rains = data.points.map(p => p.rain || 0);
@@ -99,8 +66,8 @@
99 66 }
100 67 ];
101 68
102 Removed: if (!chart) {
103 Removed: chart = new Chart(canvas, {
69 Added: if (!charts.combined) {
70 Added: charts.combined = new Chart(canvas, {
104 71 type: "bar",
105 72 data: { labels, datasets },
106 73 options: {
@@ -110,37 +77,198 @@
110 77 interaction: { mode: "index", intersect: false },
111 78 scales: weatherScaleOptions(compact),
112 79 plugins: {
113 Removed: legend: {
114 Removed: display: true,
115 Removed: labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } }
80 Added: legend: { display: true, labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } } },
81 Added: tooltip: tooltipOptions({ title: () => "" })
82 Added: }
83 Added: }
84 Added: });
85 Added: } else {
86 Added: charts.combined.data.labels = labels;
87 Added: charts.combined.data.datasets[0].data = temps;
88 Added: charts.combined.data.datasets[1].data = humids;
89 Added: charts.combined.data.datasets[2].data = rains;
90 Added: charts.combined.update();
91 Added: }
92 Added: }
93 Added:
94 Added: function loadSolar(data, labels, compact) {
95 Added: const canvas = document.getElementById("chart-weather-solar");
96 Added: if (!canvas) return;
97 Added:
98 Added: const radiation = data.points.map(p => p.solar_radiation || 0);
99 Added: const clouds = data.points.map(p => p.cloud_cover);
100 Added:
101 Added: const datasets = [
102 Added: {
103 Added: type: "line",
104 Added: label: "Solar radiation (W/m²)",
105 Added: data: radiation,
106 Added: borderColor: "#f59e0b",
107 Added: backgroundColor: "rgba(245, 158, 11, 0.08)",
108 Added: tension: 0.3,
109 Added: pointRadius: 0,
110 Added: pointHoverRadius: 3,
111 Added: yAxisID: "yRadiation",
112 Added: fill: true,
113 Added: order: 1
114 Added: },
115 Added: {
116 Added: type: "line",
117 Added: label: "Cloud cover (%)",
118 Added: data: clouds,
119 Added: borderColor: "#64748b",
120 Added: backgroundColor: "rgba(100, 116, 139, 0.06)",
121 Added: tension: 0.3,
122 Added: pointRadius: 0,
123 Added: pointHoverRadius: 3,
124 Added: yAxisID: "yCloud",
125 Added: fill: true,
126 Added: order: 2
127 Added: }
128 Added: ];
129 Added:
130 Added: if (!charts.solar) {
131 Added: charts.solar = new Chart(canvas, {
132 Added: type: "line",
133 Added: data: { labels, datasets },
134 Added: options: {
135 Added: responsive: true,
136 Added: maintainAspectRatio: false,
137 Added: animation: false,
138 Added: interaction: { mode: "index", intersect: false },
139 Added: scales: {
140 Added: x: { ticks: { maxTicksLimit: compact ? 6 : 10, maxRotation: compact ? 0 : 45, minRotation: 0, font: { size: 10 } } },
141 Added: yRadiation: {
142 Added: type: "linear",
143 Added: position: "left",
144 Added: beginAtZero: true,
145 Added: title: { display: !compact, text: "W/m²", color: "#f59e0b" },
146 Added: ticks: { color: "#f59e0b", font: { size: 10 } },
147 Added: grid: { display: true }
116 148 },
149 Added: yCloud: {
150 Added: type: "linear",
151 Added: position: "right",
152 Added: display: !compact,
153 Added: min: 0,
154 Added: max: 100,
155 Added: title: { display: true, text: "%", color: "#64748b" },
156 Added: ticks: { color: "#64748b", font: { size: 10 } },
157 Added: grid: { drawOnChartArea: false }
158 Added: }
159 Added: },
160 Added: plugins: {
161 Added: legend: { display: true, labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } } },
117 162 tooltip: tooltipOptions({ title: () => "" })
118 163 }
119 164 }
120 165 });
121 166 } else {
122 Removed: chart.data.labels = labels;
123 Removed: chart.data.datasets[0].data = temps;
124 Removed: chart.data.datasets[1].data = humids;
125 Removed: chart.data.datasets[2].data = rains;
126 Removed: chart.update();
167 Added: charts.solar.data.labels = labels;
168 Added: charts.solar.data.datasets[0].data = radiation;
169 Added: charts.solar.data.datasets[1].data = clouds;
170 Added: charts.solar.update();
127 171 }
128 172 }
129 173
174 Added: function loadGround(data, labels, compact) {
175 Added: const canvas = document.getElementById("chart-weather-ground");
176 Added: if (!canvas) return;
177 Added:
178 Added: const soilTemp = data.points.map(p => p.soil_temperature);
179 Added: const pressure = data.points.map(p => p.pressure);
180 Added:
181 Added: const datasets = [
182 Added: {
183 Added: type: "line",
184 Added: label: "Soil temp (°C)",
185 Added: data: soilTemp,
186 Added: borderColor: "#854d0e",
187 Added: backgroundColor: "rgba(133, 77, 14, 0.08)",
188 Added: tension: 0.3,
189 Added: pointRadius: 0,
190 Added: pointHoverRadius: 3,
191 Added: yAxisID: "ySoil",
192 Added: fill: true,
193 Added: order: 1
194 Added: },
195 Added: {
196 Added: type: "line",
197 Added: label: "Pressure (hPa)",
198 Added: data: pressure,
199 Added: borderColor: "#7c3aed",
200 Added: backgroundColor: "rgba(124, 58, 237, 0.06)",
201 Added: tension: 0.3,
202 Added: pointRadius: 0,
203 Added: pointHoverRadius: 3,
204 Added: yAxisID: "yPressure",
205 Added: fill: true,
206 Added: order: 2
207 Added: }
208 Added: ];
209 Added:
210 Added: if (!charts.ground) {
211 Added: charts.ground = new Chart(canvas, {
212 Added: type: "line",
213 Added: data: { labels, datasets },
214 Added: options: {
215 Added: responsive: true,
216 Added: maintainAspectRatio: false,
217 Added: animation: false,
218 Added: interaction: { mode: "index", intersect: false },
219 Added: scales: {
220 Added: x: { ticks: { maxTicksLimit: compact ? 6 : 10, maxRotation: compact ? 0 : 45, minRotation: 0, font: { size: 10 } } },
221 Added: ySoil: {
222 Added: type: "linear",
223 Added: position: "left",
224 Added: title: { display: !compact, text: "°C", color: "#854d0e" },
225 Added: ticks: { color: "#854d0e", font: { size: 10 } },
226 Added: grid: { display: true }
227 Added: },
228 Added: yPressure: {
229 Added: type: "linear",
230 Added: position: "right",
231 Added: display: !compact,
232 Added: title: { display: true, text: "hPa", color: "#7c3aed" },
233 Added: ticks: { color: "#7c3aed", font: { size: 10 } },
234 Added: grid: { drawOnChartArea: false }
235 Added: }
236 Added: },
237 Added: plugins: {
238 Added: legend: { display: true, labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } } },
239 Added: tooltip: tooltipOptions({ title: () => "" })
240 Added: }
241 Added: }
242 Added: });
243 Added: } else {
244 Added: charts.ground.data.labels = labels;
245 Added: charts.ground.data.datasets[0].data = soilTemp;
246 Added: charts.ground.data.datasets[1].data = pressure;
247 Added: charts.ground.update();
248 Added: }
249 Added: }
250 Added:
251 Added: export function weatherScaleOptions(compact) {
252 Added: return {
253 Added: x: { ticks: { maxTicksLimit: compact ? 6 : 10, maxRotation: compact ? 0 : 45, minRotation: 0, font: { size: 10 } } },
254 Added: yTemp: { type: "linear", position: "left", title: { display: !compact, text: "°C", color: "#dc2626" }, ticks: { color: "#dc2626", font: { size: 10 } }, grid: { display: true } },
255 Added: yHumid: { type: "linear", position: "right", display: !compact, min: 0, max: 100, title: { display: true, text: "%", color: "#0ea5e9" }, ticks: { color: "#0ea5e9", font: { size: 10 } }, grid: { drawOnChartArea: false } },
256 Added: yRain: { type: "linear", position: "right", display: !compact, beginAtZero: true, title: { display: true, text: "mm", color: "#64748b" }, ticks: { color: "#64748b", font: { size: 10 } }, grid: { drawOnChartArea: false }, afterFit(axis) { axis.paddingLeft = 10; } }
257 Added: };
258 Added: }
259 Added:
130 260 export function createWeatherCharts() {
131 261 const hasCanvas = document.getElementById("chart-weather-combined");
132 262 if (!hasCanvas) return { reload: () => {} };
133 263
134 264 const timeframe = createTimeframeState();
135 265
136 Removed: function loadAll() {
137 Removed: return loadCombined(timeframe);
266 Added: function refresh() {
267 Added: return loadAll(timeframe);
138 268 }
139 269
140 Removed: initialiseGraphControls(timeframe, () => loadAll());
141 Removed: loadAll();
270 Added: initialiseGraphControls(timeframe, () => refresh());
271 Added: refresh();
142 272
143 Removed: return {
144 Removed: reload() { loadAll(); }
145 Removed: };
273 Added: return { reload() { refresh(); } };
146 274 }
roles/dashboard/t/09-weather.t
index 850381bb..580ff63c 100644..100644
@@ -139,6 +139,8 @@
139 139 ->status_is(200)
140 140 ->text_is( 'title', 'FAPG DAQ Weather' )
141 141 ->element_exists('canvas#chart-weather-combined')
142 Added: ->element_exists('canvas#chart-weather-solar')
143 Added: ->element_exists('canvas#chart-weather-ground')
142 144 ->element_exists('[data-default-timeframe="day"]');
143 145 };
144 146
roles/dashboard/templates/dashboard/weather.html.ep
index fb95db58..7a3ff42c 100644..100644
@@ -18,5 +18,27 @@
18 18 </div>
19 19 </div>
20 20 </div>
21 Added:
22 Added: <div class="col-12" id="weather-solar">
23 Added: <div class="card">
24 Added: %= include 'dashboard/chart-card-header', title => 'Solar Radiation &amp; Cloud Cover', info_id => 'info-solar', info_text => 'Shortwave solar radiation (W/m²) drives photosynthesis and greenhouse heating. Cloud cover (%) indicates how much light reaches the plants. Correlates with dissolved oxygen production in the fish tanks.'
25 Added: <div class="card-body p-2 p-sm-3">
26 Added: <div class="chart-wrap">
27 Added: <canvas id="chart-weather-solar" role="img" aria-label="Solar radiation and cloud cover over time"></canvas>
28 Added: </div>
29 Added: </div>
30 Added: </div>
31 Added: </div>
32 Added:
33 Added: <div class="col-12" id="weather-ground">
34 Added: <div class="card">
35 Added: %= include 'dashboard/chart-card-header', title => 'Soil Temperature &amp; Atmospheric Pressure', info_id => 'info-ground', info_text => 'Soil temperature at 6 cm depth (°C) is relevant for outdoor root vegetables and predicting water temperature lag. Atmospheric pressure (hPa) drops correlate with incoming weather fronts and can affect fish behaviour.'
36 Added: <div class="card-body p-2 p-sm-3">
37 Added: <div class="chart-wrap">
38 Added: <canvas id="chart-weather-ground" role="img" aria-label="Soil temperature and atmospheric pressure over time"></canvas>
39 Added: </div>
40 Added: </div>
41 Added: </div>
42 Added: </div>
21 43 </div>
22 44 </section>