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.

Commit
02bc569cbfdccc5ced2ccad5c0f09354f1dfcbac
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Pages.pm
index 0cbcdd25..7ded1a66 100644..100644
@@ -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
index 354535eb..f6eb8101 100644..100644
@@ -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
index d9a6317c..a951f991 100644..100644
@@ -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
index 2794b9b2..7a9189e5 100644..100644
@@ -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
index 75bc1704..c6120201 100644..100644
@@ -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 &mdash; <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 &mdash; <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 &mdash; each line is one day, bold line is the 7-day average
66 Added: Day/night DO cycle &mdash; each line is one day, bold is the average &mdash; <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">&minus;</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 &#8596; DO Relationship</h3>
128 151 <p class="text-body-secondary small mb-0 mt-1">
129 Removed: Nitrification link &mdash; DO drops often precede pH drops as bacteria consume oxygen and produce acid
152 Added: Nitrification link &mdash; DO drops often precede pH drops
130 153 </p>
131 154 </div>
132 155 <div class="card-body">