[Perl] DAQ system for the FAPG.
refactor restructure insights into At a Glance / Explore
Split the insights page into two clear sections: At a Glance — fixed-window charts with no user controls: - System Stability (7 days) - Rate of Change (48 hours) - DO Diurnal Pattern (7 days) Explore — user-controlled timeframe with inline graph controls: - DO & ORP overlay - EC Trend & Rate - pH ↔ DO Relationship Remove the global toolbar from insights; the Explore section now manages its own timeframe state and inline controls independently from the rest of the dashboard.
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Pages.pm
@@ -69,13 +69,11 @@
69
69
70
70
sub insights ($self) {
71
71
$self->render(
72
Removed:
template => 'dashboard/insights',
73
Removed:
probes => $self->probes,
74
Removed:
nav_page => 'insights',
75
Removed:
breadcrumbs =>
72
Added:
template => 'dashboard/insights',
73
Added:
probes => $self->probes,
74
Added:
nav_page => 'insights',
75
Added:
breadcrumbs =>
76
76
[ { label => 'Home', href => '/' }, { label => 'Insights' }, ],
77
Removed:
graph_control_aria_label => 'Insights chart timeframe',
78
Removed:
default_timeframe => 'month',
79
77
);
80
78
}
81
79
roles/dashboard/public/js/dashboard.js
@@ -8,7 +8,7 @@
8
8
9
9
const timeframe = createTimeframeState();
10
10
const probes = createProbeCharts(timeframe);
11
Removed:
const insights = createInsightsCharts(timeframe);
11
Added:
const insights = createInsightsCharts();
12
12
const loadStatuses = createStatusLoader();
13
13
14
14
document.querySelectorAll(".tab-button").forEach(button => {
@@ -17,7 +17,6 @@
17
17
18
18
initialiseGraphControls(timeframe, () => {
19
19
probes.reloadDisplayed();
20
Removed:
insights.reload();
21
20
});
22
21
23
22
window.addEventListener("hashchange", () => {
roles/dashboard/public/js/dashboard/insights.js
@@ -1,6 +1,8 @@
1
1
import { fetchProbeData } from "./api.js";
2
Removed:
import { READING_RANGES } from "./constants.js";
2
Added:
import { READING_RANGES, TIMEFRAMES } from "./constants.js";
3
3
import { formatChartTime, formatDateTime } from "./format.js";
4
Added:
import { createTimeframeState, timeframeRangeLabel } from "./timeframe.js";
5
Added:
import { initialiseGraphControls } from "./graph-controls.js";
4
6
5
7
const PROBE_COLORS = {
6
8
ph: { border: "#8b5cf6", background: "rgba(139, 92, 246, 0.12)" },
@@ -31,32 +33,17 @@
31
33
};
32
34
33
35
let charts = {};
34
Removed:
let activeRequests = [];
35
36
36
Removed:
function abortAll() {
37
Removed:
activeRequests.forEach(c => c.abort());
38
Removed:
activeRequests = [];
39
Removed:
}
40
Removed:
41
Removed:
function newController() {
42
Removed:
const c = new AbortController();
43
Removed:
activeRequests.push(c);
44
Removed:
return c;
45
Removed:
}
46
Removed:
47
Removed:
function removeController(c) {
48
Removed:
activeRequests = activeRequests.filter(x => x !== c);
49
Removed:
}
50
Removed:
51
37
async function fetchJson(url, signal) {
52
38
const response = await fetch(url, signal ? { signal } : {});
53
39
if (!response.ok) throw new Error(`HTTP ${response.status}`);
54
40
return response.json();
55
41
}
56
42
57
Removed:
// ──────────────────────────────────────────────────────────────────
58
Removed:
// Chart 1: System Stability
59
Removed:
// ──────────────────────────────────────────────────────────────────
43
Added:
// ══════════════════════════════════════════════════════════════════
44
Added:
// AT A GLANCE — Fixed-window charts (no user controls)
45
Added:
// ══════════════════════════════════════════════════════════════════
46
Added:
60
47
async function loadStability(signal) {
61
48
const canvas = document.getElementById("chart-stability");
62
49
if (!canvas) return;
@@ -102,19 +89,18 @@
102
89
}
103
90
}
104
91
105
Removed:
// ──────────────────────────────────────────────────────────────────
106
Removed:
// Chart 2: Rate of Change (Derivatives)
107
Removed:
// ──────────────────────────────────────────────────────────────────
108
92
async function loadDerivatives(signal) {
109
93
const canvas = document.getElementById("chart-derivatives");
110
94
if (!canvas) return;
111
95
112
96
const data = await fetchJson("/api/insights/derivatives?hours=48", signal);
113
97
const probeKeys = Object.keys(data.probes);
98
Added:
const refProbe = probeKeys.find(p => data.probes[p].length > 0);
99
Added:
const labels = refProbe ? data.probes[refProbe].map(p => formatDateTime(p.timestamp)) : [];
114
100
115
101
const datasets = probeKeys.map(probe => ({
116
102
label: probe.toUpperCase(),
117
Removed:
data: data.probes[probe].map(p => ({ x: p.timestamp, y: p.value })),
103
Added:
data: data.probes[probe].map(p => p.value),
118
104
borderColor: PROBE_COLORS[probe]?.border || "#64748b",
119
105
backgroundColor: PROBE_COLORS[probe]?.background || "rgba(100,116,139,0.1)",
120
106
tension: 0.3,
@@ -123,10 +109,6 @@
123
109
fill: false
124
110
}));
125
111
126
Removed:
// Use timestamps from first probe with data as labels
127
Removed:
const refProbe = probeKeys.find(p => data.probes[p].length > 0);
128
Removed:
const labels = refProbe ? data.probes[refProbe].map(p => formatDateTime(p.timestamp)) : [];
129
Removed:
130
112
if (!charts.derivatives) {
131
113
charts.derivatives = new Chart(canvas, {
132
114
type: "line",
@@ -155,9 +137,6 @@
155
137
}
156
138
}
157
139
158
Removed:
// ──────────────────────────────────────────────────────────────────
159
Removed:
// Chart 3: DO Diurnal Pattern
160
Removed:
// ──────────────────────────────────────────────────────────────────
161
140
async function loadDiurnal(signal) {
162
141
const canvas = document.getElementById("chart-diurnal");
163
142
if (!canvas) return;
@@ -176,7 +155,6 @@
176
155
fill: false
177
156
}));
178
157
179
Removed:
// Add the mean trace on top (bold)
180
158
datasets.push({
181
159
label: "7-day average",
182
160
data: data.mean,
@@ -213,16 +191,16 @@
213
191
}
214
192
}
215
193
216
Removed:
// ──────────────────────────────────────────────────────────────────
217
Removed:
// Chart 4: DO & ORP Dual-Axis Overlay
218
Removed:
// ──────────────────────────────────────────────────────────────────
194
Added:
// ══════════════════════════════════════════════════════════════════
195
Added:
// EXPLORE — User-controlled timeframe charts
196
Added:
// ══════════════════════════════════════════════════════════════════
197
Added:
219
198
async function loadDoOrpOverlay(timeframe, signal) {
220
199
const canvas = document.getElementById("chart-overlay-do-orp");
221
200
if (!canvas) return;
222
201
223
202
const tf = timeframe.selected();
224
203
const range = timeframe.range();
225
Removed:
226
204
const [doData, orpData] = await Promise.all([
227
205
fetchProbeData("do", tf, range, false, signal),
228
206
fetchProbeData("orp", tf, range, false, signal)
@@ -297,27 +275,23 @@
297
275
}
298
276
}
299
277
300
Removed:
// ──────────────────────────────────────────────────────────────────
301
Removed:
// Chart 5: EC Trend & Rate
302
Removed:
// ──────────────────────────────────────────────────────────────────
303
278
async function loadEcRate(timeframe, signal) {
304
279
const canvas = document.getElementById("chart-ec-rate");
305
280
if (!canvas) return;
306
281
307
282
const tf = timeframe.selected();
308
283
const range = timeframe.range();
284
Added:
const ecData = await fetchProbeData("ec", tf, range, false, signal);
309
285
310
Removed:
const [ecData, derivData] = await Promise.all([
311
Removed:
fetchProbeData("ec", tf, range, false, signal),
312
Removed:
fetchJson("/api/insights/derivatives?hours=168", signal)
313
Removed:
]);
314
Removed:
315
286
const labels = ecData.points.map(p => formatChartTime(p.timestamp, tf));
316
287
const ecValues = ecData.points.map(p => p.value);
317
Removed:
const ecRatePoints = derivData.probes.ec || [];
318
Removed:
const rateValues = ecRatePoints.map(p => p.value);
319
Removed:
const rateLabels = ecRatePoints.map(p => formatDateTime(p.timestamp));
320
288
289
Added:
// Compute rate from the series points directly
290
Added:
const rateValues = ecValues.map((v, i) => {
291
Added:
if (i === 0 || v === null || ecValues[i - 1] === null) return null;
292
Added:
return v - ecValues[i - 1];
293
Added:
});
294
Added:
321
295
if (!charts.ecRate) {
322
296
charts.ecRate = new Chart(canvas, {
323
297
type: "line",
@@ -336,7 +310,7 @@
336
310
fill: true
337
311
},
338
312
{
339
Removed:
label: "EC rate (Δ/h)",
313
Added:
label: "EC change (Δ)",
340
314
data: rateValues,
341
315
borderColor: "#f59e0b",
342
316
backgroundColor: "rgba(245, 158, 11, 0.1)",
@@ -365,7 +339,7 @@
365
339
yRate: {
366
340
type: "linear",
367
341
position: "right",
368
Removed:
title: { display: true, text: "Δ/hour", color: "#f59e0b" },
342
Added:
title: { display: true, text: "Δ per bucket", color: "#f59e0b" },
369
343
ticks: { color: "#f59e0b" },
370
344
grid: { drawOnChartArea: false }
371
345
}
@@ -384,16 +358,12 @@
384
358
}
385
359
}
386
360
387
Removed:
// ──────────────────────────────────────────────────────────────────
388
Removed:
// Chart 6: pH ↔ DO Relationship
389
Removed:
// ──────────────────────────────────────────────────────────────────
390
361
async function loadPhDoLag(timeframe, signal) {
391
362
const canvas = document.getElementById("chart-ph-do-lag");
392
363
if (!canvas) return;
393
364
394
365
const tf = timeframe.selected();
395
366
const range = timeframe.range();
396
Removed:
397
367
const [phData, doData] = await Promise.all([
398
368
fetchProbeData("ph", tf, range, false, signal),
399
369
fetchProbeData("do", tf, range, false, signal)
@@ -468,44 +438,63 @@
468
438
}
469
439
}
470
440
471
Removed:
// ──────────────────────────────────────────────────────────────────
441
Added:
// ══════════════════════════════════════════════════════════════════
472
442
// Public API
473
Removed:
// ──────────────────────────────────────────────────────────────────
474
Removed:
export function createInsightsCharts(timeframe) {
475
Removed:
// Only activate if at least one insights canvas exists
443
Added:
// ══════════════════════════════════════════════════════════════════
444
Added:
445
Added:
export function createInsightsCharts() {
476
446
const hasCanvas = document.getElementById("chart-stability")
477
Removed:
|| document.getElementById("chart-diurnal");
447
Added:
|| document.getElementById("chart-overlay-do-orp");
478
448
479
449
if (!hasCanvas) return { reload: () => {} };
480
450
481
Removed:
async function load() {
482
Removed:
abortAll();
483
Removed:
const controller = newController();
484
Removed:
const signal = controller.signal;
451
Added:
// At a Glance: fixed loads, no timeframe dependency
452
Added:
let glanceController = null;
485
453
486
Removed:
try {
487
Removed:
await Promise.allSettled([
488
Removed:
loadStability(signal),
489
Removed:
loadDerivatives(signal),
490
Removed:
loadDiurnal(signal),
491
Removed:
loadDoOrpOverlay(timeframe, signal),
492
Removed:
loadEcRate(timeframe, signal),
493
Removed:
loadPhDoLag(timeframe, signal)
494
Removed:
]);
495
Removed:
} catch (error) {
496
Removed:
if (error.name === "AbortError") return;
497
Removed:
console.error("Insights load failed:", error);
498
Removed:
} finally {
499
Removed:
removeController(controller);
500
Removed:
}
454
Added:
function loadGlance() {
455
Added:
if (glanceController) glanceController.abort();
456
Added:
glanceController = new AbortController();
457
Added:
const signal = glanceController.signal;
458
Added:
459
Added:
return Promise.allSettled([
460
Added:
loadStability(signal),
461
Added:
loadDerivatives(signal),
462
Added:
loadDiurnal(signal)
463
Added:
]);
501
464
}
502
465
503
Removed:
load();
466
Added:
// Explore: own timeframe state + own graph controls
467
Added:
const exploreTimeframe = createTimeframeState();
468
Added:
let exploreController = null;
504
469
505
Removed:
// Highlight nav pill on scroll
470
Added:
function loadExplore() {
471
Added:
if (exploreController) exploreController.abort();
472
Added:
exploreController = new AbortController();
473
Added:
const signal = exploreController.signal;
474
Added:
475
Added:
return Promise.allSettled([
476
Added:
loadDoOrpOverlay(exploreTimeframe, signal),
477
Added:
loadEcRate(exploreTimeframe, signal),
478
Added:
loadPhDoLag(exploreTimeframe, signal)
479
Added:
]);
480
Added:
}
481
Added:
482
Added:
// Wire up the inline Explore graph controls
483
Added:
initialiseGraphControls(exploreTimeframe, () => loadExplore());
484
Added:
485
Added:
// Initial load
486
Added:
loadGlance();
487
Added:
loadExplore();
488
Added:
489
Added:
// Scroll spy
506
490
initScrollSpy();
507
491
508
Removed:
return { reload: load };
492
Added:
return {
493
Added:
reload() {
494
Added:
loadGlance();
495
Added:
loadExplore();
496
Added:
}
497
Added:
};
509
498
}
510
499
511
500
// ──────────────────────────────────────────────────────────────────
roles/dashboard/t/08-insights.t
@@ -23,12 +23,10 @@
23
23
$t->get_ok('/insights')
24
24
->status_is(200)
25
25
->element_exists('nav.insights-nav')
26
Removed:
->element_exists('a.nav-link[href="#system-overview"]')
27
Removed:
->element_exists('a.nav-link[href="#dissolved-oxygen"]')
28
Removed:
->element_exists('a.nav-link[href="#nutrient-cycling"]')
29
Removed:
->element_exists('section#system-overview')
30
Removed:
->element_exists('section#dissolved-oxygen')
31
Removed:
->element_exists('section#nutrient-cycling');
26
Added:
->element_exists('a.nav-link[href="#at-a-glance"]')
27
Added:
->element_exists('a.nav-link[href="#explore"]')
28
Added:
->element_exists('section#at-a-glance')
29
Added:
->element_exists('section#explore');
32
30
};
33
31
34
32
subtest 'insights page has all chart canvases' => sub {
roles/dashboard/templates/dashboard/insights.html.ep
@@ -11,20 +11,19 @@
11
11
<nav class="insights-nav mb-4" aria-label="Insight sections">
12
12
<ul class="nav nav-pills nav-fill flex-nowrap overflow-x-auto">
13
13
<li class="nav-item">
14
Removed:
<a class="nav-link" href="#system-overview">System Overview</a>
14
Added:
<a class="nav-link" href="#at-a-glance">At a Glance</a>
15
15
</li>
16
16
<li class="nav-item">
17
Removed:
<a class="nav-link" href="#dissolved-oxygen">Dissolved Oxygen</a>
17
Added:
<a class="nav-link" href="#explore">Explore</a>
18
18
</li>
19
Removed:
<li class="nav-item">
20
Removed:
<a class="nav-link" href="#nutrient-cycling">Nutrient Cycling</a>
21
Removed:
</li>
22
19
</ul>
23
20
</nav>
24
21
25
Removed:
<!-- THEME 1: System Overview -->
26
Removed:
<section id="system-overview" class="insight-theme mb-5">
27
Removed:
<h2 class="h5 fw-bold mb-3 text-body-secondary">System Overview</h2>
22
Added:
<!-- ════════════════════════════════════════════════════════════════
23
Added:
AT A GLANCE — Fixed-window, no user controls
24
Added:
════════════════════════════════════════════════════════════════ -->
25
Added:
<section id="at-a-glance" class="insight-theme mb-5">
26
Added:
<h2 class="h5 fw-bold mb-3 text-body-secondary">At a Glance</h2>
28
27
29
28
<div class="row g-3">
30
29
<div class="col-12" id="stability">
@@ -32,12 +31,12 @@
32
31
<div class="card-header">
33
32
<h3 class="h6 mb-0">System Stability</h3>
34
33
<p class="text-body-secondary small mb-0 mt-1">
35
Removed:
Rolling volatility score across all probes (lower = more stable)
34
Added:
Rolling volatility across all probes — <strong>last 7 days</strong>
36
35
</p>
37
36
</div>
38
37
<div class="card-body">
39
38
<div class="chart-wrap">
40
Removed:
<canvas id="chart-stability" role="img" aria-label="System stability score over time"></canvas>
39
Added:
<canvas id="chart-stability" role="img" aria-label="System stability score over the last 7 days"></canvas>
41
40
</div>
42
41
</div>
43
42
</div>
@@ -48,40 +47,71 @@
48
47
<div class="card-header">
49
48
<h3 class="h6 mb-0">Rate of Change</h3>
50
49
<p class="text-body-secondary small mb-0 mt-1">
51
Removed:
How fast each parameter is changing (per hour)
50
Added:
How fast each parameter is moving — <strong>last 48 hours</strong>
52
51
</p>
53
52
</div>
54
53
<div class="card-body">
55
54
<div class="chart-wrap">
56
Removed:
<canvas id="chart-derivatives" role="img" aria-label="Rate of change per probe"></canvas>
55
Added:
<canvas id="chart-derivatives" role="img" aria-label="Rate of change per probe over 48 hours"></canvas>
57
56
</div>
58
57
</div>
59
58
</div>
60
59
</div>
61
Removed:
</div>
62
Removed:
</section>
63
60
64
Removed:
<!-- THEME 2: Dissolved Oxygen -->
65
Removed:
<section id="dissolved-oxygen" class="insight-theme mb-5">
66
Removed:
<h2 class="h5 fw-bold mb-3 text-body-secondary">Dissolved Oxygen</h2>
67
Removed:
68
Removed:
<div class="row g-3">
69
61
<div class="col-12" id="diurnal">
70
62
<div class="card">
71
63
<div class="card-header">
72
64
<h3 class="h6 mb-0">DO Diurnal Pattern</h3>
73
65
<p class="text-body-secondary small mb-0 mt-1">
74
Removed:
Day/night DO cycle — each line is one day, bold line is the 7-day average
66
Added:
Day/night DO cycle — each line is one day, bold is the average — <strong>last 7 days</strong>
75
67
</p>
76
68
</div>
77
69
<div class="card-body">
78
70
<div class="chart-wrap">
79
Removed:
<canvas id="chart-diurnal" role="img" aria-label="Dissolved oxygen diurnal overlay"></canvas>
71
Added:
<canvas id="chart-diurnal" role="img" aria-label="Dissolved oxygen diurnal overlay over 7 days"></canvas>
80
72
</div>
81
73
</div>
82
74
</div>
83
75
</div>
76
Added:
</div>
77
Added:
</section>
84
78
79
Added:
<!-- ════════════════════════════════════════════════════════════════
80
Added:
EXPLORE — User-controlled timeframe
81
Added:
════════════════════════════════════════════════════════════════ -->
82
Added:
<section id="explore" class="insight-theme mb-5">
83
Added:
<div class="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-3">
84
Added:
<h2 class="h5 fw-bold mb-0 text-body-secondary">Explore</h2>
85
Added:
<div class="graph-control-scroll overflow-x-auto" tabindex="0" role="region" aria-label="Explore chart timeframe" data-default-timeframe="month">
86
Added:
<div class="graph-control d-inline-flex gap-2 flex-nowrap">
87
Added:
<div class="input-group input-group-sm range-control" aria-label="Adjust range">
88
Added:
<button
89
Added:
class="btn btn-outline-secondary"
90
Added:
type="button"
91
Added:
data-range-adjustment="decrease"
92
Added:
aria-label="Decrease range">−</button>
93
Added:
<span
94
Added:
class="input-group-text range-label"
95
Added:
data-range-label
96
Added:
aria-live="polite">1</span>
97
Added:
<button
98
Added:
class="btn btn-outline-secondary"
99
Added:
type="button"
100
Added:
data-range-adjustment="increase"
101
Added:
aria-label="Increase range">+</button>
102
Added:
</div>
103
Added:
<div class="btn-group timeframe-control" role="group" aria-label="Select timeframe">
104
Added:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="hour" data-timeframe-label="Hourly">Hourly</button>
105
Added:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="day" data-timeframe-label="Daily">Daily</button>
106
Added:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="week" data-timeframe-label="Weekly">Weekly</button>
107
Added:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="month" data-timeframe-label="Monthly">Monthly</button>
108
Added:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="year" data-timeframe-label="Yearly">Yearly</button>
109
Added:
</div>
110
Added:
</div>
111
Added:
</div>
112
Added:
</div>
113
Added:
114
Added:
<div class="row g-3">
85
115
<div class="col-12" id="do-orp-overlay">
86
116
<div class="card">
87
117
<div class="card-header">
@@ -97,14 +127,7 @@
97
127
</div>
98
128
</div>
99
129
</div>
100
Removed:
</div>
101
Removed:
</section>
102
130
103
Removed:
<!-- THEME 3: Nutrient Cycling -->
104
Removed:
<section id="nutrient-cycling" class="insight-theme mb-5">
105
Removed:
<h2 class="h5 fw-bold mb-3 text-body-secondary">Nutrient Cycling</h2>
106
Removed:
107
Removed:
<div class="row g-3">
108
131
<div class="col-12" id="ec-rate">
109
132
<div class="card">
110
133
<div class="card-header">
@@ -126,7 +149,7 @@
126
149
<div class="card-header">
127
150
<h3 class="h6 mb-0">pH ↔ DO Relationship</h3>
128
151
<p class="text-body-secondary small mb-0 mt-1">
129
Removed:
Nitrification link — DO drops often precede pH drops as bacteria consume oxygen and produce acid
152
Added:
Nitrification link — DO drops often precede pH drops
130
153
</p>
131
154
</div>
132
155
<div class="card-body">