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.

Commit
df76ed7508d0c724c54c0b20a45972f1c9bcd497
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard/insights.js
index a951f991..b2967c9f 100644..100644
@@ -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
index 7a9189e5..a47d91c4 100644..100644
@@ -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
index c6120201..65230676 100644..100644
@@ -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 &mdash; <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 &mdash; <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 &mdash; each line is one day, bold is the average &mdash; <strong>last 7 days</strong>
69 Added: Day/night DO cycle &mdash; 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">&minus;</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">