Improve readings chart.

Add chart x-axis timeframe selection interface, set minimum y-axis span.

Commit
34fb0aac6f8c8e317abd17023be1c3434f351fbd
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/lib/dashboard/Controller/Reading.pm
index 6caa1437..1618a567 100644..100644
@@ -24,15 +24,26 @@
24 24 $limit = 300 unless $limit =~ /^\d+$/;
25 25 $limit = 2_000 if $limit > 2_000;
26 26
27 Added: my @where = ('probe = ?');
28 Added: my @bind = ($probe);
29 Added: my $since = $self->param('since');
30 Added:
31 Added: if ( defined $since && $since =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ ) {
32 Added: push @where, 'COALESCE(received_at, timestamp) >= ?';
33 Added: push @bind, $since;
34 Added: }
35 Added:
27 36 my $rows = $self->sqlite->db->query(
28 37 q{
29 38 SELECT timestamp, received_at, node, probe, value, unit
30 39 FROM readings
31 Removed: WHERE probe = ?
40 Added: WHERE
41 Added: }
42 Added: . join( "\n AND ", @where ) . q{
32 43 ORDER BY COALESCE(received_at, timestamp) DESC, id DESC
33 44 LIMIT ?
34 45 },
35 Removed: $probe,
46 Added: @bind,
36 47 $limit,
37 48 )->hashes->to_array;
38 49
roles/dashboard/public/css/app.css
index 8146c74e..1dc75065 100644..100644
@@ -186,7 +186,7 @@
186 186 display: flex;
187 187 justify-content: space-between;
188 188 gap: 1rem;
189 Removed: align-items: baseline;
189 Added: align-items: flex-start;
190 190 margin-bottom: 1rem;
191 191 }
192 192
@@ -199,6 +199,48 @@
199 199 color: var(--muted);
200 200 }
201 201
202 Added: .panel-tools {
203 Added: display: flex;
204 Added: flex-wrap: wrap;
205 Added: justify-content: flex-end;
206 Added: gap: 0.75rem;
207 Added: align-items: center;
208 Added: }
209 Added:
210 Added: .timeframe-control {
211 Added: display: inline-flex;
212 Added: overflow: hidden;
213 Added: border: 1px solid var(--border);
214 Added: border-radius: 999px;
215 Added: background: white;
216 Added: }
217 Added:
218 Added: .timeframe-button {
219 Added: min-width: 4.75rem;
220 Added: border: 0;
221 Added: border-left: 1px solid var(--border);
222 Added: padding: 0.45rem 0.75rem;
223 Added: background: transparent;
224 Added: color: var(--text);
225 Added: cursor: pointer;
226 Added: font: inherit;
227 Added: font-size: 0.88rem;
228 Added: font-weight: 700;
229 Added: }
230 Added:
231 Added: .timeframe-button:first-child {
232 Added: border-left: 0;
233 Added: }
234 Added:
235 Added: .timeframe-button:hover {
236 Added: background: var(--accent-soft);
237 Added: }
238 Added:
239 Added: .timeframe-button.is-active {
240 Added: background: var(--accent);
241 Added: color: white;
242 Added: }
243 Added:
202 244 .chart-wrap {
203 245 min-height: 420px;
204 246 }
@@ -263,6 +305,31 @@
263 305
264 306 .panel-heading {
265 307 display: block;
308 Added: }
309 Added:
310 Added: .panel-tools {
311 Added: justify-content: flex-start;
312 Added: margin-top: 0.75rem;
313 Added: }
314 Added:
315 Added: .timeframe-control {
316 Added: display: grid;
317 Added: grid-template-columns: 1fr 1fr;
318 Added: width: 100%;
319 Added: border-radius: 0.5rem;
320 Added: }
321 Added:
322 Added: .timeframe-button {
323 Added: border-top: 1px solid var(--border);
324 Added: }
325 Added:
326 Added: .timeframe-button:nth-child(1),
327 Added: .timeframe-button:nth-child(2) {
328 Added: border-top: 0;
329 Added: }
330 Added:
331 Added: .timeframe-button:nth-child(odd) {
332 Added: border-left: 0;
266 333 }
267 334
268 335 .section-heading {
roles/dashboard/public/js/dashboard.js
index 6f3f5fd6..138bbbbc 100644..100644
@@ -1,5 +1,31 @@
1 1 const charts = new Map();
2 2 const OFFLINE_AFTER_MS = 60 * 1000;
3 Added: const DEFAULT_TIMEFRAME = "day";
4 Added: const TIMEFRAMES = {
5 Added: hour: {
6 Added: ms: 60 * 60 * 1000,
7 Added: ticks: 6
8 Added: },
9 Added: day: {
10 Added: ms: 24 * 60 * 60 * 1000,
11 Added: ticks: 8
12 Added: },
13 Added: week: {
14 Added: ms: 7 * 24 * 60 * 60 * 1000,
15 Added: ticks: 7
16 Added: },
17 Added: year: {
18 Added: ms: 365 * 24 * 60 * 60 * 1000,
19 Added: ticks: 12
20 Added: }
21 Added: };
22 Added: const MIN_Y_SPAN = {
23 Added: ph: 2,
24 Added: do: 5,
25 Added: orp: 200,
26 Added: ec: 500
27 Added: };
28 Added: let activeTimeframe = DEFAULT_TIMEFRAME;
3 29
4 30 function probeRows() {
5 31 return Array.from(document.querySelectorAll("[data-latest-probe]"));
@@ -22,12 +48,7 @@
22 48 }
23 49
24 50 function labelForRow(row) {
25 Removed: const date = new Date(row.timestamp);
26 Removed: return date.toLocaleTimeString([], {
27 Removed: hour: "2-digit",
28 Removed: minute: "2-digit",
29 Removed: second: "2-digit"
30 Removed: });
51 Added: return formatChartTime(row.timestamp);
31 52 }
32 53
33 54 function formatDateTime(timestamp) {
@@ -74,6 +95,68 @@
74 95 children.forEach(child => parent.appendChild(child));
75 96 }
76 97
98 Added: function selectedTimeframe() {
99 Added: return TIMEFRAMES[activeTimeframe] ? activeTimeframe : DEFAULT_TIMEFRAME;
100 Added: }
101 Added:
102 Added: function timeframeSince() {
103 Added: const timeframe = TIMEFRAMES[selectedTimeframe()];
104 Added: return new Date(Date.now() - timeframe.ms).toISOString().replace(/\.\d{3}Z$/, "Z");
105 Added: }
106 Added:
107 Added: function formatChartTime(timestamp) {
108 Added: const date = new Date(timestamp);
109 Added: const timeframe = selectedTimeframe();
110 Added:
111 Added: if (timeframe === "hour") {
112 Added: return date.toLocaleTimeString([], {
113 Added: hour: "2-digit",
114 Added: minute: "2-digit"
115 Added: });
116 Added: }
117 Added:
118 Added: if (timeframe === "day") {
119 Added: return date.toLocaleTimeString([], {
120 Added: hour: "2-digit",
121 Added: minute: "2-digit"
122 Added: });
123 Added: }
124 Added:
125 Added: if (timeframe === "week") {
126 Added: return date.toLocaleString([], {
127 Added: weekday: "short",
128 Added: hour: "2-digit"
129 Added: });
130 Added: }
131 Added:
132 Added: return date.toLocaleDateString([], {
133 Added: month: "short",
134 Added: day: "numeric"
135 Added: });
136 Added: }
137 Added:
138 Added: function yAxisBounds(probe, values) {
139 Added: const numbers = values.filter(Number.isFinite);
140 Added:
141 Added: if (!numbers.length) {
142 Added: return {};
143 Added: }
144 Added:
145 Added: const min = Math.min(...numbers);
146 Added: const max = Math.max(...numbers);
147 Added: const center = (min + max) / 2;
148 Added: const observedSpan = max - min;
149 Added: const configuredSpan = MIN_Y_SPAN[probe] || 1;
150 Added: const relativeSpan = Math.abs(center) * 0.2;
151 Added: const span = Math.max(observedSpan * 2.5, configuredSpan, relativeSpan);
152 Added: const halfSpan = span / 2;
153 Added:
154 Added: return {
155 Added: min: center - halfSpan,
156 Added: max: center + halfSpan
157 Added: };
158 Added: }
159 Added:
77 160 function statusLabel(state) {
78 161 return {
79 162 online: "Online",
@@ -216,7 +299,11 @@
216 299 const status = statusElement(probe);
217 300
218 301 try {
219 Removed: const response = await fetch(`/api/readings/${probe}?limit=300`);
302 Added: const params = new URLSearchParams({
303 Added: limit: "2000",
304 Added: since: timeframeSince()
305 Added: });
306 Added: const response = await fetch(`/api/readings/${probe}?${params}`);
220 307
221 308 if (!response.ok) {
222 309 throw new Error(`HTTP ${response.status}`);
@@ -228,6 +315,7 @@
228 315 const labels = rows.map(labelForRow);
229 316 const values = rows.map(row => Number(row.value));
230 317 const unit = rows.length ? rows[rows.length - 1].unit : "";
318 Added: const yBounds = yAxisBounds(probe, values);
231 319
232 320 if (!charts.has(probe)) {
233 321 const ctx = chartElement(probe);
@@ -250,11 +338,12 @@
250 338 scales: {
251 339 x: {
252 340 ticks: {
253 Removed: maxTicksLimit: 8
341 Added: maxTicksLimit: TIMEFRAMES[selectedTimeframe()].ticks
254 342 }
255 343 },
256 344 y: {
257 Removed: beginAtZero: false
345 Added: beginAtZero: false,
346 Added: ...yBounds
258 347 }
259 348 },
260 349 plugins: {
@@ -276,6 +365,9 @@
276 365 chart.data.labels = labels;
277 366 chart.data.datasets[0].data = values;
278 367 chart.data.datasets[0].label = `${probe} ${unit ? `(${unit})` : ""}`;
368 Added: chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[selectedTimeframe()].ticks;
369 Added: chart.options.scales.y.min = yBounds.min;
370 Added: chart.options.scales.y.max = yBounds.max;
279 371 chart.update();
280 372 }
281 373
@@ -311,9 +403,25 @@
311 403 loadProbe(probe);
312 404 }
313 405
406 Added: function setTimeframe(timeframe) {
407 Added: activeTimeframe = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
408 Added:
409 Added: document.querySelectorAll(".timeframe-button").forEach(button => {
410 Added: button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
411 Added: });
412 Added:
413 Added: loadProbe(activeProbe());
414 Added: }
415 Added:
314 416 document.querySelectorAll(".tab-button").forEach(button => {
315 417 button.addEventListener("click", () => {
316 418 activateTab(button.dataset.probe);
419 Added: });
420 Added: });
421 Added:
422 Added: document.querySelectorAll(".timeframe-button").forEach(button => {
423 Added: button.addEventListener("click", () => {
424 Added: setTimeframe(button.dataset.timeframe);
317 425 });
318 426 });
319 427
roles/dashboard/t/basic.t
index d173c1a5..2625ac6e 100644..100644
@@ -116,6 +116,10 @@
116 116 ->status_is(200)
117 117 ->content_like(qr/FAPG DAQ Dashboard/)
118 118 ->content_like(qr/Dissolved Oxygen/)
119 Added: ->content_like(qr/Hourly/)
120 Added: ->content_like(qr/Daily/)
121 Added: ->content_like(qr/Weekly/)
122 Added: ->content_like(qr/Yearly/)
119 123 ->content_like(qr/Last 10 pH Reading Messages/)
120 124 ->content_like(qr/Last 10 Dissolved Oxygen Reading Messages/)
121 125 ->content_like(qr/status-pill-list/)
@@ -128,6 +132,15 @@
128 132 ->json_is( '/readings/0/received_at' => '2026-07-05T12:00:10Z' )
129 133 ->json_is( '/readings/0/value' => 7.12 )
130 134 ->json_hasnt('/readings/1');
135 Added:
136 Added: $t->get_ok('/api/readings/ph?since=2026-07-05T12:00:05Z')
137 Added: ->status_is(200)
138 Added: ->json_is( '/readings/0/value' => 7.12 )
139 Added: ->json_hasnt('/readings/1');
140 Added:
141 Added: $t->get_ok('/api/readings/ph?since=2026-07-05T12:00:15Z')
142 Added: ->status_is(200)
143 Added: ->json_hasnt('/readings/0');
131 144
132 145 $t->get_ok('/api/readings/do')
133 146 ->status_is(200)
roles/dashboard/templates/dashboard/index.html.ep
index 1aae490d..82663379 100644..100644
@@ -48,10 +48,18 @@
48 48
49 49 <div class="panel-heading">
50 50 <h2><%= $probe->{label} %></h2>
51 Removed: <p>
52 Removed: Unit:
53 Removed: <strong><%= $probe->{unit} %></strong>
54 Removed: </p>
51 Added: <div class="panel-tools">
52 Added: <p>
53 Added: Unit:
54 Added: <strong><%= $probe->{unit} %></strong>
55 Added: </p>
56 Added: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
57 Added: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
58 Added: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
59 Added: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
60 Added: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
61 Added: </div>
62 Added: </div>
55 63 </div>
56 64
57 65 <div class="chart-wrap">