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

Commit
65a0c2442939dce6793996a170497ad148ebb03b
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/public/css/app.css
index c38e7d88..e30efd09 100644..100644
@@ -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
index 61e724b3..2a0e98b3 100644..100644
@@ -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
index 813c1ac1..9a472557 100644..100644
@@ -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
index 50467f78..078ed162 100644..100644
@@ -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
index 6510858e..eb814051 100644..100644
@@ -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: