feat integrate weather data from Open-Meteo

Add outdoor weather monitoring using Météo France AROME model via Open-Meteo API (no key required). Fetches hourly temperature, humidity, and rainfall for Versonnex (46.303°N, 6.098°E). Backend: - Weather model (SQLite weather_hourly table, epoch-keyed) - WeatherFetcher service (Mojo::UserAgent → Open-Meteo) - Periodic fetch on startup + hourly recurring timer - API endpoints: /api/weather/hourly, /forecast, /current, /refresh Frontend: - Index page: forecast card with current conditions + 48h chart - Dedicated /weather page with temperature, humidity, rain charts - Insights: outdoor temperature overlay on DO/ORP chart - Weather nav link added to navbar Configuration in dashboard.yml (weather.enabled, lat/lng). 10 test subtests covering model, API, and page rendering.

Commit
3381eebb8f9b632d85a6a74c5b02b423d98e292d
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/dashboard.yml
index 4cdc2f28..eeab7de9 100644..100644
@@ -1,3 +1,7 @@
1 1 ---
2 2 secrets:
3 3 - CHANGEME
4 Added: weather:
5 Added: enabled: 1
6 Added: latitude: 46.303
7 Added: longitude: 6.098
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index a49b8158..900a6f80 100644..100644
@@ -2,6 +2,8 @@
2 2 use Mojo::Base 'Mojolicious', -signatures;
3 3
4 4 use Mojo::SQLite;
5 Added: use FAPG::DAQ::Dashboard::Model::Weather;
6 Added: use FAPG::DAQ::Dashboard::Service::WeatherFetcher;
5 7
6 8 # This method will run once at server start
7 9 sub startup ($self) {
@@ -29,6 +31,40 @@
29 31
30 32 $self->helper( sqlite => sub {$sqlite} );
31 33
34 Added: # Weather model and fetcher
35 Added: my $weather_model = FAPG::DAQ::Dashboard::Model::Weather->new( sqlite => $sqlite );
36 Added: $weather_model->create_table;
37 Added:
38 Added: $self->helper( weather => sub {$weather_model} );
39 Added:
40 Added: my $weather_config = $config->{weather} // {};
41 Added: my $weather_fetcher = FAPG::DAQ::Dashboard::Service::WeatherFetcher->new(
42 Added: latitude => $weather_config->{latitude} // 46.303,
43 Added: longitude => $weather_config->{longitude} // 6.098,
44 Added: log => $self->log,
45 Added: );
46 Added:
47 Added: $self->helper( weather_fetcher => sub {$weather_fetcher} );
48 Added:
49 Added: # Schedule periodic weather fetch (only under a real server, not tests)
50 Added: if ( $weather_config->{enabled} && !$ENV{FAPG_DAQ_DB} ) {
51 Added: require Mojo::IOLoop;
52 Added: Mojo::IOLoop->timer(
53 Added: 5 => sub {
54 Added: my $rows = $weather_fetcher->fetch;
55 Added: $weather_model->store_batch($rows);
56 Added: $self->log->info( 'Weather: initial fetch stored ' . scalar(@$rows) . ' points' );
57 Added: }
58 Added: );
59 Added: Mojo::IOLoop->recurring(
60 Added: 3600 => sub {
61 Added: my $rows = $weather_fetcher->fetch;
62 Added: $weather_model->store_batch($rows);
63 Added: $self->log->info( 'Weather: periodic fetch stored ' . scalar(@$rows) . ' points' );
64 Added: }
65 Added: );
66 Added: }
67 Added:
32 68 $self->helper(
33 69 probes => sub {
34 70 return [
@@ -87,6 +123,12 @@
87 123 $r->get('/api/insights/diurnal/:probe')->to('Insights#diurnal');
88 124 $r->get('/api/insights/derivatives')->to('Insights#derivatives');
89 125 $r->get('/api/insights/stability')->to('Insights#stability');
126 Added:
127 Added: $r->get('/weather')->to('pages#weather');
128 Added: $r->get('/api/weather/hourly')->to('Weather#hourly');
129 Added: $r->get('/api/weather/forecast')->to('Weather#forecast');
130 Added: $r->get('/api/weather/current')->to('Weather#current');
131 Added: $r->get('/api/weather/refresh')->to('Weather#refresh');
90 132 }
91 133
92 134 1;
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Pages.pm
index 7ded1a66..051e6b95 100644..100644
@@ -77,6 +77,15 @@
77 77 );
78 78 }
79 79
80 Added: sub weather ($self) {
81 Added: $self->render(
82 Added: template => 'dashboard/weather',
83 Added: nav_page => 'weather',
84 Added: breadcrumbs =>
85 Added: [ { label => 'Home', href => '/' }, { label => 'Weather' }, ],
86 Added: );
87 Added: }
88 Added:
80 89 sub graph ($self) {
81 90 my $probe_key = $self->stash('probe');
82 91 my ($probe) = grep { $_->{key} eq $probe_key } $self->probes->@*;
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Weather.pm
index 00000000..3f8b6d48 000000..100644
@@ -0,0 +1,95 @@
1 Added: package FAPG::DAQ::Dashboard::Controller::Weather;
2 Added: use Mojo::Base 'Mojolicious::Controller', -signatures;
3 Added:
4 Added: use POSIX qw(strftime);
5 Added:
6 Added: # GET /api/weather/hourly?hours=168
7 Added: sub hourly ($self) {
8 Added: my $hours = $self->param('hours') // 168;
9 Added: $hours = 168 unless $hours =~ /\A\d+\z/ && $hours >= 1 && $hours <= 720;
10 Added:
11 Added: my $now = time;
12 Added: my $from = $now - ( $hours * 3600 );
13 Added: my $points = $self->weather->get_range( $from, $now );
14 Added: my @formatted = map { _format_point($_) } @$points;
15 Added:
16 Added: $self->render(
17 Added: json => {
18 Added: hours => 0 + $hours,
19 Added: points => \@formatted,
20 Added: },
21 Added: );
22 Added: }
23 Added:
24 Added: # GET /api/weather/forecast
25 Added: sub forecast ($self) {
26 Added: my $now = time;
27 Added: my $from = $now - ( 24 * 3600 ); # Include past 24h for context
28 Added: my $points = $self->weather->get_forecast($from);
29 Added: my @formatted = map { _format_point($_) } @$points;
30 Added:
31 Added: $self->render(
32 Added: json => {
33 Added: from => _utc_timestamp($from),
34 Added: points => \@formatted,
35 Added: },
36 Added: );
37 Added: }
38 Added:
39 Added: # GET /api/weather/current
40 Added: sub current ($self) {
41 Added: my $latest = $self->weather->latest;
42 Added:
43 Added: unless ($latest) {
44 Added: return $self->render(
45 Added: json => { temperature => undef, humidity => undef, rain => undef, timestamp => undef },
46 Added: );
47 Added: }
48 Added:
49 Added: $self->render(
50 Added: json => {
51 Added: temperature => $latest->{temperature},
52 Added: humidity => $latest->{humidity},
53 Added: rain => $latest->{rain},
54 Added: timestamp => _utc_timestamp( $latest->{epoch} ),
55 Added: },
56 Added: );
57 Added: }
58 Added:
59 Added: # POST /api/weather/refresh (manual trigger)
60 Added: sub refresh ($self) {
61 Added: my $config = $self->app->config->{weather} // {};
62 Added:
63 Added: unless ( $config->{enabled} ) {
64 Added: return $self->render(
65 Added: json => { status => 'disabled', message => 'Weather fetch is disabled in config' },
66 Added: );
67 Added: }
68 Added:
69 Added: my $fetcher = $self->app->weather_fetcher;
70 Added: my $rows = $fetcher->fetch;
71 Added: my $stored = $self->weather->store_batch($rows);
72 Added:
73 Added: $self->render(
74 Added: json => {
75 Added: status => 'ok',
76 Added: fetched => scalar @$rows,
77 Added: stored => $stored // 0,
78 Added: },
79 Added: );
80 Added: }
81 Added:
82 Added: sub _format_point ($row) {
83 Added: return {
84 Added: timestamp => _utc_timestamp( $row->{epoch} ),
85 Added: temperature => $row->{temperature},
86 Added: humidity => $row->{humidity},
87 Added: rain => $row->{rain},
88 Added: };
89 Added: }
90 Added:
91 Added: sub _utc_timestamp ($epoch) {
92 Added: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
93 Added: }
94 Added:
95 Added: 1;
roles/dashboard/lib/FAPG/DAQ/Dashboard/Model/Weather.pm
index 00000000..dfbf3154 000000..100644
@@ -0,0 +1,77 @@
1 Added: package FAPG::DAQ::Dashboard::Model::Weather;
2 Added: use Mojo::Base -base, -signatures;
3 Added:
4 Added: use POSIX qw(strftime);
5 Added:
6 Added: has 'sqlite';
7 Added:
8 Added: sub create_table ($self) {
9 Added: $self->sqlite->db->query(
10 Added: q{
11 Added: CREATE TABLE IF NOT EXISTS weather_hourly (
12 Added: epoch INTEGER PRIMARY KEY,
13 Added: temperature REAL,
14 Added: humidity REAL,
15 Added: rain REAL,
16 Added: fetched_at TEXT NOT NULL
17 Added: )
18 Added: }
19 Added: );
20 Added: return $self;
21 Added: }
22 Added:
23 Added: sub store_batch ( $self, $rows ) {
24 Added: return unless $rows && @$rows;
25 Added:
26 Added: my $db = $self->sqlite->db;
27 Added: my $fetched_at = strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime time );
28 Added:
29 Added: my $tx = $db->begin;
30 Added: for my $row (@$rows) {
31 Added: $db->query(
32 Added: q{INSERT OR REPLACE INTO weather_hourly
33 Added: (epoch, temperature, humidity, rain, fetched_at)
34 Added: VALUES (?, ?, ?, ?, ?)},
35 Added: $row->{epoch},
36 Added: $row->{temperature},
37 Added: $row->{humidity},
38 Added: $row->{rain},
39 Added: $fetched_at,
40 Added: );
41 Added: }
42 Added: $tx->commit;
43 Added:
44 Added: return scalar @$rows;
45 Added: }
46 Added:
47 Added: sub get_range ( $self, $from_epoch, $to_epoch ) {
48 Added: return $self->sqlite->db->query(
49 Added: q{SELECT epoch, temperature, humidity, rain
50 Added: FROM weather_hourly
51 Added: WHERE epoch >= ? AND epoch <= ?
52 Added: ORDER BY epoch},
53 Added: $from_epoch,
54 Added: $to_epoch,
55 Added: )->hashes->to_array;
56 Added: }
57 Added:
58 Added: sub get_forecast ( $self, $from_epoch ) {
59 Added: return $self->sqlite->db->query(
60 Added: q{SELECT epoch, temperature, humidity, rain
61 Added: FROM weather_hourly
62 Added: WHERE epoch >= ?
63 Added: ORDER BY epoch},
64 Added: $from_epoch,
65 Added: )->hashes->to_array;
66 Added: }
67 Added:
68 Added: sub latest ($self) {
69 Added: return $self->sqlite->db->query(
70 Added: q{SELECT epoch, temperature, humidity, rain
71 Added: FROM weather_hourly
72 Added: ORDER BY epoch DESC
73 Added: LIMIT 1},
74 Added: )->hash;
75 Added: }
76 Added:
77 Added: 1;
roles/dashboard/lib/FAPG/DAQ/Dashboard/Service/WeatherFetcher.pm
index 00000000..ca7f4959 000000..100644
@@ -0,0 +1,77 @@
1 Added: package FAPG::DAQ::Dashboard::Service::WeatherFetcher;
2 Added: use Mojo::Base -base, -signatures;
3 Added:
4 Added: use Mojo::UserAgent;
5 Added: use Mojo::URL;
6 Added: use POSIX qw(mktime);
7 Added: use Scalar::Util qw(looks_like_number);
8 Added:
9 Added: has 'latitude' => 46.303;
10 Added: has 'longitude' => 6.098;
11 Added: has 'ua' => sub { Mojo::UserAgent->new->connect_timeout(10)->request_timeout(30) };
12 Added: has 'log' => sub { Mojo::Log->new };
13 Added:
14 Added: use constant BASE_URL => 'https://api.open-meteo.com/v1/meteofrance';
15 Added:
16 Added: sub fetch ($self) {
17 Added: my $url = Mojo::URL->new(BASE_URL);
18 Added: $url->query(
19 Added: latitude => $self->latitude,
20 Added: longitude => $self->longitude,
21 Added: hourly => 'temperature_2m,relative_humidity_2m,rain',
22 Added: past_days => 7,
23 Added: forecast_days => 2,
24 Added: timezone => 'UTC',
25 Added: );
26 Added:
27 Added: my $tx = $self->ua->get($url);
28 Added:
29 Added: if ( my $err = $tx->error ) {
30 Added: my $msg = $err->{message} || "HTTP $err->{code}";
31 Added: $self->log->warn("Weather fetch failed: $msg");
32 Added: return [];
33 Added: }
34 Added:
35 Added: my $json = $tx->result->json;
36 Added: return $self->_parse_response($json);
37 Added: }
38 Added:
39 Added: sub _parse_response ( $self, $json ) {
40 Added: return [] unless $json && $json->{hourly};
41 Added:
42 Added: my $hourly = $json->{hourly};
43 Added: my $times = $hourly->{time} || [];
44 Added: my $temps = $hourly->{temperature_2m} || [];
45 Added: my $humids = $hourly->{relative_humidity_2m} || [];
46 Added: my $rains = $hourly->{rain} || [];
47 Added:
48 Added: my @rows;
49 Added: for my $i ( 0 .. $#$times ) {
50 Added: my $epoch = _iso_to_epoch( $times->[$i] );
51 Added: next unless defined $epoch;
52 Added:
53 Added: push @rows, {
54 Added: epoch => $epoch,
55 Added: temperature => $temps->[$i],
56 Added: humidity => $humids->[$i],
57 Added: rain => $rains->[$i],
58 Added: };
59 Added: }
60 Added:
61 Added: return \@rows;
62 Added: }
63 Added:
64 Added: sub _iso_to_epoch ($iso) {
65 Added: # Format: "2026-08-10T00:00" (UTC, no timezone suffix)
66 Added: return unless $iso && $iso =~ /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/;
67 Added:
68 Added: my ( $year, $mon, $mday, $hour, $min ) = ( $1, $2, $3, $4, $5 );
69 Added:
70 Added: # Use timegm equivalent via ENV
71 Added: local $ENV{TZ} = 'UTC';
72 Added: POSIX::tzset();
73 Added: my $epoch = POSIX::mktime( 0, $min, $hour, $mday, $mon - 1, $year - 1900 );
74 Added: return $epoch;
75 Added: }
76 Added:
77 Added: 1;
roles/dashboard/public/js/dashboard.js
index e8999906..429ad547 100644..100644
@@ -1,6 +1,8 @@
1 1 import { initialiseGraphControls } from "./dashboard/graph-controls.js";
2 2 import { createProbeCharts } from "./dashboard/charts.js";
3 3 import { createInsightsCharts } from "./dashboard/insights.js";
4 Added: import { createWeatherCharts } from "./dashboard/weather-page.js";
5 Added: import { loadWeatherForecast } from "./dashboard/weather-forecast.js";
4 6 import { DASHBOARD_REFRESH_MS } from "./dashboard/constants.js";
5 7 import { loadQuickReadings } from "./dashboard/quick-readings.js";
6 8 import { createStatusLoader } from "./dashboard/status.js";
@@ -13,6 +15,7 @@
13 15 const timeframe = createTimeframeState();
14 16 const probes = createProbeCharts(timeframe);
15 17 const insights = createInsightsCharts();
18 Added: const weatherPage = createWeatherCharts();
16 19 const loadStatuses = createStatusLoader();
17 20
18 21 document.querySelectorAll(".tab-button").forEach(button => {
@@ -49,6 +52,8 @@
49 52 refreshInFlight = Promise.allSettled([
50 53 probes.loadDisplayed(),
51 54 insights.reload(),
55 Added: weatherPage.reload(),
56 Added: loadWeatherForecast(),
52 57 loadQuickReadings(),
53 58 loadStatuses()
54 59 ]).finally(() => {
roles/dashboard/public/js/dashboard/insights.js
index 75e6791a..5ee88af0 100644..100644
@@ -235,51 +235,83 @@
235 235 // EXPLORE — Same page-level timeframe
236 236 // ══════════════════════════════════════════════════════════════════
237 237
238 Added: const WEATHER_COLOR = { border: "#64748b", background: "rgba(100, 116, 139, 0.08)" };
239 Added:
240 Added: async function fetchWeatherHourly(hours, signal) {
241 Added: try {
242 Added: const response = await fetch(`/api/weather/hourly?hours=${hours}`, signal ? { signal } : {});
243 Added: if (!response.ok) return [];
244 Added: const data = await response.json();
245 Added: return data.points || [];
246 Added: } catch {
247 Added: return [];
248 Added: }
249 Added: }
250 Added:
238 251 async function loadDoOrpOverlay(timeframe, signal) {
239 252 const canvas = document.getElementById("chart-overlay-do-orp");
240 253 if (!canvas) return;
241 254
242 255 const tf = timeframe.selected();
243 256 const range = timeframe.range();
244 Removed: const [doData, orpData] = await Promise.all([
257 Added: const hours = timeframeToHours(timeframe);
258 Added: const [doData, orpData, weatherPoints] = await Promise.all([
245 259 fetchProbeData("do", tf, range, false, signal),
246 Removed: fetchProbeData("orp", tf, range, false, signal)
260 Added: fetchProbeData("orp", tf, range, false, signal),
261 Added: fetchWeatherHourly(hours, signal)
247 262 ]);
248 263
249 264 const labels = doData.points.map(p => formatChartTime(p.timestamp, tf));
250 265 const doValues = doData.points.map(p => p.value);
251 266 const orpValues = orpData.points.map(p => p.value);
252 267
268 Added: // Align weather data to the same label timestamps
269 Added: const weatherByTime = Object.fromEntries(
270 Added: weatherPoints.map(p => [formatChartTime(p.timestamp, tf), p.temperature])
271 Added: );
272 Added: const tempValues = labels.map(l => weatherByTime[l] ?? null);
273 Added:
274 Added: const datasets = [
275 Added: {
276 Added: label: "DO (mg/L)",
277 Added: data: doValues,
278 Added: borderColor: PROBE_COLORS.do.border,
279 Added: backgroundColor: PROBE_COLORS.do.background,
280 Added: tension: 0.2,
281 Added: pointRadius: 0,
282 Added: pointHoverRadius: 4,
283 Added: yAxisID: "yDO",
284 Added: fill: true
285 Added: },
286 Added: {
287 Added: label: "ORP (mV)",
288 Added: data: orpValues,
289 Added: borderColor: PROBE_COLORS.orp.border,
290 Added: backgroundColor: PROBE_COLORS.orp.background,
291 Added: tension: 0.2,
292 Added: pointRadius: 0,
293 Added: pointHoverRadius: 4,
294 Added: yAxisID: "yORP",
295 Added: fill: true
296 Added: },
297 Added: {
298 Added: label: "Outdoor °C",
299 Added: data: tempValues,
300 Added: borderColor: WEATHER_COLOR.border,
301 Added: backgroundColor: WEATHER_COLOR.background,
302 Added: borderDash: [5, 3],
303 Added: tension: 0.3,
304 Added: pointRadius: 0,
305 Added: pointHoverRadius: 3,
306 Added: yAxisID: "yTemp",
307 Added: fill: false
308 Added: }
309 Added: ];
310 Added:
253 311 if (!charts.doOrp) {
254 312 charts.doOrp = new Chart(canvas, {
255 313 type: "line",
256 Removed: data: {
257 Removed: labels,
258 Removed: datasets: [
259 Removed: {
260 Removed: label: "DO (mg/L)",
261 Removed: data: doValues,
262 Removed: borderColor: PROBE_COLORS.do.border,
263 Removed: backgroundColor: PROBE_COLORS.do.background,
264 Removed: tension: 0.2,
265 Removed: pointRadius: 0,
266 Removed: pointHoverRadius: 4,
267 Removed: yAxisID: "yDO",
268 Removed: fill: true
269 Removed: },
270 Removed: {
271 Removed: label: "ORP (mV)",
272 Removed: data: orpValues,
273 Removed: borderColor: PROBE_COLORS.orp.border,
274 Removed: backgroundColor: PROBE_COLORS.orp.background,
275 Removed: tension: 0.2,
276 Removed: pointRadius: 0,
277 Removed: pointHoverRadius: 4,
278 Removed: yAxisID: "yORP",
279 Removed: fill: true
280 Removed: }
281 Removed: ]
282 Removed: },
314 Added: data: { labels, datasets },
283 315 options: {
284 316 responsive: true,
285 317 maintainAspectRatio: false,
@@ -299,6 +331,13 @@
299 331 title: { display: true, text: "ORP (mV)", color: PROBE_COLORS.orp.border },
300 332 ticks: { color: PROBE_COLORS.orp.border },
301 333 grid: { drawOnChartArea: false }
334 Added: },
335 Added: yTemp: {
336 Added: type: "linear",
337 Added: position: "right",
338 Added: title: { display: true, text: "°C", color: WEATHER_COLOR.border },
339 Added: ticks: { color: WEATHER_COLOR.border },
340 Added: grid: { drawOnChartArea: false }
302 341 }
303 342 },
304 343 plugins: {
@@ -309,8 +348,7 @@
309 348 });
310 349 } else {
311 350 charts.doOrp.data.labels = labels;
312 Removed: charts.doOrp.data.datasets[0].data = doValues;
313 Removed: charts.doOrp.data.datasets[1].data = orpValues;
351 Added: charts.doOrp.data.datasets = datasets;
314 352 charts.doOrp.update();
315 353 }
316 354 }
roles/dashboard/public/js/dashboard/weather-forecast.js
index 00000000..813c1ac1 000000..100644
@@ -0,0 +1,143 @@
1 Added: import { formatChartTime } from "./format.js";
2 Added:
3 Added: let chart = null;
4 Added:
5 Added: async function fetchJson(url) {
6 Added: const response = await fetch(url);
7 Added: if (!response.ok) throw new Error(`HTTP ${response.status}`);
8 Added: return response.json();
9 Added: }
10 Added:
11 Added: function updateCurrentConditions(points) {
12 Added: const tempEl = document.querySelector("[data-weather-current-temp]");
13 Added: const detailsEl = document.querySelector("[data-weather-current-details]");
14 Added: if (!tempEl || !detailsEl) return;
15 Added:
16 Added: // Find the point closest to now
17 Added: const now = Date.now();
18 Added: let closest = points[0];
19 Added: let minDiff = Infinity;
20 Added:
21 Added: for (const p of points) {
22 Added: const diff = Math.abs(new Date(p.timestamp).getTime() - now);
23 Added: if (diff < minDiff) {
24 Added: minDiff = diff;
25 Added: closest = p;
26 Added: }
27 Added: }
28 Added:
29 Added: if (closest && closest.temperature != null) {
30 Added: tempEl.textContent = `${closest.temperature.toFixed(1)}°C`;
31 Added: const parts = [];
32 Added: if (closest.humidity != null) parts.push(`${Math.round(closest.humidity)}% humidity`);
33 Added: if (closest.rain != null && closest.rain > 0) parts.push(`${closest.rain.toFixed(1)} mm rain`);
34 Added: detailsEl.textContent = parts.length ? parts.join(", ") : "No rain";
35 Added: } else {
36 Added: tempEl.textContent = "—";
37 Added: detailsEl.textContent = "No weather data available";
38 Added: }
39 Added: }
40 Added:
41 Added: export async function loadWeatherForecast() {
42 Added: const canvas = document.getElementById("chart-weather-forecast");
43 Added: if (!canvas) return;
44 Added:
45 Added: let data;
46 Added: try {
47 Added: data = await fetchJson("/api/weather/forecast");
48 Added: } catch {
49 Added: return;
50 Added: }
51 Added:
52 Added: if (!data.points || !data.points.length) return;
53 Added:
54 Added: updateCurrentConditions(data.points);
55 Added:
56 Added: // Filter to only future + last few hours for context
57 Added: const now = Date.now();
58 Added: const cutoff = now - 6 * 60 * 60 * 1000;
59 Added: const points = data.points.filter(p => new Date(p.timestamp).getTime() >= cutoff);
60 Added:
61 Added: const labels = points.map(p => {
62 Added: const d = new Date(p.timestamp);
63 Added: return `${d.getHours().toString().padStart(2, "0")}:00`;
64 Added: });
65 Added: const temps = points.map(p => p.temperature);
66 Added: const rains = points.map(p => p.rain || 0);
67 Added:
68 Added: if (!chart) {
69 Added: chart = new Chart(canvas, {
70 Added: type: "bar",
71 Added: data: {
72 Added: labels,
73 Added: datasets: [
74 Added: {
75 Added: type: "line",
76 Added: label: "Temperature (°C)",
77 Added: data: temps,
78 Added: borderColor: "#dc2626",
79 Added: backgroundColor: "rgba(220, 38, 38, 0.1)",
80 Added: tension: 0.3,
81 Added: pointRadius: 0,
82 Added: pointHoverRadius: 3,
83 Added: yAxisID: "yTemp",
84 Added: fill: true,
85 Added: order: 1
86 Added: },
87 Added: {
88 Added: type: "bar",
89 Added: label: "Rain (mm)",
90 Added: data: rains,
91 Added: backgroundColor: "rgba(14, 165, 233, 0.5)",
92 Added: borderColor: "#0ea5e9",
93 Added: borderWidth: 1,
94 Added: yAxisID: "yRain",
95 Added: order: 2
96 Added: }
97 Added: ]
98 Added: },
99 Added: options: {
100 Added: responsive: true,
101 Added: maintainAspectRatio: false,
102 Added: animation: false,
103 Added: interaction: { mode: "index", intersect: false },
104 Added: scales: {
105 Added: x: {
106 Added: ticks: { maxTicksLimit: 12, font: { size: 10 }, maxRotation: 0, minRotation: 0 }
107 Added: },
108 Added: yTemp: {
109 Added: type: "linear",
110 Added: position: "left",
111 Added: title: { display: true, text: "°C", font: { size: 10 } },
112 Added: ticks: { font: { size: 10 } },
113 Added: grid: { display: true }
114 Added: },
115 Added: yRain: {
116 Added: type: "linear",
117 Added: position: "right",
118 Added: beginAtZero: true,
119 Added: title: { display: true, text: "mm", font: { size: 10 } },
120 Added: ticks: { font: { size: 10 } },
121 Added: grid: { drawOnChartArea: false }
122 Added: }
123 Added: },
124 Added: plugins: {
125 Added: legend: { display: true, labels: { font: { size: 10 } } },
126 Added: tooltip: {
127 Added: backgroundColor: "rgba(30, 41, 59, 0.7)",
128 Added: titleFont: { size: 11 },
129 Added: bodyFont: { size: 12 },
130 Added: padding: { x: 8, y: 5 },
131 Added: cornerRadius: 4,
132 Added: caretSize: 0
133 Added: }
134 Added: }
135 Added: }
136 Added: });
137 Added: } else {
138 Added: chart.data.labels = labels;
139 Added: chart.data.datasets[0].data = temps;
140 Added: chart.data.datasets[1].data = rains;
141 Added: chart.update();
142 Added: }
143 Added: }
roles/dashboard/public/js/dashboard/weather-page.js
index 00000000..26afb1d3 000000..100644
@@ -0,0 +1,192 @@
1 Added: import { TIMEFRAMES } from "./constants.js";
2 Added: import { formatDateTime } from "./format.js";
3 Added: import { createTimeframeState } from "./timeframe.js";
4 Added: import { initialiseGraphControls } from "./graph-controls.js";
5 Added:
6 Added: const TOOLTIP_DEFAULTS = {
7 Added: mode: "index",
8 Added: intersect: false,
9 Added: backgroundColor: "rgba(30, 41, 59, 0.7)",
10 Added: titleFont: { size: 11, weight: "normal" },
11 Added: bodyFont: { size: 12 },
12 Added: padding: { x: 8, y: 5 },
13 Added: cornerRadius: 4,
14 Added: caretSize: 0
15 Added: };
16 Added:
17 Added: let charts = {};
18 Added:
19 Added: async function fetchJson(url) {
20 Added: const response = await fetch(url);
21 Added: if (!response.ok) throw new Error(`HTTP ${response.status}`);
22 Added: return response.json();
23 Added: }
24 Added:
25 Added: function timeframeToHours(timeframe) {
26 Added: const tf = timeframe.selected();
27 Added: const range = timeframe.range();
28 Added: const ms = TIMEFRAMES[tf]?.ms || TIMEFRAMES.day.ms;
29 Added: return Math.round((ms * range) / (60 * 60 * 1000));
30 Added: }
31 Added:
32 Added: async function loadTemperature(timeframe, signal) {
33 Added: const canvas = document.getElementById("chart-weather-temperature");
34 Added: if (!canvas) return;
35 Added:
36 Added: const hours = timeframeToHours(timeframe);
37 Added: const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
38 Added: const labels = data.points.map(p => formatDateTime(p.timestamp));
39 Added: const values = data.points.map(p => p.temperature);
40 Added:
41 Added: if (!charts.temperature) {
42 Added: charts.temperature = new Chart(canvas, {
43 Added: type: "line",
44 Added: data: {
45 Added: labels,
46 Added: datasets: [{
47 Added: label: "Temperature (°C)",
48 Added: data: values,
49 Added: borderColor: "#dc2626",
50 Added: backgroundColor: "rgba(220, 38, 38, 0.08)",
51 Added: tension: 0.3,
52 Added: pointRadius: 0,
53 Added: pointHoverRadius: 3,
54 Added: fill: true
55 Added: }]
56 Added: },
57 Added: options: {
58 Added: responsive: true,
59 Added: maintainAspectRatio: false,
60 Added: animation: false,
61 Added: scales: {
62 Added: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
63 Added: y: { title: { display: true, text: "°C" } }
64 Added: },
65 Added: plugins: {
66 Added: legend: { display: false },
67 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
68 Added: }
69 Added: }
70 Added: });
71 Added: } else {
72 Added: charts.temperature.data.labels = labels;
73 Added: charts.temperature.data.datasets[0].data = values;
74 Added: charts.temperature.update();
75 Added: }
76 Added: }
77 Added:
78 Added: async function loadHumidity(timeframe, signal) {
79 Added: const canvas = document.getElementById("chart-weather-humidity");
80 Added: if (!canvas) return;
81 Added:
82 Added: const hours = timeframeToHours(timeframe);
83 Added: const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
84 Added: const labels = data.points.map(p => formatDateTime(p.timestamp));
85 Added: const values = data.points.map(p => p.humidity);
86 Added:
87 Added: if (!charts.humidity) {
88 Added: charts.humidity = new Chart(canvas, {
89 Added: type: "line",
90 Added: data: {
91 Added: labels,
92 Added: datasets: [{
93 Added: label: "Humidity (%)",
94 Added: data: values,
95 Added: borderColor: "#0ea5e9",
96 Added: backgroundColor: "rgba(14, 165, 233, 0.08)",
97 Added: tension: 0.3,
98 Added: pointRadius: 0,
99 Added: pointHoverRadius: 3,
100 Added: fill: true
101 Added: }]
102 Added: },
103 Added: options: {
104 Added: responsive: true,
105 Added: maintainAspectRatio: false,
106 Added: animation: false,
107 Added: scales: {
108 Added: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
109 Added: y: { title: { display: true, text: "%" }, min: 0, max: 100 }
110 Added: },
111 Added: plugins: {
112 Added: legend: { display: false },
113 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
114 Added: }
115 Added: }
116 Added: });
117 Added: } else {
118 Added: charts.humidity.data.labels = labels;
119 Added: charts.humidity.data.datasets[0].data = values;
120 Added: charts.humidity.update();
121 Added: }
122 Added: }
123 Added:
124 Added: async function loadRain(timeframe, signal) {
125 Added: const canvas = document.getElementById("chart-weather-rain");
126 Added: if (!canvas) return;
127 Added:
128 Added: const hours = timeframeToHours(timeframe);
129 Added: const data = await fetchJson(`/api/weather/hourly?hours=${hours}`);
130 Added: const labels = data.points.map(p => formatDateTime(p.timestamp));
131 Added: const values = data.points.map(p => p.rain || 0);
132 Added:
133 Added: if (!charts.rain) {
134 Added: charts.rain = new Chart(canvas, {
135 Added: type: "bar",
136 Added: data: {
137 Added: labels,
138 Added: datasets: [{
139 Added: label: "Rain (mm)",
140 Added: data: values,
141 Added: backgroundColor: "rgba(14, 165, 233, 0.5)",
142 Added: borderColor: "#0ea5e9",
143 Added: borderWidth: 1
144 Added: }]
145 Added: },
146 Added: options: {
147 Added: responsive: true,
148 Added: maintainAspectRatio: false,
149 Added: animation: false,
150 Added: scales: {
151 Added: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
152 Added: y: { beginAtZero: true, title: { display: true, text: "mm" } }
153 Added: },
154 Added: plugins: {
155 Added: legend: { display: false },
156 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
157 Added: }
158 Added: }
159 Added: });
160 Added: } else {
161 Added: charts.rain.data.labels = labels;
162 Added: charts.rain.data.datasets[0].data = values;
163 Added: charts.rain.update();
164 Added: }
165 Added: }
166 Added:
167 Added: export function createWeatherCharts() {
168 Added: const hasCanvas = document.getElementById("chart-weather-temperature");
169 Added: if (!hasCanvas) return { reload: () => {} };
170 Added:
171 Added: const timeframe = createTimeframeState();
172 Added: let controller = null;
173 Added:
174 Added: function loadAll() {
175 Added: if (controller) controller.abort();
176 Added: controller = new AbortController();
177 Added: const signal = controller.signal;
178 Added:
179 Added: return Promise.allSettled([
180 Added: loadTemperature(timeframe, signal),
181 Added: loadHumidity(timeframe, signal),
182 Added: loadRain(timeframe, signal)
183 Added: ]);
184 Added: }
185 Added:
186 Added: initialiseGraphControls(timeframe, () => loadAll());
187 Added: loadAll();
188 Added:
189 Added: return {
190 Added: reload() { loadAll(); }
191 Added: };
192 Added: }
roles/dashboard/t/09-weather.t
index 00000000..094f1d07 000000..100644
@@ -0,0 +1,131 @@
1 Added: use Mojo::Base -strict;
2 Added:
3 Added: use Test2::V0;
4 Added:
5 Added: use FindBin;
6 Added: use lib "${FindBin::Bin}/../lib/";
7 Added: use lib "${FindBin::Bin}/lib/";
8 Added: use Dashboard::Test qw(test_app test_empty_app);
9 Added:
10 Added: my $t = test_app();
11 Added:
12 Added: subtest 'weather model stores and retrieves data' => sub {
13 Added: my $weather = $t->app->weather;
14 Added: ok $weather, 'weather helper is available';
15 Added:
16 Added: my $now = time;
17 Added: my @rows = map {
18 Added: { epoch => ( int( $now / 3600 ) - $_ ) * 3600,
19 Added: temperature => 20 + $_ * 0.5,
20 Added: humidity => 60 + $_,
21 Added: rain => $_ % 3 == 0 ? 0.2 : 0,
22 Added: }
23 Added: } 1 .. 12;
24 Added:
25 Added: my $stored = $weather->store_batch( \@rows );
26 Added: is $stored, 12, 'stored 12 weather points';
27 Added:
28 Added: my $range = $weather->get_range( $rows[-1]{epoch}, $rows[0]{epoch} );
29 Added: is scalar @$range, 12, 'get_range returns all 12 points';
30 Added: ok $range->[0]{temperature}, 'points have temperature';
31 Added: ok $range->[0]{humidity}, 'points have humidity';
32 Added:
33 Added: my $latest = $weather->latest;
34 Added: ok $latest, 'latest returns a row';
35 Added: is $latest->{epoch}, $rows[0]{epoch}, 'latest is the most recent epoch';
36 Added: };
37 Added:
38 Added: subtest 'weather model deduplicates on epoch' => sub {
39 Added: my $weather = $t->app->weather;
40 Added: my $epoch = int( time / 3600 ) * 3600;
41 Added:
42 Added: $weather->store_batch( [ { epoch => $epoch, temperature => 25, humidity => 70, rain => 0 } ] );
43 Added: $weather->store_batch( [ { epoch => $epoch, temperature => 26, humidity => 71, rain => 0.1 } ] );
44 Added:
45 Added: my $range = $weather->get_range( $epoch, $epoch );
46 Added: is scalar @$range, 1, 'only one row for the same epoch';
47 Added: is $range->[0]{temperature}, 26, 'second insert replaces first';
48 Added: };
49 Added:
50 Added: subtest 'weather API hourly endpoint' => sub {
51 Added: $t->get_ok('/api/weather/hourly?hours=24')
52 Added: ->status_is(200)
53 Added: ->json_has('/points')
54 Added: ->json_is('/hours' => 24);
55 Added:
56 Added: my $points = $t->tx->res->json->{points};
57 Added: ok @$points > 0, 'hourly returns seeded weather points';
58 Added: ok exists $points->[0]{temperature}, 'points have temperature';
59 Added: ok exists $points->[0]{humidity}, 'points have humidity';
60 Added: ok exists $points->[0]{rain}, 'points have rain';
61 Added: ok exists $points->[0]{timestamp}, 'points have timestamp';
62 Added: };
63 Added:
64 Added: subtest 'weather API forecast endpoint' => sub {
65 Added: # Seed some future data
66 Added: my $future = ( int( time / 3600 ) + 2 ) * 3600;
67 Added: $t->app->weather->store_batch( [
68 Added: { epoch => $future, temperature => 28, humidity => 55, rain => 0 },
69 Added: { epoch => $future + 3600, temperature => 27, humidity => 58, rain => 0.5 },
70 Added: ] );
71 Added:
72 Added: $t->get_ok('/api/weather/forecast')
73 Added: ->status_is(200)
74 Added: ->json_has('/points')
75 Added: ->json_has('/from');
76 Added:
77 Added: my $points = $t->tx->res->json->{points};
78 Added: ok @$points > 0, 'forecast returns points';
79 Added: };
80 Added:
81 Added: subtest 'weather API current endpoint' => sub {
82 Added: $t->get_ok('/api/weather/current')
83 Added: ->status_is(200)
84 Added: ->json_has('/temperature')
85 Added: ->json_has('/humidity')
86 Added: ->json_has('/rain')
87 Added: ->json_has('/timestamp');
88 Added: };
89 Added:
90 Added: subtest 'weather API refresh endpoint' => sub {
91 Added: $t->get_ok('/api/weather/refresh')
92 Added: ->status_is(200)
93 Added: ->json_has('/status');
94 Added: };
95 Added:
96 Added: subtest 'weather page renders' => sub {
97 Added: $t->get_ok('/weather')
98 Added: ->status_is(200)
99 Added: ->text_is('title', 'FAPG DAQ Weather')
100 Added: ->element_exists('canvas#chart-weather-temperature')
101 Added: ->element_exists('canvas#chart-weather-humidity')
102 Added: ->element_exists('canvas#chart-weather-rain')
103 Added: ->element_exists('[data-default-timeframe="day"]');
104 Added: };
105 Added:
106 Added: subtest 'weather page has nav link' => sub {
107 Added: $t->get_ok('/weather')
108 Added: ->status_is(200)
109 Added: ->element_exists('.navbar-nav a.nav-link.active[href="/weather"]');
110 Added: };
111 Added:
112 Added: subtest 'index page has weather forecast card' => sub {
113 Added: $t->get_ok('/')
114 Added: ->status_is(200)
115 Added: ->element_exists('[data-weather-forecast]')
116 Added: ->element_exists('canvas#chart-weather-forecast');
117 Added: };
118 Added:
119 Added: subtest 'empty database weather endpoints' => sub {
120 Added: my $empty = test_empty_app();
121 Added: $empty->get_ok('/api/weather/hourly')
122 Added: ->status_is(200)
123 Added: ->json_is('/points' => []);
124 Added: $empty->get_ok('/api/weather/current')
125 Added: ->status_is(200)
126 Added: ->json_is('/temperature' => undef);
127 Added: $empty->get_ok('/weather')
128 Added: ->status_is(200);
129 Added: };
130 Added:
131 Added: done_testing;
roles/dashboard/t/lib/Dashboard/Test.pm
index b6ca64d8..395e9a26 100644..100644
@@ -71,6 +71,7 @@
71 71 }
72 72 );
73 73
74 Added: # weather_hourly is created by the Weather model in startup
74 75 return;
75 76 }
76 77
roles/dashboard/templates/dashboard/index.html.ep
index 0863da4a..6510858e 100644..100644
@@ -50,5 +50,20 @@
50 50 % }
51 51 </div>
52 52 </section>
53 Added:
54 Added: <section class="mt-4" aria-label="Weather forecast">
55 Added: <h2 class="h6 fw-semibold text-body-secondary mb-3">Weather — Versonnex</h2>
56 Added: <div class="card" data-weather-forecast>
57 Added: <div class="card-body">
58 Added: <div class="d-flex align-items-baseline gap-3 mb-2">
59 Added: <span class="fs-3 fw-bold" data-weather-current-temp>—</span>
60 Added: <span class="text-body-secondary small" data-weather-current-details>Loading weather…</span>
61 Added: </div>
62 Added: <div class="chart-wrap" style="height: 160px;">
63 Added: <canvas id="chart-weather-forecast" role="img" aria-label="48-hour weather forecast"></canvas>
64 Added: </div>
65 Added: </div>
66 Added: </div>
67 Added: </section>
53 68 </section>
54 69
roles/dashboard/templates/dashboard/weather.html.ep
index 00000000..7dfb5fb6 000000..100644
@@ -0,0 +1,54 @@
1 Added: % layout 'default';
2 Added: % title 'FAPG DAQ Weather';
3 Added: % stash use_charts => 1;
4 Added:
5 Added: <section class="mb-4" aria-label="Weather data">
6 Added: <div class="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-3 sticky-controls">
7 Added: <div>
8 Added: <h1 class="h4 fw-bold mb-1">Weather</h1>
9 Added: <p class="text-body-secondary small mb-0">Outdoor conditions at Versonnex — Météo France (AROME)</p>
10 Added: </div>
11 Added: %= include 'dashboard/graph-control', aria_label => 'Weather timeframe', default_timeframe => 'day'
12 Added: </div>
13 Added:
14 Added: <div class="row g-3">
15 Added: <div class="col-12" id="weather-temperature">
16 Added: <div class="card">
17 Added: <div class="card-header">
18 Added: <h2 class="h6 mb-0">Temperature</h2>
19 Added: </div>
20 Added: <div class="card-body">
21 Added: <div class="chart-wrap">
22 Added: <canvas id="chart-weather-temperature" role="img" aria-label="Outdoor temperature over time"></canvas>
23 Added: </div>
24 Added: </div>
25 Added: </div>
26 Added: </div>
27 Added:
28 Added: <div class="col-12" id="weather-humidity">
29 Added: <div class="card">
30 Added: <div class="card-header">
31 Added: <h2 class="h6 mb-0">Humidity</h2>
32 Added: </div>
33 Added: <div class="card-body">
34 Added: <div class="chart-wrap">
35 Added: <canvas id="chart-weather-humidity" role="img" aria-label="Outdoor relative humidity over time"></canvas>
36 Added: </div>
37 Added: </div>
38 Added: </div>
39 Added: </div>
40 Added:
41 Added: <div class="col-12" id="weather-rain">
42 Added: <div class="card">
43 Added: <div class="card-header">
44 Added: <h2 class="h6 mb-0">Rainfall</h2>
45 Added: </div>
46 Added: <div class="card-body">
47 Added: <div class="chart-wrap">
48 Added: <canvas id="chart-weather-rain" role="img" aria-label="Rainfall over time"></canvas>
49 Added: </div>
50 Added: </div>
51 Added: </div>
52 Added: </div>
53 Added: </div>
54 Added: </section>
roles/dashboard/templates/layouts/default.html.ep
index 3c341245..1b489108 100644..100644
@@ -53,6 +53,13 @@
53 53 % }
54 54 </li>
55 55 <li class="nav-item">
56 Added: % if ((stash('nav_page') // '') eq 'weather') {
57 Added: <a class="nav-link fw-semibold active" href="/weather" aria-current="page">Weather</a>
58 Added: % } else {
59 Added: <a class="nav-link fw-semibold" href="/weather">Weather</a>
60 Added: % }
61 Added: </li>
62 Added: <li class="nav-item">
56 63 % if ((stash('nav_page') // '') eq 'downloads') {
57 64 <a class="nav-link fw-semibold active" href="/downloads" aria-current="page">Downloads</a>
58 65 % } else {