feat expand insights page with 6 derived visualisations

Add three themed sections with anchor navigation and scroll spy: System Overview: - Stability score (rolling 6h normalised σ across all probes) - Rate of change (hourly derivative per probe) Dissolved Oxygen: - DO diurnal pattern (7-day overlay aligned by hour-of-day) - DO & ORP dual-axis time overlay Nutrient Cycling: - EC trend with rate-of-change overlay - pH ↔ DO relationship (dual-axis for nitrification lag) Backend: new Controller::Insights with /api/insights/{diurnal, derivatives, stability} endpoints computed from reading_rollups. Tests cover all new endpoints and template rendering (275 total).

Commit
1cf8ff2078a6bc77703bbb615f0e010478e047f7
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.pm
index 07cb4076..a49b8158 100644..100644
@@ -83,6 +83,10 @@
83 83 $r->get('/api/correlation/:probe_x/:probe_y')->to('Reading#correlation');
84 84 $r->get('/api/status')->to('Reading#status_list');
85 85 $r->get('/api/status/:probe')->to('Reading#status');
86 Added:
87 Added: $r->get('/api/insights/diurnal/:probe')->to('Insights#diurnal');
88 Added: $r->get('/api/insights/derivatives')->to('Insights#derivatives');
89 Added: $r->get('/api/insights/stability')->to('Insights#stability');
86 90 }
87 91
88 92 1;
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Insights.pm
index 00000000..717b2dbc 000000..100644
@@ -0,0 +1,241 @@
1 Added: package FAPG::DAQ::Dashboard::Controller::Insights;
2 Added: use Mojo::Base 'Mojolicious::Controller', -signatures;
3 Added:
4 Added: use POSIX qw(strftime);
5 Added:
6 Added: my $WEEK_SECONDS = 7 * 24 * 60 * 60;
7 Added: my $HOUR_SECONDS = 3_600;
8 Added: my $DAY_SECONDS = 24 * 60 * 60;
9 Added:
10 Added: # GET /api/insights/diurnal/:probe
11 Added: # Returns 24 hourly slots with per-day traces for the last 7 days.
12 Added: sub diurnal ($self) {
13 Added: my $probe = $self->param('probe') // '';
14 Added: my %known = map { $_->{key} => 1 } $self->probes->@*;
15 Added:
16 Added: return $self->render(
17 Added: status => 404,
18 Added: json => { error => "Unknown probe: $probe" },
19 Added: ) unless $known{$probe};
20 Added:
21 Added: my $days = $self->param('days') // 7;
22 Added: $days = 7 unless $days =~ /\A\d+\z/ && $days >= 1 && $days <= 30;
23 Added:
24 Added: my $now = time;
25 Added: my $start = $now - ( $days * $DAY_SECONDS );
26 Added: my $rollups = $self->sqlite->db->query(
27 Added: q{
28 Added: SELECT
29 Added: bucket_epoch,
30 Added: value_total / sample_count AS avg_value
31 Added: FROM reading_rollups
32 Added: WHERE probe = ?
33 Added: AND bucket_seconds = ?
34 Added: AND bucket_epoch >= ?
35 Added: AND bucket_epoch < ?
36 Added: ORDER BY bucket_epoch
37 Added: },
38 Added: $probe,
39 Added: $HOUR_SECONDS,
40 Added: $start,
41 Added: $now,
42 Added: )->hashes->to_array;
43 Added:
44 Added: # Group by day-of-week, with hour-of-day as x-axis
45 Added: my %by_day;
46 Added: for my $row (@$rollups) {
47 Added: my $epoch = $row->{bucket_epoch};
48 Added: my @t = gmtime($epoch);
49 Added: my $hour = $t[2];
50 Added: my $date = strftime( '%Y-%m-%d', @t );
51 Added:
52 Added: $by_day{$date}[$hour] = 0 + $row->{avg_value};
53 Added: }
54 Added:
55 Added: # Build traces: one array per day, plus a mean trace
56 Added: my @traces;
57 Added: my @sums = (0) x 24;
58 Added: my @counts = (0) x 24;
59 Added:
60 Added: for my $date ( sort keys %by_day ) {
61 Added: my $values = $by_day{$date};
62 Added: my @trace;
63 Added: for my $h ( 0 .. 23 ) {
64 Added: my $v = $values->[$h];
65 Added: push @trace, $v;
66 Added: if ( defined $v ) {
67 Added: $sums[$h] += $v;
68 Added: $counts[$h] += 1;
69 Added: }
70 Added: }
71 Added: push @traces, { date => $date, values => \@trace };
72 Added: }
73 Added:
74 Added: my @mean = map { $counts[$_] > 0 ? $sums[$_] / $counts[$_] : undef } 0 .. 23;
75 Added:
76 Added: $self->render(
77 Added: json => {
78 Added: probe => $probe,
79 Added: days => 0 + $days,
80 Added: hours => [ 0 .. 23 ],
81 Added: traces => \@traces,
82 Added: mean => \@mean,
83 Added: },
84 Added: );
85 Added: }
86 Added:
87 Added: # GET /api/insights/derivatives
88 Added: # Returns hourly rate-of-change for all probes over the last N hours.
89 Added: sub derivatives ($self) {
90 Added: my $hours = $self->param('hours') // 48;
91 Added: $hours = 48 unless $hours =~ /\A\d+\z/ && $hours >= 1 && $hours <= 720;
92 Added:
93 Added: my $now = time;
94 Added: my $start = $now - ( $hours * $HOUR_SECONDS );
95 Added:
96 Added: my %probe_data;
97 Added: for my $probe_info ( $self->probes->@* ) {
98 Added: my $probe = $probe_info->{key};
99 Added:
100 Added: my $rollups = $self->sqlite->db->query(
101 Added: q{
102 Added: SELECT
103 Added: bucket_epoch,
104 Added: value_total / sample_count AS avg_value
105 Added: FROM reading_rollups
106 Added: WHERE probe = ?
107 Added: AND bucket_seconds = ?
108 Added: AND bucket_epoch >= ?
109 Added: AND bucket_epoch < ?
110 Added: ORDER BY bucket_epoch
111 Added: },
112 Added: $probe,
113 Added: $HOUR_SECONDS,
114 Added: $start,
115 Added: $now,
116 Added: )->hashes->to_array;
117 Added:
118 Added: my @points;
119 Added: for my $i ( 1 .. $rollups->$#* ) {
120 Added: my $prev = $rollups->[ $i - 1 ];
121 Added: my $curr = $rollups->[$i];
122 Added: my $dt = $curr->{bucket_epoch} - $prev->{bucket_epoch};
123 Added:
124 Added: next unless $dt > 0;
125 Added:
126 Added: # Rate per hour
127 Added: my $rate = ( $curr->{avg_value} - $prev->{avg_value} )
128 Added: / ( $dt / $HOUR_SECONDS );
129 Added:
130 Added: push @points, {
131 Added: timestamp => utc_timestamp( $curr->{bucket_epoch} ),
132 Added: value => 0 + sprintf( '%.4f', $rate ),
133 Added: };
134 Added: }
135 Added:
136 Added: $probe_data{$probe} = \@points;
137 Added: }
138 Added:
139 Added: $self->render(
140 Added: json => {
141 Added: hours => 0 + $hours,
142 Added: probes => \%probe_data,
143 Added: },
144 Added: );
145 Added: }
146 Added:
147 Added: # GET /api/insights/stability
148 Added: # Returns a rolling stability score (lower = more stable).
149 Added: # Computed as the rolling 6h normalised standard deviation across all probes.
150 Added: sub stability ($self) {
151 Added: my $hours = $self->param('hours') // 168; # 7 days default
152 Added: $hours = 168 unless $hours =~ /\A\d+\z/ && $hours >= 1 && $hours <= 720;
153 Added:
154 Added: my $now = time;
155 Added: my $start = $now - ( $hours * $HOUR_SECONDS );
156 Added:
157 Added: # Optimal ranges for normalisation
158 Added: my %ranges = (
159 Added: ph => { min => 6.4, max => 7.2 },
160 Added: do => { min => 5.5, max => 10 },
161 Added: orp => { min => 250, max => 400 },
162 Added: ec => { min => 300, max => 1500 },
163 Added: );
164 Added:
165 Added: # Fetch hourly averages for all probes
166 Added: my %by_epoch;
167 Added: for my $probe_info ( $self->probes->@* ) {
168 Added: my $probe = $probe_info->{key};
169 Added: my $range = $ranges{$probe} or next;
170 Added: my $span = $range->{max} - $range->{min};
171 Added:
172 Added: my $rollups = $self->sqlite->db->query(
173 Added: q{
174 Added: SELECT
175 Added: bucket_epoch,
176 Added: value_total / sample_count AS avg_value
177 Added: FROM reading_rollups
178 Added: WHERE probe = ?
179 Added: AND bucket_seconds = ?
180 Added: AND bucket_epoch >= ?
181 Added: AND bucket_epoch < ?
182 Added: ORDER BY bucket_epoch
183 Added: },
184 Added: $probe,
185 Added: $HOUR_SECONDS,
186 Added: $start,
187 Added: $now,
188 Added: )->hashes->to_array;
189 Added:
190 Added: for my $row (@$rollups) {
191 Added: my $normalised = ( $row->{avg_value} - $range->{min} ) / $span;
192 Added: $by_epoch{ $row->{bucket_epoch} }{$probe} = $normalised;
193 Added: }
194 Added: }
195 Added:
196 Added: # Compute rolling stddev over a 6-hour window
197 Added: my $window_size = 6;
198 Added: my @sorted_epochs = sort { $a <=> $b } keys %by_epoch;
199 Added: my @points;
200 Added:
201 Added: for my $i ( $window_size - 1 .. $#sorted_epochs ) {
202 Added: my @window_epochs
203 Added: = @sorted_epochs[ $i - $window_size + 1 .. $i ];
204 Added: my @all_values;
205 Added:
206 Added: for my $epoch (@window_epochs) {
207 Added: push @all_values, values $by_epoch{$epoch}->%*;
208 Added: }
209 Added:
210 Added: next unless @all_values >= 2;
211 Added:
212 Added: my $mean = 0;
213 Added: $mean += $_ for @all_values;
214 Added: $mean /= @all_values;
215 Added:
216 Added: my $variance = 0;
217 Added: $variance += ( $_ - $mean ) ** 2 for @all_values;
218 Added: $variance /= @all_values;
219 Added:
220 Added: my $stddev = sqrt($variance);
221 Added:
222 Added: push @points, {
223 Added: timestamp => utc_timestamp( $sorted_epochs[$i] ),
224 Added: score => 0 + sprintf( '%.4f', $stddev ),
225 Added: };
226 Added: }
227 Added:
228 Added: $self->render(
229 Added: json => {
230 Added: hours => 0 + $hours,
231 Added: window => $window_size,
232 Added: points => \@points,
233 Added: },
234 Added: );
235 Added: }
236 Added:
237 Added: sub utc_timestamp ($epoch) {
238 Added: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
239 Added: }
240 Added:
241 Added: 1;
roles/dashboard/public/css/app.css
index 796bfa00..7b9743b9 100644..100644
@@ -333,3 +333,24 @@
333 333 color: #fff !important;
334 334 font-weight: 600;
335 335 }
336 Added:
337 Added: /* ----------------------------------------------------------------
338 Added: Insights page — section anchor nav with scroll spy
339 Added: ---------------------------------------------------------------- */
340 Added: .insights-nav .nav-link {
341 Added: white-space: nowrap;
342 Added: font-size: 0.82rem;
343 Added: font-weight: 600;
344 Added: color: var(--fapg-text-muted);
345 Added: border-radius: 999px;
346 Added: padding: 0.35rem 0.85rem;
347 Added: }
348 Added:
349 Added: .insights-nav .nav-link.active {
350 Added: background-color: var(--fapg-accent) !important;
351 Added: color: #fff !important;
352 Added: }
353 Added:
354 Added: .insight-theme {
355 Added: scroll-margin-top: 5rem;
356 Added: }
roles/dashboard/public/js/dashboard/insights.js
index ab7a3d85..d9a6317c 100644..100644
@@ -1,5 +1,5 @@
1 1 import { fetchProbeData } from "./api.js";
2 Removed: import { READING_RANGES, TIMEFRAMES } from "./constants.js";
2 Added: import { READING_RANGES } from "./constants.js";
3 3 import { formatChartTime, formatDateTime } from "./format.js";
4 4
5 5 const PROBE_COLORS = {
@@ -9,270 +9,524 @@
9 9 ec: { border: "#10b981", background: "rgba(16, 185, 129, 0.12)" }
10 10 };
11 11
12 Removed: const PROBE_LABELS = {
13 Removed: ph: "pH",
14 Removed: do: "DO (mg/L)",
15 Removed: orp: "ORP (mV)",
16 Removed: ec: "EC (µS/cm)"
12 Added: const DAY_COLORS = [
13 Added: "rgba(14, 165, 233, 0.25)",
14 Added: "rgba(139, 92, 246, 0.25)",
15 Added: "rgba(245, 158, 11, 0.25)",
16 Added: "rgba(16, 185, 129, 0.25)",
17 Added: "rgba(220, 38, 38, 0.25)",
18 Added: "rgba(100, 116, 139, 0.25)",
19 Added: "rgba(236, 72, 153, 0.25)"
20 Added: ];
21 Added:
22 Added: const TOOLTIP_DEFAULTS = {
23 Added: mode: "index",
24 Added: intersect: false,
25 Added: backgroundColor: "rgba(30, 41, 59, 0.7)",
26 Added: titleFont: { size: 11, weight: "normal" },
27 Added: bodyFont: { size: 12 },
28 Added: padding: { x: 8, y: 5 },
29 Added: cornerRadius: 4,
30 Added: caretSize: 0
17 31 };
18 32
19 Removed: let overlayChart = null;
20 Removed: let normalisedChart = null;
33 Added: let charts = {};
21 34 let activeRequests = [];
22 35
23 Removed: function extractSeries(points, timeframe) {
24 Removed: const labels = [];
25 Removed: const timestamps = [];
26 Removed: const values = [];
36 Added: function abortAll() {
37 Added: activeRequests.forEach(c => c.abort());
38 Added: activeRequests = [];
39 Added: }
27 40
28 Removed: points.forEach(row => {
29 Removed: labels.push(formatChartTime(row.timestamp, timeframe));
30 Removed: timestamps.push(row.timestamp);
31 Removed: values.push(row.value === null ? null : Number(row.value));
32 Removed: });
33 Removed:
34 Removed: return { labels, timestamps, values };
41 Added: function newController() {
42 Added: const c = new AbortController();
43 Added: activeRequests.push(c);
44 Added: return c;
35 45 }
36 46
37 Removed: function normalise(value, probe) {
38 Removed: const range = READING_RANGES[probe];
39 Removed: if (!range || value === null || !Number.isFinite(value)) return null;
40 Removed:
41 Removed: // Normalise to 0-100 based on goodMin–goodMax range
42 Removed: const span = range.goodMax - range.goodMin;
43 Removed: if (span === 0) return 50;
44 Removed:
45 Removed: return ((value - range.goodMin) / span) * 100;
47 Added: function removeController(c) {
48 Added: activeRequests = activeRequests.filter(x => x !== c);
46 49 }
47 50
48 Removed: function registerTopLeftTooltipPositioner() {
49 Removed: if (Chart.Tooltip.positioners.dashboardTopLeft) return;
50 Removed:
51 Removed: Chart.Tooltip.positioners.dashboardTopLeft = function () {
52 Removed: return {
53 Removed: x: this.chart.chartArea.left,
54 Removed: y: this.chart.chartArea.top
55 Removed: };
56 Removed: };
51 Added: async function fetchJson(url, signal) {
52 Added: const response = await fetch(url, signal ? { signal } : {});
53 Added: if (!response.ok) throw new Error(`HTTP ${response.status}`);
54 Added: return response.json();
57 55 }
58 56
59 Removed: function createDualAxisChart(canvas, doSeries, orpSeries, timeframe) {
60 Removed: registerTopLeftTooltipPositioner();
57 Added: // ──────────────────────────────────────────────────────────────────
58 Added: // Chart 1: System Stability
59 Added: // ──────────────────────────────────────────────────────────────────
60 Added: async function loadStability(signal) {
61 Added: const canvas = document.getElementById("chart-stability");
62 Added: if (!canvas) return;
61 63
62 Removed: return new Chart(canvas, {
63 Removed: type: "line",
64 Removed: data: {
65 Removed: labels: doSeries.labels,
66 Removed: timestamps: doSeries.timestamps,
67 Removed: datasets: [
68 Removed: {
69 Removed: label: "DO (mg/L)",
70 Removed: data: doSeries.values,
71 Removed: borderColor: PROBE_COLORS.do.border,
72 Removed: backgroundColor: PROBE_COLORS.do.background,
73 Removed: tension: 0.2,
64 Added: const data = await fetchJson("/api/insights/stability?hours=168", signal);
65 Added: const labels = data.points.map(p => formatDateTime(p.timestamp));
66 Added: const values = data.points.map(p => p.score);
67 Added:
68 Added: if (!charts.stability) {
69 Added: charts.stability = new Chart(canvas, {
70 Added: type: "line",
71 Added: data: {
72 Added: labels,
73 Added: datasets: [{
74 Added: label: "Stability score",
75 Added: data: values,
76 Added: borderColor: "#dc2626",
77 Added: backgroundColor: "rgba(220, 38, 38, 0.08)",
78 Added: tension: 0.3,
74 79 pointRadius: 0,
75 Removed: pointHoverRadius: 4,
76 Removed: yAxisID: "yDO",
80 Added: pointHoverRadius: 3,
77 81 fill: true
82 Added: }]
83 Added: },
84 Added: options: {
85 Added: responsive: true,
86 Added: maintainAspectRatio: false,
87 Added: animation: false,
88 Added: scales: {
89 Added: x: { display: true, ticks: { maxTicksLimit: 8, font: { size: 10 } } },
90 Added: y: { beginAtZero: true, title: { display: true, text: "Volatility (σ)" } }
78 91 },
79 Removed: {
80 Removed: label: "ORP (mV)",
81 Removed: data: orpSeries.values,
82 Removed: borderColor: PROBE_COLORS.orp.border,
83 Removed: backgroundColor: PROBE_COLORS.orp.background,
84 Removed: tension: 0.2,
85 Removed: pointRadius: 0,
86 Removed: pointHoverRadius: 4,
87 Removed: yAxisID: "yORP",
88 Removed: fill: true
92 Added: plugins: {
93 Added: legend: { display: false },
94 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
89 95 }
90 Removed: ]
91 Removed: },
92 Removed: options: {
93 Removed: responsive: true,
94 Removed: maintainAspectRatio: false,
95 Removed: animation: false,
96 Removed: interaction: { mode: "index", intersect: false },
97 Removed: scales: {
98 Removed: x: { ticks: { maxTicksLimit: TIMEFRAMES[timeframe].ticks } },
99 Removed: yDO: {
100 Removed: type: "linear",
101 Removed: position: "left",
102 Removed: title: { display: true, text: "DO (mg/L)", color: PROBE_COLORS.do.border },
103 Removed: ticks: { color: PROBE_COLORS.do.border },
104 Removed: grid: { drawOnChartArea: true }
105 Removed: },
106 Removed: yORP: {
107 Removed: type: "linear",
108 Removed: position: "right",
109 Removed: title: { display: true, text: "ORP (mV)", color: PROBE_COLORS.orp.border },
110 Removed: ticks: { color: PROBE_COLORS.orp.border },
111 Removed: grid: { drawOnChartArea: false }
112 Removed: }
113 Removed: },
114 Removed: plugins: {
115 Removed: legend: { display: true },
116 Removed: tooltip: {
117 Removed: mode: "index",
118 Removed: intersect: false,
119 Removed: position: "dashboardTopLeft",
120 Removed: xAlign: "left",
121 Removed: yAlign: "top",
122 Removed: caretSize: 0,
123 Removed: backgroundColor: "rgba(30, 41, 59, 0.7)",
124 Removed: titleFont: { size: 11, weight: "normal" },
125 Removed: bodyFont: { size: 12 },
126 Removed: padding: { x: 8, y: 5 },
127 Removed: cornerRadius: 4,
128 Removed: callbacks: {
129 Removed: title: () => ""
130 Removed: }
131 Removed: }
132 96 }
133 Removed: }
134 Removed: });
97 Added: });
98 Added: } else {
99 Added: charts.stability.data.labels = labels;
100 Added: charts.stability.data.datasets[0].data = values;
101 Added: charts.stability.update();
102 Added: }
135 103 }
136 104
137 Removed: function createNormalisedChart(canvas, probeData, timeframe) {
138 Removed: registerTopLeftTooltipPositioner();
105 Added: // ──────────────────────────────────────────────────────────────────
106 Added: // Chart 2: Rate of Change (Derivatives)
107 Added: // ──────────────────────────────────────────────────────────────────
108 Added: async function loadDerivatives(signal) {
109 Added: const canvas = document.getElementById("chart-derivatives");
110 Added: if (!canvas) return;
139 111
140 Removed: const probeKeys = Object.keys(probeData);
141 Removed: const referenceProbe = probeData[probeKeys[0]];
112 Added: const data = await fetchJson("/api/insights/derivatives?hours=48", signal);
113 Added: const probeKeys = Object.keys(data.probes);
142 114
143 115 const datasets = probeKeys.map(probe => ({
144 Removed: label: PROBE_LABELS[probe] || probe,
145 Removed: data: probeData[probe].values.map(v => normalise(v, probe)),
146 Removed: borderColor: PROBE_COLORS[probe].border,
147 Removed: backgroundColor: PROBE_COLORS[probe].background,
148 Removed: tension: 0.2,
116 Added: label: probe.toUpperCase(),
117 Added: data: data.probes[probe].map(p => ({ x: p.timestamp, y: p.value })),
118 Added: borderColor: PROBE_COLORS[probe]?.border || "#64748b",
119 Added: backgroundColor: PROBE_COLORS[probe]?.background || "rgba(100,116,139,0.1)",
120 Added: tension: 0.3,
149 121 pointRadius: 0,
150 Removed: pointHoverRadius: 4,
122 Added: pointHoverRadius: 3,
151 123 fill: false
152 124 }));
153 125
154 Removed: return new Chart(canvas, {
155 Removed: type: "line",
156 Removed: data: {
157 Removed: labels: referenceProbe.labels,
158 Removed: timestamps: referenceProbe.timestamps,
159 Removed: datasets
160 Removed: },
161 Removed: options: {
162 Removed: responsive: true,
163 Removed: maintainAspectRatio: false,
164 Removed: animation: false,
165 Removed: interaction: { mode: "index", intersect: false },
166 Removed: scales: {
167 Removed: x: { ticks: { maxTicksLimit: TIMEFRAMES[timeframe].ticks } },
168 Removed: y: {
169 Removed: title: { display: true, text: "% of optimal range" },
170 Removed: min: -50,
171 Removed: max: 150,
172 Removed: ticks: {
173 Removed: callback: value => `${value}%`
126 Added: // Use timestamps from first probe with data as labels
127 Added: const refProbe = probeKeys.find(p => data.probes[p].length > 0);
128 Added: const labels = refProbe ? data.probes[refProbe].map(p => formatDateTime(p.timestamp)) : [];
129 Added:
130 Added: if (!charts.derivatives) {
131 Added: charts.derivatives = new Chart(canvas, {
132 Added: type: "line",
133 Added: data: { labels, datasets },
134 Added: options: {
135 Added: responsive: true,
136 Added: maintainAspectRatio: false,
137 Added: animation: false,
138 Added: scales: {
139 Added: x: { display: true, ticks: { maxTicksLimit: 8, font: { size: 10 } } },
140 Added: y: {
141 Added: title: { display: true, text: "Δ per hour" },
142 Added: grid: { color: ctx => ctx.tick.value === 0 ? "#94a3b8" : "rgba(0,0,0,0.05)" }
174 143 }
175 Removed: }
176 Removed: },
177 Removed: plugins: {
178 Removed: legend: { display: true },
179 Removed: tooltip: {
180 Removed: mode: "index",
181 Removed: intersect: false,
182 Removed: position: "dashboardTopLeft",
183 Removed: xAlign: "left",
184 Removed: yAlign: "top",
185 Removed: caretSize: 0,
186 Removed: backgroundColor: "rgba(30, 41, 59, 0.7)",
187 Removed: titleFont: { size: 11, weight: "normal" },
188 Removed: bodyFont: { size: 12 },
189 Removed: padding: { x: 8, y: 5 },
190 Removed: cornerRadius: 4,
191 Removed: callbacks: {
192 Removed: title: () => "",
193 Removed: label: context => {
194 Removed: const value = context.parsed.y;
195 Removed: if (value === null) return "";
196 Removed: return `${context.dataset.label}: ${value.toFixed(0)}%`;
197 Removed: }
198 Removed: }
199 144 },
200 Removed: annotation: undefined
145 Added: plugins: {
146 Added: legend: { display: true },
147 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
148 Added: }
201 149 }
202 Removed: }
150 Added: });
151 Added: } else {
152 Added: charts.derivatives.data.labels = labels;
153 Added: charts.derivatives.data.datasets = datasets;
154 Added: charts.derivatives.update();
155 Added: }
156 Added: }
157 Added:
158 Added: // ──────────────────────────────────────────────────────────────────
159 Added: // Chart 3: DO Diurnal Pattern
160 Added: // ──────────────────────────────────────────────────────────────────
161 Added: async function loadDiurnal(signal) {
162 Added: const canvas = document.getElementById("chart-diurnal");
163 Added: if (!canvas) return;
164 Added:
165 Added: const data = await fetchJson("/api/insights/diurnal/do", signal);
166 Added: const labels = data.hours.map(h => `${String(h).padStart(2, "0")}:00`);
167 Added:
168 Added: const datasets = data.traces.map((trace, i) => ({
169 Added: label: trace.date,
170 Added: data: trace.values,
171 Added: borderColor: DAY_COLORS[i % DAY_COLORS.length],
172 Added: borderWidth: 1,
173 Added: tension: 0.3,
174 Added: pointRadius: 0,
175 Added: pointHoverRadius: 2,
176 Added: fill: false
177 Added: }));
178 Added:
179 Added: // Add the mean trace on top (bold)
180 Added: datasets.push({
181 Added: label: "7-day average",
182 Added: data: data.mean,
183 Added: borderColor: PROBE_COLORS.do.border,
184 Added: borderWidth: 3,
185 Added: tension: 0.3,
186 Added: pointRadius: 0,
187 Added: pointHoverRadius: 4,
188 Added: fill: false
203 189 });
190 Added:
191 Added: if (!charts.diurnal) {
192 Added: charts.diurnal = new Chart(canvas, {
193 Added: type: "line",
194 Added: data: { labels, datasets },
195 Added: options: {
196 Added: responsive: true,
197 Added: maintainAspectRatio: false,
198 Added: animation: false,
199 Added: scales: {
200 Added: x: { title: { display: true, text: "Hour of day (UTC)" } },
201 Added: y: { title: { display: true, text: "DO (mg/L)" } }
202 Added: },
203 Added: plugins: {
204 Added: legend: { display: true, labels: { font: { size: 10 } } },
205 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
206 Added: }
207 Added: }
208 Added: });
209 Added: } else {
210 Added: charts.diurnal.data.labels = labels;
211 Added: charts.diurnal.data.datasets = datasets;
212 Added: charts.diurnal.update();
213 Added: }
204 214 }
205 215
206 Removed: export function createInsightsCharts(timeframe) {
207 Removed: const canvasOverlay = document.getElementById("chart-overlay-do-orp");
208 Removed: const canvasNormalised = document.getElementById("chart-overlay-all");
216 Added: // ──────────────────────────────────────────────────────────────────
217 Added: // Chart 4: DO & ORP Dual-Axis Overlay
218 Added: // ──────────────────────────────────────────────────────────────────
219 Added: async function loadDoOrpOverlay(timeframe, signal) {
220 Added: const canvas = document.getElementById("chart-overlay-do-orp");
221 Added: if (!canvas) return;
209 222
210 Removed: if (!canvasOverlay && !canvasNormalised) return { reload: () => {} };
223 Added: const tf = timeframe.selected();
224 Added: const range = timeframe.range();
211 225
212 Removed: async function load() {
213 Removed: activeRequests.forEach(c => c.abort());
214 Removed: activeRequests = [];
226 Added: const [doData, orpData] = await Promise.all([
227 Added: fetchProbeData("do", tf, range, false, signal),
228 Added: fetchProbeData("orp", tf, range, false, signal)
229 Added: ]);
215 230
216 Removed: const controller = new AbortController();
217 Removed: activeRequests.push(controller);
231 Added: const labels = doData.points.map(p => formatChartTime(p.timestamp, tf));
232 Added: const doValues = doData.points.map(p => p.value);
233 Added: const orpValues = orpData.points.map(p => p.value);
218 234
219 Removed: const tf = timeframe.selected();
220 Removed: const range = timeframe.range();
235 Added: if (!charts.doOrp) {
236 Added: charts.doOrp = new Chart(canvas, {
237 Added: type: "line",
238 Added: data: {
239 Added: labels,
240 Added: datasets: [
241 Added: {
242 Added: label: "DO (mg/L)",
243 Added: data: doValues,
244 Added: borderColor: PROBE_COLORS.do.border,
245 Added: backgroundColor: PROBE_COLORS.do.background,
246 Added: tension: 0.2,
247 Added: pointRadius: 0,
248 Added: pointHoverRadius: 4,
249 Added: yAxisID: "yDO",
250 Added: fill: true
251 Added: },
252 Added: {
253 Added: label: "ORP (mV)",
254 Added: data: orpValues,
255 Added: borderColor: PROBE_COLORS.orp.border,
256 Added: backgroundColor: PROBE_COLORS.orp.background,
257 Added: tension: 0.2,
258 Added: pointRadius: 0,
259 Added: pointHoverRadius: 4,
260 Added: yAxisID: "yORP",
261 Added: fill: true
262 Added: }
263 Added: ]
264 Added: },
265 Added: options: {
266 Added: responsive: true,
267 Added: maintainAspectRatio: false,
268 Added: animation: false,
269 Added: interaction: { mode: "index", intersect: false },
270 Added: scales: {
271 Added: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
272 Added: yDO: {
273 Added: type: "linear",
274 Added: position: "left",
275 Added: title: { display: true, text: "DO (mg/L)", color: PROBE_COLORS.do.border },
276 Added: ticks: { color: PROBE_COLORS.do.border }
277 Added: },
278 Added: yORP: {
279 Added: type: "linear",
280 Added: position: "right",
281 Added: title: { display: true, text: "ORP (mV)", color: PROBE_COLORS.orp.border },
282 Added: ticks: { color: PROBE_COLORS.orp.border },
283 Added: grid: { drawOnChartArea: false }
284 Added: }
285 Added: },
286 Added: plugins: {
287 Added: legend: { display: true },
288 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
289 Added: }
290 Added: }
291 Added: });
292 Added: } else {
293 Added: charts.doOrp.data.labels = labels;
294 Added: charts.doOrp.data.datasets[0].data = doValues;
295 Added: charts.doOrp.data.datasets[1].data = orpValues;
296 Added: charts.doOrp.update();
297 Added: }
298 Added: }
221 299
222 Removed: try {
223 Removed: const [doData, orpData, phData, ecData] = await Promise.all([
224 Removed: fetchProbeData("do", tf, range, false, controller.signal),
225 Removed: fetchProbeData("orp", tf, range, false, controller.signal),
226 Removed: fetchProbeData("ph", tf, range, false, controller.signal),
227 Removed: fetchProbeData("ec", tf, range, false, controller.signal)
228 Removed: ]);
300 Added: // ──────────────────────────────────────────────────────────────────
301 Added: // Chart 5: EC Trend & Rate
302 Added: // ──────────────────────────────────────────────────────────────────
303 Added: async function loadEcRate(timeframe, signal) {
304 Added: const canvas = document.getElementById("chart-ec-rate");
305 Added: if (!canvas) return;
229 306
230 Removed: const doSeries = extractSeries(doData.points, tf);
231 Removed: const orpSeries = extractSeries(orpData.points, tf);
232 Removed: const phSeries = extractSeries(phData.points, tf);
233 Removed: const ecSeries = extractSeries(ecData.points, tf);
307 Added: const tf = timeframe.selected();
308 Added: const range = timeframe.range();
234 309
235 Removed: // DO vs ORP dual-axis overlay
236 Removed: if (canvasOverlay) {
237 Removed: if (!overlayChart) {
238 Removed: overlayChart = createDualAxisChart(canvasOverlay, doSeries, orpSeries, tf);
239 Removed: } else {
240 Removed: overlayChart.data.labels = doSeries.labels;
241 Removed: overlayChart.data.timestamps = doSeries.timestamps;
242 Removed: overlayChart.data.datasets[0].data = doSeries.values;
243 Removed: overlayChart.data.datasets[1].data = orpSeries.values;
244 Removed: overlayChart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[tf].ticks;
245 Removed: overlayChart.update();
310 Added: const [ecData, derivData] = await Promise.all([
311 Added: fetchProbeData("ec", tf, range, false, signal),
312 Added: fetchJson("/api/insights/derivatives?hours=168", signal)
313 Added: ]);
314 Added:
315 Added: const labels = ecData.points.map(p => formatChartTime(p.timestamp, tf));
316 Added: const ecValues = ecData.points.map(p => p.value);
317 Added: const ecRatePoints = derivData.probes.ec || [];
318 Added: const rateValues = ecRatePoints.map(p => p.value);
319 Added: const rateLabels = ecRatePoints.map(p => formatDateTime(p.timestamp));
320 Added:
321 Added: if (!charts.ecRate) {
322 Added: charts.ecRate = new Chart(canvas, {
323 Added: type: "line",
324 Added: data: {
325 Added: labels,
326 Added: datasets: [
327 Added: {
328 Added: label: "EC (µS/cm)",
329 Added: data: ecValues,
330 Added: borderColor: PROBE_COLORS.ec.border,
331 Added: backgroundColor: PROBE_COLORS.ec.background,
332 Added: tension: 0.2,
333 Added: pointRadius: 0,
334 Added: pointHoverRadius: 4,
335 Added: yAxisID: "yEC",
336 Added: fill: true
337 Added: },
338 Added: {
339 Added: label: "EC rate (Δ/h)",
340 Added: data: rateValues,
341 Added: borderColor: "#f59e0b",
342 Added: backgroundColor: "rgba(245, 158, 11, 0.1)",
343 Added: borderDash: [4, 3],
344 Added: tension: 0.3,
345 Added: pointRadius: 0,
346 Added: pointHoverRadius: 3,
347 Added: yAxisID: "yRate",
348 Added: fill: false
349 Added: }
350 Added: ]
351 Added: },
352 Added: options: {
353 Added: responsive: true,
354 Added: maintainAspectRatio: false,
355 Added: animation: false,
356 Added: interaction: { mode: "index", intersect: false },
357 Added: scales: {
358 Added: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
359 Added: yEC: {
360 Added: type: "linear",
361 Added: position: "left",
362 Added: title: { display: true, text: "EC (µS/cm)", color: PROBE_COLORS.ec.border },
363 Added: ticks: { color: PROBE_COLORS.ec.border }
364 Added: },
365 Added: yRate: {
366 Added: type: "linear",
367 Added: position: "right",
368 Added: title: { display: true, text: "Δ/hour", color: "#f59e0b" },
369 Added: ticks: { color: "#f59e0b" },
370 Added: grid: { drawOnChartArea: false }
371 Added: }
372 Added: },
373 Added: plugins: {
374 Added: legend: { display: true },
375 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
246 376 }
247 377 }
378 Added: });
379 Added: } else {
380 Added: charts.ecRate.data.labels = labels;
381 Added: charts.ecRate.data.datasets[0].data = ecValues;
382 Added: charts.ecRate.data.datasets[1].data = rateValues;
383 Added: charts.ecRate.update();
384 Added: }
385 Added: }
248 386
249 Removed: // Normalised all-probes overlay
250 Removed: if (canvasNormalised) {
251 Removed: const allProbeData = { ph: phSeries, do: doSeries, orp: orpSeries, ec: ecSeries };
387 Added: // ──────────────────────────────────────────────────────────────────
388 Added: // Chart 6: pH ↔ DO Relationship
389 Added: // ──────────────────────────────────────────────────────────────────
390 Added: async function loadPhDoLag(timeframe, signal) {
391 Added: const canvas = document.getElementById("chart-ph-do-lag");
392 Added: if (!canvas) return;
252 393
253 Removed: if (!normalisedChart) {
254 Removed: normalisedChart = createNormalisedChart(canvasNormalised, allProbeData, tf);
255 Removed: } else {
256 Removed: const probeKeys = Object.keys(allProbeData);
257 Removed: normalisedChart.data.labels = allProbeData[probeKeys[0]].labels;
258 Removed: normalisedChart.data.timestamps = allProbeData[probeKeys[0]].timestamps;
259 Removed: probeKeys.forEach((probe, index) => {
260 Removed: normalisedChart.data.datasets[index].data =
261 Removed: allProbeData[probe].values.map(v => normalise(v, probe));
262 Removed: });
263 Removed: normalisedChart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[tf].ticks;
264 Removed: normalisedChart.update();
394 Added: const tf = timeframe.selected();
395 Added: const range = timeframe.range();
396 Added:
397 Added: const [phData, doData] = await Promise.all([
398 Added: fetchProbeData("ph", tf, range, false, signal),
399 Added: fetchProbeData("do", tf, range, false, signal)
400 Added: ]);
401 Added:
402 Added: const labels = phData.points.map(p => formatChartTime(p.timestamp, tf));
403 Added: const phValues = phData.points.map(p => p.value);
404 Added: const doValues = doData.points.map(p => p.value);
405 Added:
406 Added: if (!charts.phDoLag) {
407 Added: charts.phDoLag = new Chart(canvas, {
408 Added: type: "line",
409 Added: data: {
410 Added: labels,
411 Added: datasets: [
412 Added: {
413 Added: label: "pH",
414 Added: data: phValues,
415 Added: borderColor: PROBE_COLORS.ph.border,
416 Added: backgroundColor: PROBE_COLORS.ph.background,
417 Added: tension: 0.2,
418 Added: pointRadius: 0,
419 Added: pointHoverRadius: 4,
420 Added: yAxisID: "yPH",
421 Added: fill: true
422 Added: },
423 Added: {
424 Added: label: "DO (mg/L)",
425 Added: data: doValues,
426 Added: borderColor: PROBE_COLORS.do.border,
427 Added: backgroundColor: PROBE_COLORS.do.background,
428 Added: tension: 0.2,
429 Added: pointRadius: 0,
430 Added: pointHoverRadius: 4,
431 Added: yAxisID: "yDO",
432 Added: fill: true
433 Added: }
434 Added: ]
435 Added: },
436 Added: options: {
437 Added: responsive: true,
438 Added: maintainAspectRatio: false,
439 Added: animation: false,
440 Added: interaction: { mode: "index", intersect: false },
441 Added: scales: {
442 Added: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
443 Added: yPH: {
444 Added: type: "linear",
445 Added: position: "left",
446 Added: title: { display: true, text: "pH", color: PROBE_COLORS.ph.border },
447 Added: ticks: { color: PROBE_COLORS.ph.border }
448 Added: },
449 Added: yDO: {
450 Added: type: "linear",
451 Added: position: "right",
452 Added: title: { display: true, text: "DO (mg/L)", color: PROBE_COLORS.do.border },
453 Added: ticks: { color: PROBE_COLORS.do.border },
454 Added: grid: { drawOnChartArea: false }
455 Added: }
456 Added: },
457 Added: plugins: {
458 Added: legend: { display: true },
459 Added: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
265 460 }
266 461 }
462 Added: });
463 Added: } else {
464 Added: charts.phDoLag.data.labels = labels;
465 Added: charts.phDoLag.data.datasets[0].data = phValues;
466 Added: charts.phDoLag.data.datasets[1].data = doValues;
467 Added: charts.phDoLag.update();
468 Added: }
469 Added: }
470 Added:
471 Added: // ──────────────────────────────────────────────────────────────────
472 Added: // Public API
473 Added: // ──────────────────────────────────────────────────────────────────
474 Added: export function createInsightsCharts(timeframe) {
475 Added: // Only activate if at least one insights canvas exists
476 Added: const hasCanvas = document.getElementById("chart-stability")
477 Added: || document.getElementById("chart-diurnal");
478 Added:
479 Added: if (!hasCanvas) return { reload: () => {} };
480 Added:
481 Added: async function load() {
482 Added: abortAll();
483 Added: const controller = newController();
484 Added: const signal = controller.signal;
485 Added:
486 Added: try {
487 Added: await Promise.allSettled([
488 Added: loadStability(signal),
489 Added: loadDerivatives(signal),
490 Added: loadDiurnal(signal),
491 Added: loadDoOrpOverlay(timeframe, signal),
492 Added: loadEcRate(timeframe, signal),
493 Added: loadPhDoLag(timeframe, signal)
494 Added: ]);
267 495 } catch (error) {
268 496 if (error.name === "AbortError") return;
269 Removed: console.error("Insights chart load failed:", error);
497 Added: console.error("Insights load failed:", error);
270 498 } finally {
271 Removed: activeRequests = activeRequests.filter(c => c !== controller);
499 Added: removeController(controller);
272 500 }
273 501 }
274 502
275 503 load();
276 504
505 Added: // Highlight nav pill on scroll
506 Added: initScrollSpy();
507 Added:
277 508 return { reload: load };
509 Added: }
510 Added:
511 Added: // ──────────────────────────────────────────────────────────────────
512 Added: // Scroll spy: highlight active nav pill based on visible section
513 Added: // ──────────────────────────────────────────────────────────────────
514 Added: function initScrollSpy() {
515 Added: const navLinks = document.querySelectorAll(".insights-nav a.nav-link");
516 Added: const sections = document.querySelectorAll(".insight-theme[id]");
517 Added:
518 Added: if (!navLinks.length || !sections.length) return;
519 Added:
520 Added: const observer = new IntersectionObserver(entries => {
521 Added: for (const entry of entries) {
522 Added: if (entry.isIntersecting) {
523 Added: const id = entry.target.id;
524 Added: navLinks.forEach(link => {
525 Added: link.classList.toggle("active", link.getAttribute("href") === `#${id}`);
526 Added: });
527 Added: }
528 Added: }
529 Added: }, { rootMargin: "-20% 0px -60% 0px" });
530 Added:
531 Added: sections.forEach(section => observer.observe(section));
278 532 }
roles/dashboard/t/08-insights.t
index 83546f88..2794b9b2 100644..100644
@@ -9,26 +9,93 @@
9 9
10 10 my $t = test_app();
11 11
12 Removed: subtest 'insights page renders' => sub {
12 Added: subtest 'insights page renders with all sections' => sub {
13 13 $t->get_ok('/insights')
14 14 ->status_is(200)
15 15 ->text_is('title', 'FAPG DAQ Insights')
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('canvas#chart-overlay-do-orp')
20 Removed: ->element_exists('canvas#chart-overlay-all')
21 19 ->element_exists('[data-default-timeframe="month"]');
22 20 };
23 21
24 Removed: subtest 'insights page has graph controls' => sub {
22 Added: subtest 'insights page has section anchors and nav' => sub {
25 23 $t->get_ok('/insights')
26 24 ->status_is(200)
27 Removed: ->element_exists('.graph-control-scroll')
28 Removed: ->element_exists('button[data-timeframe="hour"]')
29 Removed: ->element_exists('button[data-timeframe="month"]');
25 Added: ->element_exists('nav.insights-nav')
26 Added: ->element_exists('a.nav-link[href="#system-overview"]')
27 Added: ->element_exists('a.nav-link[href="#dissolved-oxygen"]')
28 Added: ->element_exists('a.nav-link[href="#nutrient-cycling"]')
29 Added: ->element_exists('section#system-overview')
30 Added: ->element_exists('section#dissolved-oxygen')
31 Added: ->element_exists('section#nutrient-cycling');
30 32 };
31 33
34 Added: subtest 'insights page has all chart canvases' => sub {
35 Added: $t->get_ok('/insights')
36 Added: ->status_is(200)
37 Added: ->element_exists('canvas#chart-stability')
38 Added: ->element_exists('canvas#chart-derivatives')
39 Added: ->element_exists('canvas#chart-diurnal')
40 Added: ->element_exists('canvas#chart-overlay-do-orp')
41 Added: ->element_exists('canvas#chart-ec-rate')
42 Added: ->element_exists('canvas#chart-ph-do-lag');
43 Added: };
44 Added:
45 Added: subtest 'diurnal API returns structured response' => sub {
46 Added: $t->get_ok('/api/insights/diurnal/do')
47 Added: ->status_is(200)
48 Added: ->json_has('/probe')
49 Added: ->json_is('/probe' => 'do')
50 Added: ->json_has('/hours')
51 Added: ->json_has('/traces')
52 Added: ->json_has('/mean');
53 Added: };
54 Added:
55 Added: subtest 'diurnal API with invalid probe' => sub {
56 Added: $t->get_ok('/api/insights/diurnal/nope')
57 Added: ->status_is(404)
58 Added: ->json_has('/error');
59 Added: };
60 Added:
61 Added: subtest 'diurnal API accepts days param' => sub {
62 Added: $t->get_ok('/api/insights/diurnal/ph?days=3')
63 Added: ->status_is(200)
64 Added: ->json_is('/days' => 3)
65 Added: ->json_is('/probe' => 'ph');
66 Added: };
67 Added:
68 Added: subtest 'derivatives API returns all probes' => sub {
69 Added: $t->get_ok('/api/insights/derivatives')
70 Added: ->status_is(200)
71 Added: ->json_has('/probes')
72 Added: ->json_has('/probes/ph')
73 Added: ->json_has('/probes/do')
74 Added: ->json_has('/probes/orp')
75 Added: ->json_has('/probes/ec')
76 Added: ->json_is('/hours' => 48);
77 Added: };
78 Added:
79 Added: subtest 'derivatives API accepts hours param' => sub {
80 Added: $t->get_ok('/api/insights/derivatives?hours=24')
81 Added: ->status_is(200)
82 Added: ->json_is('/hours' => 24);
83 Added: };
84 Added:
85 Added: subtest 'stability API returns points array' => sub {
86 Added: $t->get_ok('/api/insights/stability')
87 Added: ->status_is(200)
88 Added: ->json_has('/points')
89 Added: ->json_is('/hours' => 168)
90 Added: ->json_is('/window' => 6);
91 Added: };
92 Added:
93 Added: subtest 'stability API accepts hours param' => sub {
94 Added: $t->get_ok('/api/insights/stability?hours=48')
95 Added: ->status_is(200)
96 Added: ->json_is('/hours' => 48);
97 Added: };
98 Added:
32 99 subtest 'correlation API returns points' => sub {
33 100 $t->get_ok('/api/correlation/ph/do?timeframe=year&range=1')
34 101 ->status_is(200)
@@ -49,17 +116,65 @@
49 116 ->status_is(404);
50 117 };
51 118
52 Removed: subtest 'correlation API requires timeframe' => sub {
53 Removed: $t->get_ok('/api/correlation/ph/do')
54 Removed: ->status_is(200)
55 Removed: ->json_has('/points');
56 Removed: };
57 Removed:
58 119 subtest 'empty database insights page' => sub {
59 120 my $empty = test_empty_app();
60 121 $empty->get_ok('/insights')
61 122 ->status_is(200)
62 Removed: ->element_exists('canvas#chart-overlay-do-orp');
123 Added: ->element_exists('canvas#chart-stability');
124 Added: };
125 Added:
126 Added: subtest 'insights APIs with no recent data' => sub {
127 Added: # test_app has data from 2026-07-05, outside default time windows
128 Added: # so these return empty/minimal results gracefully
129 Added: $t->get_ok('/api/insights/stability?hours=1')
130 Added: ->status_is(200)
131 Added: ->json_is('/points' => []);
132 Added: $t->get_ok('/api/insights/derivatives?hours=1')
133 Added: ->status_is(200)
134 Added: ->json_has('/probes');
135 Added: $t->get_ok('/api/insights/diurnal/do?days=1')
136 Added: ->status_is(200)
137 Added: ->json_is('/traces' => []);
138 Added: };
139 Added:
140 Added: subtest 'insights APIs with matching data' => sub {
141 Added: # Seed recent hourly rollups to exercise computation paths
142 Added: my $now = time;
143 Added:
144 Added: for my $offset ( 1 .. 12 ) {
145 Added: my $epoch = ( int( $now / 3600 ) - $offset ) * 3600;
146 Added: for my $probe (qw(ph do orp ec)) {
147 Added: my %base = ( ph => 7.0, do => 8.0, orp => 350, ec => 800 );
148 Added: my $val = $base{$probe} + ( $offset * 0.1 );
149 Added: $t->app->sqlite->db->query(
150 Added: q{INSERT OR IGNORE INTO reading_rollups
151 Added: (probe, bucket_seconds, bucket_epoch, sample_count,
152 Added: value_total, value_min, value_max,
153 Added: first_sample_epoch, last_sample_epoch, max_gap_seconds)
154 Added: VALUES (?, 3600, ?, 10, ?, ?, ?, ?, ?, 0)},
155 Added: $probe, $epoch, $val * 10, $val - 0.1, $val + 0.1,
156 Added: $epoch, $epoch + 3000,
157 Added: );
158 Added: }
159 Added: }
160 Added:
161 Added: $t->get_ok('/api/insights/stability?hours=24')
162 Added: ->status_is(200)
163 Added: ->json_has('/points');
164 Added:
165 Added: $t->get_ok('/api/insights/derivatives?hours=24')
166 Added: ->status_is(200)
167 Added: ->json_has('/probes/ph');
168 Added:
169 Added: my $derivs = $t->tx->res->json->{probes};
170 Added: ok @{ $derivs->{ph} } > 0, 'derivatives returns rate points for ph';
171 Added:
172 Added: $t->get_ok('/api/insights/diurnal/do?days=1')
173 Added: ->status_is(200)
174 Added: ->json_has('/traces');
175 Added:
176 Added: my $diurnal = $t->tx->res->json;
177 Added: ok @{ $diurnal->{traces} } > 0, 'diurnal returns traces with recent data';
63 178 };
64 179
65 180 done_testing;
roles/dashboard/templates/dashboard/insights.html.ep
index 5ffae806..75bc1704 100644..100644
@@ -2,39 +2,140 @@
2 2 % title 'FAPG DAQ Insights';
3 3 % stash use_charts => 1;
4 4
5 Removed: <section class="mb-4" aria-label="Experimental visualisations">
5 Added: <section class="mb-4" aria-label="Insights visualisations">
6 6 <div class="mb-3">
7 7 <h1 class="h4 fw-bold mb-1">Insights</h1>
8 Removed: <p class="text-body-secondary small mb-0">Secondary visualisations derived from primary probe readings</p>
8 Added: <p class="text-body-secondary small mb-0">Derived visualisations for system monitoring</p>
9 9 </div>
10 10
11 Removed: <div class="row g-3">
12 Removed: <div class="col-12">
13 Removed: <div class="card">
14 Removed: <div class="card-header">
15 Removed: <h2 class="h5 mb-0">DO &amp; ORP over time</h2>
16 Removed: <p class="text-body-secondary small mb-0 mt-1">Dissolved Oxygen and ORP on a shared time axis</p>
11 Added: <nav class="insights-nav mb-4" aria-label="Insight sections">
12 Added: <ul class="nav nav-pills nav-fill flex-nowrap overflow-x-auto">
13 Added: <li class="nav-item">
14 Added: <a class="nav-link" href="#system-overview">System Overview</a>
15 Added: </li>
16 Added: <li class="nav-item">
17 Added: <a class="nav-link" href="#dissolved-oxygen">Dissolved Oxygen</a>
18 Added: </li>
19 Added: <li class="nav-item">
20 Added: <a class="nav-link" href="#nutrient-cycling">Nutrient Cycling</a>
21 Added: </li>
22 Added: </ul>
23 Added: </nav>
24 Added:
25 Added: <!-- THEME 1: System Overview -->
26 Added: <section id="system-overview" class="insight-theme mb-5">
27 Added: <h2 class="h5 fw-bold mb-3 text-body-secondary">System Overview</h2>
28 Added:
29 Added: <div class="row g-3">
30 Added: <div class="col-12" id="stability">
31 Added: <div class="card">
32 Added: <div class="card-header">
33 Added: <h3 class="h6 mb-0">System Stability</h3>
34 Added: <p class="text-body-secondary small mb-0 mt-1">
35 Added: Rolling volatility score across all probes (lower = more stable)
36 Added: </p>
37 Added: </div>
38 Added: <div class="card-body">
39 Added: <div class="chart-wrap">
40 Added: <canvas id="chart-stability" role="img" aria-label="System stability score over time"></canvas>
41 Added: </div>
42 Added: </div>
17 43 </div>
18 Removed: <div class="card-body">
19 Removed: <div class="chart-wrap">
20 Removed: <canvas id="chart-overlay-do-orp" role="img" aria-label="Dissolved Oxygen and ORP overlay chart"></canvas>
44 Added: </div>
45 Added:
46 Added: <div class="col-12" id="rate-of-change">
47 Added: <div class="card">
48 Added: <div class="card-header">
49 Added: <h3 class="h6 mb-0">Rate of Change</h3>
50 Added: <p class="text-body-secondary small mb-0 mt-1">
51 Added: How fast each parameter is changing (per hour)
52 Added: </p>
21 53 </div>
54 Added: <div class="card-body">
55 Added: <div class="chart-wrap">
56 Added: <canvas id="chart-derivatives" role="img" aria-label="Rate of change per probe"></canvas>
57 Added: </div>
58 Added: </div>
22 59 </div>
23 60 </div>
24 61 </div>
62 Added: </section>
25 63
26 Removed: <div class="col-12">
27 Removed: <div class="card">
28 Removed: <div class="card-header">
29 Removed: <h2 class="h5 mb-0">All probes overlay</h2>
30 Removed: <p class="text-body-secondary small mb-0 mt-1">All four parameters normalised to their optimal range (0–100%)</p>
64 Added: <!-- THEME 2: Dissolved Oxygen -->
65 Added: <section id="dissolved-oxygen" class="insight-theme mb-5">
66 Added: <h2 class="h5 fw-bold mb-3 text-body-secondary">Dissolved Oxygen</h2>
67 Added:
68 Added: <div class="row g-3">
69 Added: <div class="col-12" id="diurnal">
70 Added: <div class="card">
71 Added: <div class="card-header">
72 Added: <h3 class="h6 mb-0">DO Diurnal Pattern</h3>
73 Added: <p class="text-body-secondary small mb-0 mt-1">
74 Added: Day/night DO cycle &mdash; each line is one day, bold line is the 7-day average
75 Added: </p>
76 Added: </div>
77 Added: <div class="card-body">
78 Added: <div class="chart-wrap">
79 Added: <canvas id="chart-diurnal" role="img" aria-label="Dissolved oxygen diurnal overlay"></canvas>
80 Added: </div>
81 Added: </div>
31 82 </div>
32 Removed: <div class="card-body">
33 Removed: <div class="chart-wrap">
34 Removed: <canvas id="chart-overlay-all" role="img" aria-label="All probes normalised overlay chart"></canvas>
83 Added: </div>
84 Added:
85 Added: <div class="col-12" id="do-orp-overlay">
86 Added: <div class="card">
87 Added: <div class="card-header">
88 Added: <h3 class="h6 mb-0">DO &amp; ORP Over Time</h3>
89 Added: <p class="text-body-secondary small mb-0 mt-1">
90 Added: Dual-axis overlay &mdash; correlated drops indicate oxygen-driven redox shifts
91 Added: </p>
35 92 </div>
93 Added: <div class="card-body">
94 Added: <div class="chart-wrap">
95 Added: <canvas id="chart-overlay-do-orp" role="img" aria-label="DO and ORP overlay chart"></canvas>
96 Added: </div>
97 Added: </div>
36 98 </div>
37 99 </div>
38 100 </div>
39 Removed: </div>
101 Added: </section>
102 Added:
103 Added: <!-- THEME 3: Nutrient Cycling -->
104 Added: <section id="nutrient-cycling" class="insight-theme mb-5">
105 Added: <h2 class="h5 fw-bold mb-3 text-body-secondary">Nutrient Cycling</h2>
106 Added:
107 Added: <div class="row g-3">
108 Added: <div class="col-12" id="ec-rate">
109 Added: <div class="card">
110 Added: <div class="card-header">
111 Added: <h3 class="h6 mb-0">EC Trend &amp; Rate</h3>
112 Added: <p class="text-body-secondary small mb-0 mt-1">
113 Added: Electrical conductivity with rate of change &mdash; rising EC means nutrient buildup
114 Added: </p>
115 Added: </div>
116 Added: <div class="card-body">
117 Added: <div class="chart-wrap">
118 Added: <canvas id="chart-ec-rate" role="img" aria-label="EC trend with rate of change"></canvas>
119 Added: </div>
120 Added: </div>
121 Added: </div>
122 Added: </div>
123 Added:
124 Added: <div class="col-12" id="ph-do-lag">
125 Added: <div class="card">
126 Added: <div class="card-header">
127 Added: <h3 class="h6 mb-0">pH &#8596; DO Relationship</h3>
128 Added: <p class="text-body-secondary small mb-0 mt-1">
129 Added: Nitrification link &mdash; DO drops often precede pH drops as bacteria consume oxygen and produce acid
130 Added: </p>
131 Added: </div>
132 Added: <div class="card-body">
133 Added: <div class="chart-wrap">
134 Added: <canvas id="chart-ph-do-lag" role="img" aria-label="pH and DO relationship overlay"></canvas>
135 Added: </div>
136 Added: </div>
137 Added: </div>
138 Added: </div>
139 Added: </div>
140 Added: </section>
40 141 </section>