[Perl] DAQ system for the FAPG.
insights: move timeframe controls to page top
All charts (At a Glance + Explore) now share a single page-level timeframe control, placed in the header next to the page title. Uses the shared graph-control.html.ep partial with default 'day'. Previously, only the Explore section had user-controlled timeframe while At a Glance was fixed to 7 days / 48 hours.
Changed files
roles/dashboard/public/js/dashboard/insights.js
@@ -40,15 +40,24 @@
40
40
return response.json();
41
41
}
42
42
43
Added:
/** Convert a timeframe selection to an API hours parameter */
44
Added:
function timeframeToHours(timeframe) {
45
Added:
const tf = timeframe.selected();
46
Added:
const range = timeframe.range();
47
Added:
const ms = TIMEFRAMES[tf]?.ms || TIMEFRAMES.day.ms;
48
Added:
return Math.round((ms * range) / (60 * 60 * 1000));
49
Added:
}
50
Added:
43
51
// ══════════════════════════════════════════════════════════════════
44
Removed:
// AT A GLANCE — Fixed-window charts (no user controls)
52
Added:
// AT A GLANCE — Uses the page-level timeframe
45
53
// ══════════════════════════════════════════════════════════════════
46
54
47
Removed:
async function loadStability(signal) {
55
Added:
async function loadStability(timeframe, signal) {
48
56
const canvas = document.getElementById("chart-stability");
49
57
if (!canvas) return;
50
58
51
Removed:
const data = await fetchJson("/api/insights/stability?hours=168", signal);
59
Added:
const hours = timeframeToHours(timeframe);
60
Added:
const data = await fetchJson(`/api/insights/stability?hours=${hours}`, signal);
52
61
const labels = data.points.map(p => formatDateTime(p.timestamp));
53
62
const values = data.points.map(p => p.score);
54
63
@@ -89,11 +98,12 @@
89
98
}
90
99
}
91
100
92
Removed:
async function loadDerivatives(signal) {
101
Added:
async function loadDerivatives(timeframe, signal) {
93
102
const canvas = document.getElementById("chart-derivatives");
94
103
if (!canvas) return;
95
104
96
Removed:
const data = await fetchJson("/api/insights/derivatives?hours=48", signal);
105
Added:
const hours = timeframeToHours(timeframe);
106
Added:
const data = await fetchJson(`/api/insights/derivatives?hours=${hours}`, signal);
97
107
const probeKeys = Object.keys(data.probes);
98
108
const refProbe = probeKeys.find(p => data.probes[p].length > 0);
99
109
const labels = refProbe ? data.probes[refProbe].map(p => formatDateTime(p.timestamp)) : [];
@@ -137,11 +147,14 @@
137
147
}
138
148
}
139
149
140
Removed:
async function loadDiurnal(signal) {
150
Added:
async function loadDiurnal(timeframe, signal) {
141
151
const canvas = document.getElementById("chart-diurnal");
142
152
if (!canvas) return;
143
153
144
Removed:
const data = await fetchJson("/api/insights/diurnal/do", signal);
154
Added:
const hours = timeframeToHours(timeframe);
155
Added:
const days = Math.max(1, Math.round(hours / 24));
156
Added:
157
Added:
const data = await fetchJson(`/api/insights/diurnal/do?days=${days}`, signal);
145
158
const labels = data.hours.map(h => `${String(h).padStart(2, "0")}:00`);
146
159
147
160
const datasets = data.traces.map((trace, i) => ({
@@ -156,7 +169,7 @@
156
169
}));
157
170
158
171
datasets.push({
159
Removed:
label: "7-day average",
172
Added:
label: `${days}-day average`,
160
173
data: data.mean,
161
174
borderColor: PROBE_COLORS.do.border,
162
175
borderWidth: 3,
@@ -192,7 +205,7 @@
192
205
}
193
206
194
207
// ══════════════════════════════════════════════════════════════════
195
Removed:
// EXPLORE — User-controlled timeframe charts
208
Added:
// EXPLORE — Same page-level timeframe
196
209
// ══════════════════════════════════════════════════════════════════
197
210
198
211
async function loadDoOrpOverlay(timeframe, signal) {
@@ -448,51 +461,37 @@
448
461
449
462
if (!hasCanvas) return { reload: () => {} };
450
463
451
Removed:
// At a Glance: fixed loads, no timeframe dependency
452
Removed:
let glanceController = null;
464
Added:
// Single page-level timeframe state controlling ALL charts
465
Added:
const timeframe = createTimeframeState();
466
Added:
let controller = null;
453
467
454
Removed:
function loadGlance() {
455
Removed:
if (glanceController) glanceController.abort();
456
Removed:
glanceController = new AbortController();
457
Removed:
const signal = glanceController.signal;
468
Added:
function loadAll() {
469
Added:
if (controller) controller.abort();
470
Added:
controller = new AbortController();
471
Added:
const signal = controller.signal;
458
472
459
473
return Promise.allSettled([
460
Removed:
loadStability(signal),
461
Removed:
loadDerivatives(signal),
462
Removed:
loadDiurnal(signal)
474
Added:
loadStability(timeframe, signal),
475
Added:
loadDerivatives(timeframe, signal),
476
Added:
loadDiurnal(timeframe, signal),
477
Added:
loadDoOrpOverlay(timeframe, signal),
478
Added:
loadEcRate(timeframe, signal),
479
Added:
loadPhDoLag(timeframe, signal)
463
480
]);
464
481
}
465
482
466
Removed:
// Explore: own timeframe state + own graph controls
467
Removed:
const exploreTimeframe = createTimeframeState();
468
Removed:
let exploreController = null;
483
Added:
// Wire up the page-level graph controls
484
Added:
initialiseGraphControls(timeframe, () => loadAll());
469
485
470
Removed:
function loadExplore() {
471
Removed:
if (exploreController) exploreController.abort();
472
Removed:
exploreController = new AbortController();
473
Removed:
const signal = exploreController.signal;
474
Removed:
475
Removed:
return Promise.allSettled([
476
Removed:
loadDoOrpOverlay(exploreTimeframe, signal),
477
Removed:
loadEcRate(exploreTimeframe, signal),
478
Removed:
loadPhDoLag(exploreTimeframe, signal)
479
Removed:
]);
480
Removed:
}
481
Removed:
482
Removed:
// Wire up the inline Explore graph controls
483
Removed:
initialiseGraphControls(exploreTimeframe, () => loadExplore());
484
Removed:
485
486
// Initial load
486
Removed:
loadGlance();
487
Removed:
loadExplore();
487
Added:
loadAll();
488
488
489
489
// Scroll spy
490
490
initScrollSpy();
491
491
492
492
return {
493
493
reload() {
494
Removed:
loadGlance();
495
Removed:
loadExplore();
494
Added:
loadAll();
496
495
}
497
496
};
498
497
}
roles/dashboard/t/08-insights.t
@@ -16,7 +16,7 @@
16
16
->element_exists('nav[aria-label="Breadcrumb"]')
17
17
->text_like('.breadcrumb-item.active span', qr/Insights/)
18
18
->element_exists('.navbar-nav a.nav-link.active[href="/insights"]')
19
Removed:
->element_exists('[data-default-timeframe="month"]');
19
Added:
->element_exists('[data-default-timeframe="day"]');
20
20
};
21
21
22
22
subtest 'insights page has section anchors and nav' => sub {
roles/dashboard/templates/dashboard/insights.html.ep
@@ -3,9 +3,12 @@
3
3
% stash use_charts => 1;
4
4
5
5
<section class="mb-4" aria-label="Insights visualisations">
6
Removed:
<div class="mb-3">
7
Removed:
<h1 class="h4 fw-bold mb-1">Insights</h1>
8
Removed:
<p class="text-body-secondary small mb-0">Derived visualisations for system monitoring</p>
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">
7
Added:
<div>
8
Added:
<h1 class="h4 fw-bold mb-1">Insights</h1>
9
Added:
<p class="text-body-secondary small mb-0">Derived visualisations for system monitoring</p>
10
Added:
</div>
11
Added:
%= include 'dashboard/graph-control', aria_label => 'Insights timeframe', default_timeframe => 'day'
9
12
</div>
10
13
11
14
<nav class="insights-nav mb-4" aria-label="Insight sections">
@@ -20,7 +23,7 @@
20
23
</nav>
21
24
22
25
<!-- ════════════════════════════════════════════════════════════════
23
Removed:
AT A GLANCE — Fixed-window, no user controls
26
Added:
AT A GLANCE
24
27
════════════════════════════════════════════════════════════════ -->
25
28
<section id="at-a-glance" class="insight-theme mb-5">
26
29
<h2 class="h5 fw-bold mb-3 text-body-secondary">At a Glance</h2>
@@ -31,12 +34,12 @@
31
34
<div class="card-header">
32
35
<h3 class="h6 mb-0">System Stability</h3>
33
36
<p class="text-body-secondary small mb-0 mt-1">
34
Removed:
Rolling volatility across all probes — <strong>last 7 days</strong>
37
Added:
Rolling volatility across all probes
35
38
</p>
36
39
</div>
37
40
<div class="card-body">
38
41
<div class="chart-wrap">
39
Removed:
<canvas id="chart-stability" role="img" aria-label="System stability score over the last 7 days"></canvas>
42
Added:
<canvas id="chart-stability" role="img" aria-label="System stability score"></canvas>
40
43
</div>
41
44
</div>
42
45
</div>
@@ -47,12 +50,12 @@
47
50
<div class="card-header">
48
51
<h3 class="h6 mb-0">Rate of Change</h3>
49
52
<p class="text-body-secondary small mb-0 mt-1">
50
Removed:
How fast each parameter is moving — <strong>last 48 hours</strong>
53
Added:
How fast each parameter is moving
51
54
</p>
52
55
</div>
53
56
<div class="card-body">
54
57
<div class="chart-wrap">
55
Removed:
<canvas id="chart-derivatives" role="img" aria-label="Rate of change per probe over 48 hours"></canvas>
58
Added:
<canvas id="chart-derivatives" role="img" aria-label="Rate of change per probe"></canvas>
56
59
</div>
57
60
</div>
58
61
</div>
@@ -63,12 +66,12 @@
63
66
<div class="card-header">
64
67
<h3 class="h6 mb-0">DO Diurnal Pattern</h3>
65
68
<p class="text-body-secondary small mb-0 mt-1">
66
Removed:
Day/night DO cycle — each line is one day, bold is the average — <strong>last 7 days</strong>
69
Added:
Day/night DO cycle — each line is one day, bold is the average
67
70
</p>
68
71
</div>
69
72
<div class="card-body">
70
73
<div class="chart-wrap">
71
Removed:
<canvas id="chart-diurnal" role="img" aria-label="Dissolved oxygen diurnal overlay over 7 days"></canvas>
74
Added:
<canvas id="chart-diurnal" role="img" aria-label="Dissolved oxygen diurnal overlay"></canvas>
72
75
</div>
73
76
</div>
74
77
</div>
@@ -77,39 +80,10 @@
77
80
</section>
78
81
79
82
<!-- ════════════════════════════════════════════════════════════════
80
Removed:
EXPLORE — User-controlled timeframe
83
Added:
EXPLORE — Same timeframe as above (page-level controls)
81
84
════════════════════════════════════════════════════════════════ -->
82
85
<section id="explore" class="insight-theme mb-5">
83
Removed:
<div class="d-flex flex-column flex-sm-row align-items-start align-items-sm-center justify-content-between gap-2 mb-3">
84
Removed:
<h2 class="h5 fw-bold mb-0 text-body-secondary">Explore</h2>
85
Removed:
<div class="graph-control-scroll overflow-x-auto" tabindex="0" role="region" aria-label="Explore chart timeframe" data-default-timeframe="month">
86
Removed:
<div class="graph-control d-inline-flex gap-2 flex-nowrap">
87
Removed:
<div class="input-group input-group-sm range-control" aria-label="Adjust range">
88
Removed:
<button
89
Removed:
class="btn btn-outline-secondary"
90
Removed:
type="button"
91
Removed:
data-range-adjustment="decrease"
92
Removed:
aria-label="Decrease range">−</button>
93
Removed:
<span
94
Removed:
class="input-group-text range-label"
95
Removed:
data-range-label
96
Removed:
aria-live="polite">1</span>
97
Removed:
<button
98
Removed:
class="btn btn-outline-secondary"
99
Removed:
type="button"
100
Removed:
data-range-adjustment="increase"
101
Removed:
aria-label="Increase range">+</button>
102
Removed:
</div>
103
Removed:
<div class="btn-group timeframe-control" role="group" aria-label="Select timeframe">
104
Removed:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="hour" data-timeframe-label="Hourly">Hourly</button>
105
Removed:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="day" data-timeframe-label="Daily">Daily</button>
106
Removed:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="week" data-timeframe-label="Weekly">Weekly</button>
107
Removed:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="month" data-timeframe-label="Monthly">Monthly</button>
108
Removed:
<button class="btn btn-sm btn-outline-secondary timeframe-button" type="button" data-timeframe="year" data-timeframe-label="Yearly">Yearly</button>
109
Removed:
</div>
110
Removed:
</div>
111
Removed:
</div>
112
Removed:
</div>
86
Added:
<h2 class="h5 fw-bold mb-3 text-body-secondary">Explore</h2>
113
87
114
88
<div class="row g-3">
115
89
<div class="col-12" id="do-orp-overlay">