[Perl] DAQ system for the FAPG.
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).
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & 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 — 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 & ORP Over Time</h3>
89
Added:
<p class="text-body-secondary small mb-0 mt-1">
90
Added:
Dual-axis overlay — 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 & Rate</h3>
112
Added:
<p class="text-body-secondary small mb-0 mt-1">
113
Added:
Electrical conductivity with rate of change — 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 ↔ DO Relationship</h3>
128
Added:
<p class="text-body-secondary small mb-0 mt-1">
129
Added:
Nitrification link — 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>