[Perl] DAQ system for the FAPG.
feat compact mobile index with now-indicator
Redesign the index page for single-screenful mobile viewing: - 2x2 grid for probe cards with minimal padding - Shorter value display (just value + unit) - Thinner range bars - Removed redundant header and quick-links nav - Weather card shows a green dashed 'now' line on the chart - Date labels at midnight boundaries for orientation
Changed files
roles/dashboard/public/css/app.css
@@ -293,7 +293,7 @@
293
293
294
294
.reading-range {
295
295
position: relative;
296
Removed:
height: 0.85rem;
296
Added:
height: 0.5rem;
297
297
border-radius: 999px;
298
298
background: var(--range-gradient);
299
299
}
@@ -302,8 +302,8 @@
302
302
position: absolute;
303
303
top: 50%;
304
304
left: 50%;
305
Removed:
width: 1rem;
306
Removed:
height: 1rem;
305
Added:
width: 0.75rem;
306
Added:
height: 0.75rem;
307
307
border: 2px solid white;
308
308
border-radius: 50%;
309
309
background: var(--fapg-unknown);
roles/dashboard/public/js/dashboard/quick-readings.js
@@ -54,10 +54,11 @@
54
54
`linear-gradient(90deg, #b42318 0%, #e8a308 ${Math.max(0, goodStart - 5)}%, #276749 ${goodStart}%, #276749 ${goodEnd}%, #e8a308 ${Math.min(100, goodEnd + 5)}%, #b42318 100%)`);
55
55
}
56
56
57
Removed:
item.querySelector("[data-reading-quick-min]").textContent = `${formatValue(range.min, range)} ${unit}`;
58
Removed:
item.querySelector("[data-reading-quick-target]").textContent =
59
Removed:
`${formatValue(range.goodMin, range)}-${formatValue(range.goodMax, range)} ${unit}`;
60
Removed:
item.querySelector("[data-reading-quick-max]").textContent = `${formatValue(range.max, range)} ${unit}`;
57
Added:
item.querySelector("[data-reading-quick-min]")?.textContent && (item.querySelector("[data-reading-quick-min]").textContent = `${formatValue(range.min, range)} ${unit}`);
58
Added:
const targetEl = item.querySelector("[data-reading-quick-target]");
59
Added:
if (targetEl) targetEl.textContent = `${formatValue(range.goodMin, range)}-${formatValue(range.goodMax, range)} ${unit}`;
60
Added:
const maxEl = item.querySelector("[data-reading-quick-max]");
61
Added:
if (maxEl) maxEl.textContent = `${formatValue(range.max, range)} ${unit}`;
61
62
}
62
63
63
64
function markerColor(quality) {
@@ -105,8 +106,8 @@
105
106
meter.setAttribute("aria-valuenow", Math.round(percent).toString());
106
107
meter.setAttribute("aria-label", `${label} ${qualityLabel(quality)} at ${formatValue(value, range)} ${unit}`);
107
108
item.querySelector("[data-reading-quick-value]").textContent = Number.isFinite(value)
108
Removed:
? `${formatValue(value, range)} ${unit} - ${qualityLabel(quality)} - ${age}`
109
Removed:
: "No reading yet";
109
Added:
? `${formatValue(value, range)} ${unit}`
110
Added:
: "—";
110
111
}
111
112
112
113
export async function loadQuickReadings() {
roles/dashboard/public/js/dashboard/weather-forecast.js
@@ -29,15 +29,67 @@
29
29
if (closest && closest.temperature != null) {
30
30
tempEl.textContent = `${closest.temperature.toFixed(1)}°C`;
31
31
const parts = [];
32
Removed:
if (closest.humidity != null) parts.push(`${Math.round(closest.humidity)}% humidity`);
33
Removed:
if (closest.rain != null && closest.rain > 0) parts.push(`${closest.rain.toFixed(1)} mm rain`);
34
Removed:
detailsEl.textContent = parts.length ? parts.join(", ") : "No rain";
32
Added:
if (closest.humidity != null) parts.push(`${Math.round(closest.humidity)}%`);
33
Added:
if (closest.rain != null && closest.rain > 0) parts.push(`${closest.rain.toFixed(1)} mm`);
34
Added:
else parts.push("no rain");
35
Added:
detailsEl.textContent = parts.join(", ");
35
36
} else {
36
37
tempEl.textContent = "—";
37
Removed:
detailsEl.textContent = "No weather data available";
38
Added:
detailsEl.textContent = "No data";
38
39
}
39
40
}
40
41
42
Added:
// Chart.js plugin to draw a vertical "now" line
43
Added:
const nowLinePlugin = {
44
Added:
id: "nowLine",
45
Added:
afterDraw(chart) {
46
Added:
const { ctx, chartArea, scales } = chart;
47
Added:
const xScale = scales.x;
48
Added:
if (!xScale || !chart.data.labels) return;
49
Added:
50
Added:
// Find the label index closest to current time
51
Added:
const nowHour = new Date().getHours();
52
Added:
const nowLabel = `${nowHour.toString().padStart(2, "0")}:00`;
53
Added:
const labels = chart.data.labels;
54
Added:
55
Added:
// Find today's "now" label
56
Added:
let nowIndex = -1;
57
Added:
const now = Date.now();
58
Added:
const meta = chart._weatherTimestamps;
59
Added:
if (meta) {
60
Added:
let minDiff = Infinity;
61
Added:
for (let i = 0; i < meta.length; i++) {
62
Added:
const diff = Math.abs(meta[i] - now);
63
Added:
if (diff < minDiff) {
64
Added:
minDiff = diff;
65
Added:
nowIndex = i;
66
Added:
}
67
Added:
}
68
Added:
}
69
Added:
70
Added:
if (nowIndex < 0 || nowIndex >= labels.length) return;
71
Added:
72
Added:
const x = xScale.getPixelForValue(nowIndex);
73
Added:
if (x < chartArea.left || x > chartArea.right) return;
74
Added:
75
Added:
ctx.save();
76
Added:
ctx.beginPath();
77
Added:
ctx.moveTo(x, chartArea.top);
78
Added:
ctx.lineTo(x, chartArea.bottom);
79
Added:
ctx.lineWidth = 2;
80
Added:
ctx.strokeStyle = "rgba(22, 163, 74, 0.7)";
81
Added:
ctx.setLineDash([4, 3]);
82
Added:
ctx.stroke();
83
Added:
84
Added:
// "Now" label
85
Added:
ctx.fillStyle = "rgba(22, 163, 74, 0.9)";
86
Added:
ctx.font = "bold 9px sans-serif";
87
Added:
ctx.textAlign = "center";
88
Added:
ctx.fillText("now", x, chartArea.top - 3);
89
Added:
ctx.restore();
90
Added:
}
91
Added:
};
92
Added:
41
93
export async function loadWeatherForecast() {
42
94
const canvas = document.getElementById("chart-weather-forecast");
43
95
if (!canvas) return;
@@ -53,14 +105,20 @@
53
105
54
106
updateCurrentConditions(data.points);
55
107
56
Removed:
// Filter to only future + last few hours for context
108
Added:
// Show from 6h ago to end of forecast
57
109
const now = Date.now();
58
110
const cutoff = now - 6 * 60 * 60 * 1000;
59
111
const points = data.points.filter(p => new Date(p.timestamp).getTime() >= cutoff);
60
112
113
Added:
const timestamps = points.map(p => new Date(p.timestamp).getTime());
61
114
const labels = points.map(p => {
62
115
const d = new Date(p.timestamp);
63
Removed:
return `${d.getHours().toString().padStart(2, "0")}:00`;
116
Added:
const h = d.getHours().toString().padStart(2, "0");
117
Added:
// Show date at midnight boundaries
118
Added:
if (d.getHours() === 0) {
119
Added:
return `${d.getDate()}/${d.getMonth() + 1}`;
120
Added:
}
121
Added:
return `${h}:00`;
64
122
});
65
123
const temps = points.map(p => p.temperature);
66
124
const rains = points.map(p => p.rain || 0);
@@ -73,10 +131,10 @@
73
131
datasets: [
74
132
{
75
133
type: "line",
76
Removed:
label: "Temperature (°C)",
134
Added:
label: "°C",
77
135
data: temps,
78
136
borderColor: "#dc2626",
79
Removed:
backgroundColor: "rgba(220, 38, 38, 0.1)",
137
Added:
backgroundColor: "rgba(220, 38, 38, 0.08)",
80
138
tension: 0.3,
81
139
pointRadius: 0,
82
140
pointHoverRadius: 3,
@@ -86,7 +144,7 @@
86
144
},
87
145
{
88
146
type: "bar",
89
Removed:
label: "Rain (mm)",
147
Added:
label: "mm",
90
148
data: rains,
91
149
backgroundColor: "rgba(14, 165, 233, 0.5)",
92
150
borderColor: "#0ea5e9",
@@ -100,44 +158,47 @@
100
158
responsive: true,
101
159
maintainAspectRatio: false,
102
160
animation: false,
161
Added:
layout: { padding: { top: 12 } },
103
162
interaction: { mode: "index", intersect: false },
104
163
scales: {
105
164
x: {
106
Removed:
ticks: { maxTicksLimit: 12, font: { size: 10 }, maxRotation: 0, minRotation: 0 }
165
Added:
ticks: { maxTicksLimit: 10, font: { size: 9 }, maxRotation: 0, minRotation: 0 }
107
166
},
108
167
yTemp: {
109
168
type: "linear",
110
169
position: "left",
111
Removed:
title: { display: true, text: "°C", font: { size: 10 } },
112
Removed:
ticks: { font: { size: 10 } },
170
Added:
ticks: { font: { size: 9 } },
113
171
grid: { display: true }
114
172
},
115
173
yRain: {
116
174
type: "linear",
117
175
position: "right",
118
176
beginAtZero: true,
119
Removed:
title: { display: true, text: "mm", font: { size: 10 } },
120
Removed:
ticks: { font: { size: 10 } },
177
Added:
ticks: { font: { size: 9 } },
121
178
grid: { drawOnChartArea: false }
122
179
}
123
180
},
124
181
plugins: {
125
Removed:
legend: { display: true, labels: { font: { size: 10 } } },
182
Added:
legend: { display: true, labels: { font: { size: 9 }, boxWidth: 10 } },
126
183
tooltip: {
127
184
backgroundColor: "rgba(30, 41, 59, 0.7)",
128
Removed:
titleFont: { size: 11 },
129
Removed:
bodyFont: { size: 12 },
130
Removed:
padding: { x: 8, y: 5 },
185
Added:
titleFont: { size: 10 },
186
Added:
bodyFont: { size: 11 },
187
Added:
padding: { x: 6, y: 4 },
131
188
cornerRadius: 4,
132
189
caretSize: 0
133
190
}
134
191
}
135
Removed:
}
192
Added:
},
193
Added:
plugins: [nowLinePlugin]
136
194
});
195
Added:
// Store timestamps for the now-line plugin
196
Added:
chart._weatherTimestamps = timestamps;
137
197
} else {
138
198
chart.data.labels = labels;
139
199
chart.data.datasets[0].data = temps;
140
200
chart.data.datasets[1].data = rains;
201
Added:
chart._weatherTimestamps = timestamps;
141
202
chart.update();
142
203
}
143
204
}
roles/dashboard/t/00-homepage.t
@@ -18,14 +18,11 @@
18
18
->text_is( '.navbar-nav a.nav-link[href="/downloads"]', 'Downloads' )
19
19
->element_exists_not('.navbar-nav a.nav-link.active')
20
20
->element_exists_not('nav[aria-label="Breadcrumb"]')
21
Removed:
->element_exists('h1.display-5')
22
Removed:
->element_exists('nav[aria-label="Quick links"] a[href="/probes"]')
23
Removed:
->element_exists('nav[aria-label="Quick links"] a[href="/insights"]')
24
Removed:
->element_exists('nav[aria-label="Quick links"] a[href="/status"]')
25
Removed:
->element_exists('nav[aria-label="Quick links"] a[href="/downloads"]')
21
Added:
->element_exists('h1')
26
22
->element_exists(
27
23
'section.reading-quick-status [data-reading-quick-status]')
28
Removed:
->element_exists('section.reading-quick-status h2')
24
Added:
->element_exists('[data-weather-forecast]')
25
Added:
->element_exists('canvas#chart-weather-forecast')
29
26
->element_exists(
30
27
'footer a[href="https://www.lafermeaquaponique.com/"]');
31
28
roles/dashboard/templates/dashboard/index.html.ep
@@ -2,47 +2,28 @@
2
2
% title 'FAPG DAQ Dashboard';
3
3
% stash use_charts => 1;
4
4
5
Removed:
<section class="mb-4" aria-label="DAQ status overview">
6
Removed:
<div class="mb-3">
7
Removed:
<h1 class="display-5 fw-bold mb-1">FAPG DAQ Dashboard</h1>
8
Removed:
<p class="lead text-body-secondary mb-2">Live aquaponic sensor readings</p>
9
Removed:
<nav aria-label="Quick links">
10
Removed:
<ul class="nav nav-pills">
11
Removed:
<li class="nav-item"><a class="nav-link" href="/probes">Probes</a></li>
12
Removed:
<li class="nav-item"><a class="nav-link" href="/insights">Insights</a></li>
13
Removed:
<li class="nav-item"><a class="nav-link" href="/status">Status</a></li>
14
Removed:
<li class="nav-item"><a class="nav-link" href="/downloads">Downloads</a></li>
15
Removed:
</ul>
16
Removed:
</nav>
17
Removed:
</div>
5
Added:
<section class="mb-3" aria-label="DAQ status overview">
6
Added:
<h1 class="h5 fw-bold mb-2">FAPG DAQ</h1>
18
7
19
Removed:
<section class="reading-quick-status" aria-label="Sensor reading quick status">
20
Removed:
<h2 class="h6 fw-semibold text-body-secondary mb-3">Live readings</h2>
21
Removed:
<div class="row g-3" data-reading-quick-status>
8
Added:
<section class="reading-quick-status mb-3" aria-label="Sensor reading quick status">
9
Added:
<div class="row g-2" data-reading-quick-status>
22
10
% for my $probe ($probes->@*) {
23
Removed:
<div class="col-12 col-sm-6 col-xl-3">
11
Added:
<div class="col-6 col-sm-6 col-xl-3">
24
12
<a class="reading-quick-link text-decoration-none text-body d-block h-100" href="/probes/<%= $probe->{key} %>">
25
13
<article
26
14
class="card h-100 reading-quick-item"
27
15
data-reading-quick-probe="<%= $probe->{key} %>"
28
16
data-reading-quick-label="<%= $probe->{label} %>"
29
17
data-reading-quick-unit="<%= $probe->{unit} %>">
30
Removed:
<div class="card-body d-flex flex-column gap-2">
31
Removed:
<div class="reading-quick-header d-flex justify-content-between align-items-start">
32
Removed:
<div>
33
Removed:
<h3 class="fs-6 fw-bold mb-0"><%= $probe->{label} %></h3>
34
Removed:
<p class="text-body-secondary small mb-0" data-reading-quick-value>Waiting for data</p>
35
Removed:
</div>
36
Removed:
<span class="status-pill is-unknown" data-reading-quick-status="<%= $probe->{key} %>">Unknown</span>
18
Added:
<div class="card-body py-2 px-2 d-flex flex-column gap-1">
19
Added:
<div class="d-flex justify-content-between align-items-center">
20
Added:
<h3 class="small fw-bold mb-0 text-truncate"><%= $probe->{label} %></h3>
21
Added:
<span class="status-pill is-unknown small" data-reading-quick-status="<%= $probe->{key} %>"></span>
37
22
</div>
23
Added:
<p class="fs-6 fw-semibold mb-0" data-reading-quick-value>—</p>
38
24
<div class="reading-range" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
39
25
<span class="reading-range-marker" data-reading-quick-marker></span>
40
26
</div>
41
Removed:
<div class="reading-range-labels d-grid gap-1 small text-body-secondary">
42
Removed:
<span data-reading-quick-min></span>
43
Removed:
<span class="fw-bold text-center" data-reading-quick-target></span>
44
Removed:
<span class="text-end" data-reading-quick-max></span>
45
Removed:
</div>
46
27
</div>
47
28
</article>
48
29
</a>
@@ -51,19 +32,20 @@
51
32
</div>
52
33
</section>
53
34
54
Removed:
<section class="mt-4" aria-label="Weather forecast">
55
Removed:
<h2 class="h6 fw-semibold text-body-secondary mb-3">Weather — Versonnex</h2>
35
Added:
<section aria-label="Weather forecast">
56
36
<div class="card" data-weather-forecast>
57
Removed:
<div class="card-body">
58
Removed:
<div class="d-flex align-items-baseline gap-3 mb-2">
59
Removed:
<span class="fs-3 fw-bold" data-weather-current-temp>—</span>
60
Removed:
<span class="text-body-secondary small" data-weather-current-details>Loading weather…</span>
37
Added:
<div class="card-body py-2 px-3">
38
Added:
<div class="d-flex align-items-baseline justify-content-between mb-1">
39
Added:
<div class="d-flex align-items-baseline gap-2">
40
Added:
<span class="fs-4 fw-bold" data-weather-current-temp>—</span>
41
Added:
<span class="text-body-secondary small" data-weather-current-details>Loading…</span>
42
Added:
</div>
43
Added:
<span class="text-body-secondary small">Versonnex</span>
61
44
</div>
62
Removed:
<div class="chart-wrap" style="height: 160px;">
45
Added:
<div class="chart-wrap" style="height: 130px;">
63
46
<canvas id="chart-weather-forecast" role="img" aria-label="48-hour weather forecast"></canvas>
64
47
</div>
65
48
</div>
66
49
</div>
67
50
</section>
68
51
</section>
69
Removed: