feat add raw reading downloads

- add probe-card downloads synchronized with the selected graph timeframe - add probe filtering and semantic preset or custom date ranges to the downloads page - share timeframe window calculations and cover download behavior with dashboard tests

Commit
fbfbbaf3d5412aae030fb42e279de95fddeab78e
Author
Codex <codex@openai.com>
Author date
Committer
Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
index 075c38c0..c3039092 100644..100644
@@ -27,28 +27,34 @@
27 27 }
28 28
29 29 sub downloads ($self) {
30 Removed: my $from = strftime( '%Y-%m-%d', gmtime( time - ( 7 * 24 * 60 * 60 ) ) );
31 Removed: my $to = strftime( '%Y-%m-%d', gmtime(time) );
30 Added: my $from = strftime( '%Y-%m-%d', gmtime( time - ( 6 * 24 * 60 * 60 ) ) );
31 Added: my $month_from
32 Added: = strftime( '%Y-%m-%d', gmtime( time - ( 29 * 24 * 60 * 60 ) ) );
33 Added: my $to = strftime( '%Y-%m-%d', gmtime(time) );
32 34 my $database = $self->sqlite->db;
33 Removed: my $table = $database->query(
35 Added: my $table
36 Added: = $database->query(
34 37 q{SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'readings'}
35 Removed: )->hash;
38 Added: )->hash;
36 39 my $oldest;
37 40
38 41 if ($table) {
39 Removed: $oldest = $database->query(
42 Added: $oldest
43 Added: = $database->query(
40 44 'SELECT MIN(COALESCE(received_at, timestamp)) AS timestamp FROM readings'
41 Removed: )->hash->{timestamp};
45 Added: )->hash->{timestamp};
42 46 }
43 47
44 48 $oldest = $from if !defined $oldest;
45 49 $oldest =~ s/T.*\z//;
46 50
47 51 $self->render(
48 Removed: template => 'dashboard/downloads',
49 Removed: from => $from,
50 Removed: to => $to,
51 Removed: oldest => $oldest,
52 Added: template => 'dashboard/downloads',
53 Added: from => $from,
54 Added: month_from => $month_from,
55 Added: to => $to,
56 Added: oldest => $oldest,
57 Added: probes => $self->probes,
52 58 );
53 59 }
54 60
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Download.pm
index 81d73603..35f3f295 100644..100644
@@ -3,14 +3,19 @@
3 3 package FAPG::DAQ::Dashboard::Controller::Download;
4 4 use Mojo::Base 'Mojolicious::Controller', -signatures;
5 5
6 Removed: use POSIX qw(strftime);
7 Removed: use Time::Local qw(timegm);
6 Added: use FAPG::DAQ::Dashboard::Timeframe qw(max_series_range series_window);
7 Added: use POSIX qw(strftime);
8 Added: use Time::Local qw(timegm);
8 9
9 10 sub readings ($self) {
10 Removed: my $from = $self->param('from') // '';
11 Removed: my $to = $self->param('to') // '';
11 Added: return timeframe_readings($self) if defined $self->param('timeframe');
12 Added:
13 Added: my $from = $self->param('from') // '';
14 Added: my $to = $self->param('to') // '';
15 Added: my $probe = $self->param('probe') // '';
12 16 my $from_epoch = date_epoch($from);
13 17 my $to_epoch = date_epoch($to);
18 Added: my %known = map { $_->{key} => 1 } $self->probes->@*;
14 19
15 20 return $self->render(
16 21 status => 400,
@@ -22,38 +27,103 @@
22 27 text => 'The from date must not be after the to date.',
23 28 ) if $from_epoch > $to_epoch;
24 29
30 Added: return $self->render(
31 Added: status => 400,
32 Added: text => 'The probe must be blank or a known probe type.',
33 Added: ) if $probe ne '' && !$known{$probe};
34 Added:
35 Added: my $probe_filter = $probe eq '' ? '' : ' AND probe = ?';
36 Added: my @probe_bind = $probe eq '' ? () : ($probe);
37 Added: my $rows = raw_readings(
38 Added: $self,
39 Added: 'COALESCE(received_at, timestamp) >= ? '
40 Added: . 'AND COALESCE(received_at, timestamp) < ?'
41 Added: . $probe_filter,
42 Added: 'COALESCE(received_at, timestamp)',
43 Added: utc_timestamp($from_epoch),
44 Added: utc_timestamp( $to_epoch + ( 24 * 60 * 60 ) ),
45 Added: @probe_bind,
46 Added: );
47 Added: my $filename
48 Added: = $probe eq ''
49 Added: ? "fapg-daq-readings-$from-to-$to.csv"
50 Added: : "fapg-daq-readings-$probe-$from-to-$to.csv";
51 Added:
52 Added: render_csv( $self, $rows, $filename );
53 Added: }
54 Added:
55 Added: sub timeframe_readings ($self) {
56 Added: my $probe = $self->param('probe') // '';
57 Added: my $timeframe = $self->param('timeframe') // '';
58 Added: my $range = $self->param('range') // 1;
59 Added: my %known = map { $_->{key} => 1 } $self->probes->@*;
60 Added:
61 Added: return $self->render(
62 Added: status => 400,
63 Added: text => 'A known probe is required for timeframe downloads.',
64 Added: ) unless $known{$probe};
65 Added:
66 Added: return $self->render(
67 Added: status => 400,
68 Added: text => 'Range must be an integer from 1 to ' . max_series_range(),
69 Added: )
70 Added: if $range !~ /\A\d+\z/
71 Added: || $range < 1
72 Added: || $range > max_series_range();
73 Added:
74 Added: my $window = series_window( $timeframe, $range );
75 Added:
76 Added: return $self->render(
77 Added: status => 400,
78 Added: text => "Unknown timeframe: $timeframe",
79 Added: ) unless defined $window;
80 Added:
81 Added: my $rows = raw_readings(
82 Added: $self,
83 Added: 'probe = ? AND COALESCE(timestamp, received_at) >= ? '
84 Added: . 'AND COALESCE(timestamp, received_at) < ?',
85 Added: 'COALESCE(timestamp, received_at)',
86 Added: $probe,
87 Added: $window->{start_iso},
88 Added: $window->{end_iso},
89 Added: );
90 Added: my $filename = "fapg-daq-$probe-$timeframe-$range.csv";
91 Added:
92 Added: render_csv( $self, $rows, $filename );
93 Added: }
94 Added:
95 Added: sub raw_readings ( $self, $where, $order_by, @bind ) {
25 96 my $database = $self->sqlite->db;
26 Removed: my $table = $database->query(
97 Added: my $table
98 Added: = $database->query(
27 99 q{SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'readings'}
28 Removed: )->hash;
29 Removed: my $rows = [];
100 Added: )->hash;
30 101
31 Removed: if ($table) {
32 Removed: $rows = $database->query(
33 Removed: q{
102 Added: return [] unless $table;
103 Added:
104 Added: return $database->query(
105 Added: qq{
34 106 SELECT timestamp, received_at, node, probe, value, unit
35 107 FROM readings
36 Removed: WHERE COALESCE(received_at, timestamp) >= ?
37 Removed: AND COALESCE(received_at, timestamp) < ?
38 Removed: ORDER BY COALESCE(received_at, timestamp), id
108 Added: WHERE $where
109 Added: ORDER BY $order_by, id
39 110 },
40 Removed: utc_timestamp($from_epoch),
41 Removed: utc_timestamp( $to_epoch + ( 24 * 60 * 60 ) ),
42 Removed: )->hashes->to_array;
43 Removed: }
111 Added: @bind,
112 Added: )->hashes->to_array;
113 Added: }
44 114
115 Added: sub render_csv ( $self, $rows, $filename ) {
45 116 my @lines = ('timestamp,received_at,node,probe,value,unit');
46 117
47 118 for my $row ( $rows->@* ) {
48 Removed: push @lines, join ',', map { csv_value( $row->{$_} ) }
119 Added: push @lines, join ',',
120 Added: map { csv_value( $row->{$_} ) }
49 121 qw(timestamp received_at node probe value unit);
50 122 }
51 123
52 Removed: my $filename = "fapg-daq-readings-$from-to-$to.csv";
53 Removed:
54 124 $self->res->headers->content_type('text/csv; charset=utf-8');
55 125 $self->res->headers->content_disposition(
56 Removed: qq{attachment; filename="$filename"} );
126 Added: qq{attachment; filename="$filename"});
57 127 $self->render( data => join( "\n", @lines ) . "\n" );
58 128 }
59 129
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
index d917bce4..e0b51249 100644..100644
@@ -3,34 +3,13 @@
3 3 package FAPG::DAQ::Dashboard::Controller::Reading;
4 4 use Mojo::Base 'Mojolicious::Controller', -signatures;
5 5
6 Removed: use POSIX qw(strftime);
6 Added: use FAPG::DAQ::Dashboard::Timeframe qw(max_series_range series_window);
7 Added: use POSIX qw(strftime);
7 8
8 9 my $STATUS_REACHABLE_SECONDS = 120;
9 Removed: my $MAX_SERIES_RANGE = 24;
10 Added: my $MAX_SERIES_RANGE = max_series_range();
10 11 my $SERIES_GAP_SECONDS = 30;
11 12 my $MINUTE_SECONDS = 60;
12 Removed: my %SERIES_TIMEFRAMES = (
13 Removed: hour => {
14 Removed: span_seconds => 60 * 60,
15 Removed: bucket_stride => 60,
16 Removed: },
17 Removed: day => {
18 Removed: span_seconds => 24 * 60 * 60,
19 Removed: bucket_stride => 15 * 60,
20 Removed: },
21 Removed: week => {
22 Removed: span_seconds => 7 * 24 * 60 * 60,
23 Removed: bucket_stride => 60 * 60,
24 Removed: },
25 Removed: month => {
26 Removed: span_seconds => 30 * 24 * 60 * 60,
27 Removed: bucket_stride => 6 * 60 * 60,
28 Removed: },
29 Removed: year => {
30 Removed: span_seconds => 365 * 24 * 60 * 60,
31 Removed: bucket_stride => 24 * 60 * 60,
32 Removed: },
33 Removed: );
34 13
35 14 sub list ($self) {
36 15 my $probe = $self->param('probe') // '';
@@ -266,24 +245,6 @@
266 245 sub reachable_since {
267 246 return strftime( '%Y-%m-%dT%H:%M:%SZ',
268 247 gmtime( time - $STATUS_REACHABLE_SECONDS ) );
269 Removed: }
270 Removed:
271 Removed: sub series_window ( $timeframe, $range ) {
272 Removed: my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
273 Removed: my $bucket_stride = $config->{bucket_stride} * $range;
274 Removed: my $span_seconds = $config->{span_seconds} * $range;
275 Removed: my $end_epoch
276 Removed: = int( time / $bucket_stride ) * $bucket_stride + $bucket_stride;
277 Removed: my $start_epoch = $end_epoch - $span_seconds;
278 Removed:
279 Removed: return {
280 Removed: bucket_count => int( $span_seconds / $bucket_stride ),
281 Removed: bucket_stride => $bucket_stride,
282 Removed: start_epoch => $start_epoch,
283 Removed: end_epoch => $end_epoch,
284 Removed: start_iso => utc_timestamp($start_epoch),
285 Removed: end_iso => utc_timestamp($end_epoch),
286 Removed: };
287 248 }
288 249
289 250 sub series_points ($window) {
roles/dashboard/lib/FAPG/DAQ/Dashboard/Timeframe.pm
index 00000000..c1b824be 000000..100644
@@ -0,0 +1,63 @@
1 Added: # -*- mode: cperl; -*-
2 Added:
3 Added: package FAPG::DAQ::Dashboard::Timeframe;
4 Added: use Mojo::Base -strict;
5 Added:
6 Added: use Exporter qw(import);
7 Added: use POSIX qw(strftime);
8 Added:
9 Added: our @EXPORT_OK = qw(max_series_range series_window);
10 Added:
11 Added: my $MAX_SERIES_RANGE = 24;
12 Added: my %SERIES_TIMEFRAMES = (
13 Added: hour => {
14 Added: span_seconds => 60 * 60,
15 Added: bucket_stride => 60,
16 Added: },
17 Added: day => {
18 Added: span_seconds => 24 * 60 * 60,
19 Added: bucket_stride => 15 * 60,
20 Added: },
21 Added: week => {
22 Added: span_seconds => 7 * 24 * 60 * 60,
23 Added: bucket_stride => 60 * 60,
24 Added: },
25 Added: month => {
26 Added: span_seconds => 30 * 24 * 60 * 60,
27 Added: bucket_stride => 6 * 60 * 60,
28 Added: },
29 Added: year => {
30 Added: span_seconds => 365 * 24 * 60 * 60,
31 Added: bucket_stride => 24 * 60 * 60,
32 Added: },
33 Added: );
34 Added:
35 Added: sub max_series_range {
36 Added: return $MAX_SERIES_RANGE;
37 Added: }
38 Added:
39 Added: sub series_window {
40 Added: my ( $timeframe, $range ) = @_;
41 Added: my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
42 Added: my $bucket_stride = $config->{bucket_stride} * $range;
43 Added: my $span_seconds = $config->{span_seconds} * $range;
44 Added: my $end_epoch
45 Added: = int( time / $bucket_stride ) * $bucket_stride + $bucket_stride;
46 Added: my $start_epoch = $end_epoch - $span_seconds;
47 Added:
48 Added: return {
49 Added: bucket_count => int( $span_seconds / $bucket_stride ),
50 Added: bucket_stride => $bucket_stride,
51 Added: start_epoch => $start_epoch,
52 Added: end_epoch => $end_epoch,
53 Added: start_iso => utc_timestamp($start_epoch),
54 Added: end_iso => utc_timestamp($end_epoch),
55 Added: };
56 Added: }
57 Added:
58 Added: sub utc_timestamp {
59 Added: my ($epoch) = @_;
60 Added: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
61 Added: }
62 Added:
63 Added: 1;
roles/dashboard/public/css/app.css
index c45dd15d..3cf2e84c 100644..100644
@@ -122,21 +122,100 @@
122 122 gap: 1rem;
123 123 }
124 124
125 Added: .download-date-fields[hidden] {
126 Added: display: none;
127 Added: }
128 Added:
125 129 .download-date-field {
126 130 display: grid;
127 131 gap: 0.35rem;
128 132 }
129 133
130 Removed: .download-date-field label {
134 Added: .download-range-fieldset {
135 Added: display: grid;
136 Added: gap: 0.35rem;
137 Added: margin: 0;
138 Added: border: 0;
139 Added: padding: 0;
140 Added: }
141 Added:
142 Added: .download-range-fieldset legend {
143 Added: margin-bottom: 0.35rem;
144 Added: padding: 0;
131 145 font-weight: 700;
132 146 }
133 147
134 Removed: .date-input-control {
135 Removed: display: flex;
136 Removed: gap: 0.5rem;
148 Added: .download-range-control {
149 Added: display: inline-flex;
150 Added: align-self: flex-start;
151 Added: overflow: hidden;
152 Added: border: 1px solid var(--border);
153 Added: border-radius: 0.4rem;
154 Added: background: white;
137 155 }
138 156
139 Removed: .date-input-control input,
157 Added: .download-range-control label {
158 Added: position: relative;
159 Added: margin: 0;
160 Added: cursor: pointer;
161 Added: }
162 Added:
163 Added: .download-range-control input {
164 Added: position: absolute;
165 Added: width: 1px;
166 Added: height: 1px;
167 Added: opacity: 0;
168 Added: }
169 Added:
170 Added: .download-range-control span {
171 Added: display: inline-flex;
172 Added: align-items: center;
173 Added: justify-content: center;
174 Added: min-height: 2.75rem;
175 Added: border: 0;
176 Added: border-left: 1px solid var(--border);
177 Added: padding: 0.5rem 0.75rem;
178 Added: background: white;
179 Added: color: var(--text);
180 Added: cursor: pointer;
181 Added: font: inherit;
182 Added: font-weight: 700;
183 Added: }
184 Added:
185 Added: .download-range-control label:first-child span {
186 Added: border-left: 0;
187 Added: }
188 Added:
189 Added: .download-range-control label:hover span {
190 Added: background: var(--accent-soft);
191 Added: }
192 Added:
193 Added: .download-range-control input:checked + span {
194 Added: background: var(--accent);
195 Added: color: white;
196 Added: }
197 Added:
198 Added: .download-range-control input:focus-visible + span {
199 Added: position: relative;
200 Added: z-index: 1;
201 Added: outline: 2px solid var(--accent);
202 Added: outline-offset: -2px;
203 Added: }
204 Added:
205 Added:
206 Added: .download-probe-field {
207 Added: display: grid;
208 Added: gap: 0.35rem;
209 Added: align-self: flex-start;
210 Added: }
211 Added:
212 Added: .download-date-field label,
213 Added: .download-probe-field label {
214 Added: font-weight: 700;
215 Added: }
216 Added:
217 Added: .download-date-field input,
218 Added: .download-probe-field select,
140 219 .downloads-button,
141 220 .secondary-button {
142 221 min-height: 2.75rem;
@@ -146,6 +225,12 @@
146 225 font: inherit;
147 226 }
148 227
228 Added: .download-probe-field select {
229 Added: min-width: 14rem;
230 Added: background: white;
231 Added: color: var(--text);
232 Added: }
233 Added:
149 234 .downloads-button {
150 235 align-self: flex-end;
151 236 margin-top: auto;
@@ -161,10 +246,14 @@
161 246 }
162 247
163 248 .secondary-button {
249 Added: display: inline-flex;
250 Added: align-items: center;
251 Added: justify-content: center;
164 252 background: white;
165 253 color: var(--accent);
166 254 font-weight: 700;
167 255 cursor: pointer;
256 Added: text-decoration: none;
168 257 }
169 258
170 259 .secondary-button:hover {
@@ -687,6 +776,12 @@
687 776 font-size: 0.95rem;
688 777 }
689 778
779 Added: .probe-card-actions {
780 Added: display: flex;
781 Added: justify-content: flex-end;
782 Added: margin-top: 1rem;
783 Added: }
784 Added:
690 785 .message-log {
691 786 margin-top: 1.25rem;
692 787 }
@@ -849,6 +944,30 @@
849 944 padding-inline: 0.65rem;
850 945 text-align: center;
851 946 white-space: normal;
947 Added: }
948 Added:
949 Added: .download-range-control {
950 Added: display: grid;
951 Added: grid-template-columns: repeat(2, minmax(0, 1fr));
952 Added: width: 100%;
953 Added: }
954 Added:
955 Added: .download-range-control span {
956 Added: width: 100%;
957 Added: border-top: 1px solid var(--border);
958 Added: }
959 Added:
960 Added: .download-range-control label:first-child span,
961 Added: .download-range-control label:nth-child(2) span {
962 Added: border-top: 0;
963 Added: }
964 Added:
965 Added: .download-range-control label:nth-child(odd) span {
966 Added: border-left: 0;
967 Added: }
968 Added:
969 Added: .download-range-control label:last-child {
970 Added: grid-column: span 2;
852 971 }
853 972
854 973 .reading-quick-grid {
roles/dashboard/public/js/dashboard.js
index b98f6e42..b3248dc3 100644..100644
@@ -126,6 +126,10 @@
126 126 return Array.from(document.querySelectorAll("[data-smoothing-toggle]"));
127 127 }
128 128
129 Added: function rawDownloadLinks() {
130 Added: return Array.from(document.querySelectorAll("[data-raw-download]"));
131 Added: }
132 Added:
129 133 function labelForRow(row) {
130 134 return formatChartTime(row.timestamp);
131 135 }
@@ -830,8 +834,22 @@
830 834 option.querySelector('[data-timeframe-adjustment="decrease"]').disabled = range <= 1;
831 835 option.querySelector('[data-timeframe-adjustment="increase"]').disabled = range >= MAX_TIMEFRAME_RANGE;
832 836 });
837 Added:
838 Added: updateRawDownloadLinks();
833 839 }
834 840
841 Added: function updateRawDownloadLinks() {
842 Added: rawDownloadLinks().forEach(link => {
843 Added: const params = new URLSearchParams({
844 Added: probe: link.dataset.probe,
845 Added: timeframe: selectedTimeframe(),
846 Added: range: String(selectedTimeframeRange())
847 Added: });
848 Added:
849 Added: link.href = `/downloads/readings.csv?${params}`;
850 Added: });
851 Added: }
852 Added:
835 853 function setTimeframe(timeframe, expand = false) {
836 854 const selected = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
837 855
@@ -932,6 +950,8 @@
932 950 });
933 951
934 952 const initialProbe = probeFromLocation();
953 Added:
954 Added: updateRawDownloadLinks();
935 955
936 956 if (initialProbe) {
937 957 activateTab(initialProbe);
roles/dashboard/public/js/downloads.js
index a1e40619..dfca783f 100644..100644
@@ -1,9 +1,27 @@
1 Removed: document.querySelectorAll('[data-date-shortcut]').forEach(button => {
2 Removed: button.addEventListener('click', () => {
3 Removed: const input = document.querySelector(button.dataset.dateShortcut);
1 Added: const downloadRangeButtons = Array.from(document.querySelectorAll("[data-download-range]"));
2 Added: const customDateFields = document.querySelector("[data-custom-date-fields]");
3 Added: const downloadFrom = document.querySelector("#download-from");
4 Added: const downloadTo = document.querySelector("#download-to");
4 5
5 Removed: if (input) {
6 Removed: input.value = button.dataset.dateValue;
6 Added: downloadRangeButtons.forEach(option => {
7 Added: option.addEventListener("change", () => {
8 Added: const custom = option.value === "custom";
9 Added:
10 Added: if (customDateFields) {
11 Added: customDateFields.hidden = !custom;
12 Added: }
13 Added:
14 Added: if (custom) {
15 Added: downloadFrom?.focus();
16 Added: return;
17 Added: }
18 Added:
19 Added: if (downloadFrom) {
20 Added: downloadFrom.value = option.dataset.from;
21 Added: }
22 Added:
23 Added: if (downloadTo) {
24 Added: downloadTo.value = option.dataset.to;
7 25 }
8 26 });
9 27 });
roles/dashboard/t/01-graph-page.t
index bfdce543..a3ccaa81 100644..100644
@@ -14,7 +14,8 @@
14 14 $t->get_ok('/probes/ph')
15 15 ->status_is(200)
16 16 ->text_is( 'a.page-brand[href="/"]', 'FAPG' )
17 Removed: ->element_exists('a.header-status-link[href="/status"][data-header-status]')
17 Added: ->element_exists(
18 Added: 'a.header-status-link[href="/status"][data-header-status]')
18 19 ->element_exists('a.header-status-link span.header-status-led')
19 20 ->element_exists('[data-header-status-item="hub"]')
20 21 ->element_exists('[data-header-status-item="ph"]')
@@ -27,9 +28,14 @@
27 28 'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
28 29 ->element_exists('div.card > div.card-heading')
29 30 ->element_exists('div.card > div.card-body')
30 Removed: ->text_is('div.card h1 a[href="/probes/ph"]', 'pH')
31 Added: ->text_is( 'div.card h1 a[href="/probes/ph"]', 'pH' )
31 32 ->element_exists('section#probe-ph[data-panel="ph"]')
32 33 ->element_exists('canvas#chart-ph')
34 Added: ->text_is( 'a.secondary-button[data-raw-download][data-probe="ph"]',
35 Added: 'Download raw data' )
36 Added: ->element_exists(
37 Added: 'a[data-raw-download][href="/downloads/readings.csv?probe=ph&timeframe=day&range=1"]'
38 Added: )
33 39 ->element_exists_not('section#probe-do[data-panel="do"]')
34 40 ->content_unlike(qr/Sensor Reading Quick Status/)
35 41 ->content_like(qr/Hourly/)
@@ -40,8 +46,10 @@
40 46 ->element_exists('div.graph-control > div.timeframe-control')
41 47 ->element_exists('[data-timeframe-option="week"]')
42 48 ->element_exists('[data-timeframe-option="month"]')
43 Removed: ->element_exists('[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
44 Removed: ->element_exists('[data-timeframe-adjustment="increase"][data-timeframe="week"]')
49 Added: ->element_exists(
50 Added: '[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
51 Added: ->element_exists(
52 Added: '[data-timeframe-adjustment="increase"][data-timeframe="week"]')
45 53 ->content_like(qr/data-smoothing-toggle checked/)
46 54 ->content_like(qr/Latest Readings/)
47 55 ->element_exists('section.card.message-log > div.card-heading')
@@ -55,10 +63,10 @@
55 63 ->status_is(200)
56 64 ->element_exists('div.card > div.card-heading')
57 65 ->element_exists('div.card > div.card-body')
58 Removed: ->text_is('h1 a[href="/probes/ph"]', 'pH')
59 Removed: ->text_is('h1 a[href="/probes/do"]', 'Dissolved Oxygen')
60 Removed: ->text_is('h1 a[href="/probes/orp"]', 'ORP')
61 Removed: ->text_is('h1 a[href="/probes/ec"]', 'Electrical Conductivity')
66 Added: ->text_is( 'h1 a[href="/probes/ph"]', 'pH' )
67 Added: ->text_is( 'h1 a[href="/probes/do"]', 'Dissolved Oxygen' )
68 Added: ->text_is( 'h1 a[href="/probes/orp"]', 'ORP' )
69 Added: ->text_is( 'h1 a[href="/probes/ec"]', 'Electrical Conductivity' )
62 70 ->element_exists('section#probe-ph[data-panel="ph"]')
63 71 ->element_exists('section#probe-do[data-panel="do"]')
64 72 ->element_exists('section#probe-orp[data-panel="orp"]')
@@ -67,12 +75,18 @@
67 75 ->element_exists('canvas#chart-do')
68 76 ->element_exists('canvas#chart-orp')
69 77 ->element_exists('canvas#chart-ec')
78 Added: ->element_exists('a[data-raw-download][data-probe="ph"]')
79 Added: ->element_exists('a[data-raw-download][data-probe="do"]')
80 Added: ->element_exists('a[data-raw-download][data-probe="orp"]')
81 Added: ->element_exists('a[data-raw-download][data-probe="ec"]')
70 82 ->element_exists('div.probes-grid')
71 83 ->element_exists('div.graph-control > div.timeframe-control')
72 84 ->element_exists_not('div.card div.timeframe-control')
73 85 ->element_exists('[data-timeframe-option="week"]')
74 Removed: ->element_exists('[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
75 Removed: ->element_exists('[data-timeframe-adjustment="increase"][data-timeframe="week"]')
86 Added: ->element_exists(
87 Added: '[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
88 Added: ->element_exists(
89 Added: '[data-timeframe-adjustment="increase"][data-timeframe="week"]')
76 90 ->element_exists_not('section.message-log')
77 91 ->content_unlike(qr/Reading Messages/);
78 92
roles/dashboard/t/04-readings-series.t
index 8d70a755..effe3dfe 100644..100644
@@ -19,40 +19,33 @@
19 19 ->status_is(200)
20 20 ->json_is( '/probe' => 'ec' )
21 21 ->json_is( '/timeframe' => 'hour' )
22 Removed: ->json_is( '/readings/58/value' => 1050 )
23 Removed: ->json_is( '/readings/58/lower' => 1000 )
24 Removed: ->json_is( '/readings/58/upper' => 1100 )
25 Removed: ->json_is( '/readings/58/count' => 2 )
26 22 ->json_is( '/band/lower_percentile' => 10 )
27 23 ->json_is( '/band/upper_percentile' => 90 )
28 24 ->json_is( '/gap_seconds' => 30 )
29 25 ->json_hasnt('/readings/60');
26 Added: assert_series_reading( $t, 1050, 1000, 1100, 2 );
30 27
31 28 $t->get_ok('/api/readings/ec/series?timeframe=day')
32 29 ->status_is(200)
33 Removed: ->json_is( '/readings/95/value' => 1050 )
34 Removed: ->json_is( '/readings/95/count' => 2 )
35 30 ->json_hasnt('/readings/96');
31 Added: assert_series_reading( $t, 1050, 1000, 1100, 2 );
36 32
37 33 $t->get_ok('/api/readings/ec/series?timeframe=month')
38 34 ->status_is(200)
39 Removed: ->json_is( '/timeframe' => 'month' )
40 Removed: ->json_is( '/readings/119/value' => 1050 )
41 Removed: ->json_is( '/readings/119/count' => 2 )
35 Added: ->json_is( '/timeframe' => 'month' )
42 36 ->json_hasnt('/readings/120');
37 Added: assert_series_reading( $t, 1050, 1000, 1100, 2 );
43 38
44 39 $t->get_ok('/api/readings/ec/series?timeframe=year')
45 40 ->status_is(200)
46 Removed: ->json_is( '/readings/364/value' => 1050 )
47 Removed: ->json_is( '/readings/364/count' => 2 )
48 41 ->json_hasnt('/readings/365');
42 Added: assert_series_reading( $t, 1050, 1000, 1100, 2 );
49 43
50 44 $t->get_ok('/api/readings/ec/series?timeframe=week&range=2')
51 45 ->status_is(200)
52 Removed: ->json_is( '/range' => 2 )
53 Removed: ->json_is( '/readings/167/value' => 1050 )
54 Removed: ->json_is( '/readings/167/count' => 2 )
46 Added: ->json_is( '/range' => 2 )
55 47 ->json_hasnt('/readings/168');
48 Added: assert_series_reading( $t, 1050, 1000, 1100, 2 );
56 49
57 50 $t->get_ok('/api/readings/orp/series?timeframe=hour&smooth=0')
58 51 ->status_is(200)
@@ -84,9 +77,26 @@
84 77
85 78 done_testing;
86 79
80 Added: sub assert_series_reading {
81 Added: my ( $test, $value, $lower, $upper, $count ) = @_;
82 Added: my @populated
83 Added: = grep { defined $_->{value} } $test->tx->res->json->{readings}->@*;
84 Added:
85 Added: is( scalar @populated, 1, 'one series bucket is populated' );
86 Added: is( $populated[0]{value}, $value, 'series bucket has expected value' );
87 Added: is( $populated[0]{lower},
88 Added: $lower, 'series bucket has expected lower band' );
89 Added: is( $populated[0]{upper},
90 Added: $upper, 'series bucket has expected upper band' );
91 Added: is( $populated[0]{count},
92 Added: $count, 'series bucket has expected sample count' );
93 Added:
94 Added: return;
95 Added: }
96 Added:
87 97 sub seed_series_readings {
88 98 my ($t) = @_;
89 Removed: my $bucket = minute_floor(time);
99 Added: my $sample = time - 5;
90 100 my $db = $t->app->sqlite->db;
91 101
92 102 $db->query(
@@ -94,8 +104,8 @@
94 104 INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
95 105 VALUES (?, ?, ?, ?, ?, ?)
96 106 },
97 Removed: utc_timestamp( $bucket - 50 ),
98 Removed: utc_timestamp( $bucket - 50 ),
107 Added: utc_timestamp( $sample - 1 ),
108 Added: utc_timestamp( $sample - 1 ),
99 109 'fapg-daq-zero-ec-01',
100 110 'ec',
101 111 1000,
@@ -107,8 +117,8 @@
107 117 INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
108 118 VALUES (?, ?, ?, ?, ?, ?)
109 119 },
110 Removed: utc_timestamp( $bucket - 40 ),
111 Removed: utc_timestamp( $bucket - 40 ),
120 Added: utc_timestamp($sample),
121 Added: utc_timestamp($sample),
112 122 'fapg-daq-zero-ec-01',
113 123 'ec',
114 124 1100,
roles/dashboard/t/06-downloads.t
index d9a768c4..e06af1e2 100644..100644
@@ -10,30 +10,79 @@
10 10 use Dashboard::Test qw(test_app test_empty_app);
11 11
12 12 my $t = test_app();
13 Added: seed_recent_readings($t);
13 14
14 15 $t->get_ok('/downloads')
15 16 ->status_is(200)
16 Removed: ->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
17 Added: ->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
17 18 ->text_is( 'div.nav-links a[href="/downloads"]', 'Downloads' )
18 Removed: ->element_exists('a.header-status-link[href="/status"][data-header-status]')
19 Added: ->element_exists(
20 Added: 'a.header-status-link[href="/status"][data-header-status]')
19 21 ->element_exists('a.header-status-link span.header-status-led')
20 22 ->element_exists('div.card.downloads-card > div.card-heading')
21 23 ->element_exists('div.card.downloads-card > div.card-body')
22 24 ->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
25 Added: ->text_is(
26 Added: 'select#download-probe[name="probe"] option[value=""][selected]',
27 Added: 'All probes' )
28 Added: ->text_is( 'select#download-probe option[value="ph"]', 'pH' )
29 Added: ->text_is( 'select#download-probe option[value="do"]',
30 Added: 'Dissolved Oxygen' )
31 Added: ->text_is( 'select#download-probe option[value="orp"]', 'ORP' )
32 Added: ->text_is( 'select#download-probe option[value="ec"]',
33 Added: 'Electrical Conductivity' )
34 Added: ->element_exists('fieldset.download-range-fieldset')
35 Added: ->text_is( 'div.download-range-control label:nth-child(1) span', 'Today' )
36 Added: ->element_exists(
37 Added: 'input[type="radio"][name="date_range"][value="today"][data-download-range]'
38 Added: )
39 Added: ->text_is( 'div.download-range-control label:nth-child(2) span',
40 Added: 'Past week' )
41 Added: ->element_exists(
42 Added: 'input[type="radio"][name="date_range"][value="week"][data-download-range][checked]'
43 Added: )
44 Added: ->text_is( 'div.download-range-control label:nth-child(3) span',
45 Added: 'Past month' )
46 Added: ->text_is( 'div.download-range-control label:nth-child(4) span',
47 Added: 'All time' )
48 Added: ->text_is( 'div.download-range-control label:nth-child(5) span',
49 Added: 'Custom' )
50 Added: ->element_exists(
51 Added: 'input[type="radio"][name="date_range"][value="custom"][data-download-range]'
52 Added: )
53 Added: ->element_exists(
54 Added: 'div.download-date-fields[data-custom-date-fields][hidden]')
23 55 ->element_exists('input[name="from"][type="date"][required]')
24 56 ->element_exists('input[name="to"][type="date"][required]')
25 Removed: ->text_is('button.secondary-button[data-date-shortcut="#download-from"]', 'Oldest')
26 Removed: ->text_is('button.secondary-button[data-date-shortcut="#download-to"]', 'Today')
27 Removed: ->text_is('button.downloads-button[type="submit"]', 'Download CSV');
57 Added: ->element_exists_not('button[data-date-shortcut]')
58 Added: ->text_is( 'button.downloads-button[type="submit"]', 'Download CSV' );
28 59
29 60 $t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
30 61 ->status_is(200)
31 Removed: ->header_like( 'Content-Type' => qr{text/csv} )
62 Added: ->header_like( 'Content-Type' => qr{text/csv} )
32 63 ->header_like( 'Content-Disposition' => qr{attachment} )
33 64 ->content_like(qr/^timestamp,received_at,node,probe,value,unit/m)
34 65 ->content_like(qr/2026-07-05T12:00:00Z/)
35 66 ->content_like(qr/2026-07-05T12:00:15Z/);
36 67
68 Added: $t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05&probe=ph')
69 Added: ->status_is(200)
70 Added: ->content_like(qr/2026-07-05T12:00:00Z/)
71 Added: ->content_unlike(qr/2026-07-05T12:00:15Z/);
72 Added:
73 Added: $t->get_ok('/downloads/readings.csv?probe=ph&timeframe=hour&range=1')
74 Added: ->status_is(200)
75 Added: ->header_like( 'Content-Disposition' => qr/fapg-daq-ph-hour-1\.csv/ )
76 Added: ->content_like(qr/,"ph",/)
77 Added: ->content_unlike(qr/,"do",/);
78 Added:
79 Added: $t->get_ok('/downloads/readings.csv?probe=unknown&timeframe=day&range=1')
80 Added: ->status_is(400);
81 Added:
82 Added: $t->get_ok(
83 Added: '/downloads/readings.csv?probe=unknown&from=2026-07-05&to=2026-07-05')
84 Added: ->status_is(400);
85 Added:
37 86 $t->get_ok('/downloads/readings.csv?from=2026-07-06&to=2026-07-05')
38 87 ->status_is(400)
39 88 ->content_like(qr/from date must not be after the to date/);
@@ -42,10 +91,41 @@
42 91
43 92 $empty_t->get_ok('/downloads')
44 93 ->status_is(200)
45 Removed: ->element_exists('button.secondary-button[data-date-shortcut="#download-from"]');
94 Added: ->element_exists('select#download-probe option[value=""][selected]')
95 Added: ->element_exists('input[data-download-range][value="custom"]');
46 96
47 97 $empty_t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
48 98 ->status_is(200)
49 99 ->content_is("timestamp,received_at,node,probe,value,unit\n");
50 100
51 101 done_testing;
102 Added:
103 Added: sub seed_recent_readings {
104 Added: my ($test) = @_;
105 Added: my $timestamp = utc_timestamp( time - 60 );
106 Added:
107 Added: for my $reading ( [ 'ph', 7.15, 'pH' ], [ 'do', 8.20, 'mg/L' ], ) {
108 Added: my ( $probe, $value, $unit ) = $reading->@*;
109 Added:
110 Added: $test->app->sqlite->db->query(
111 Added: q{
112 Added: INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
113 Added: VALUES (?, ?, ?, ?, ?, ?)
114 Added: },
115 Added: $timestamp,
116 Added: $timestamp,
117 Added: "fapg-daq-zero-$probe-01",
118 Added: $probe,
119 Added: $value,
120 Added: $unit,
121 Added: );
122 Added: }
123 Added:
124 Added: return;
125 Added: }
126 Added:
127 Added: sub utc_timestamp {
128 Added: my ($epoch) = @_;
129 Added: require POSIX;
130 Added: return POSIX::strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
131 Added: }
roles/dashboard/templates/dashboard/downloads.html.ep
index 0c778a39..96399df9 100644..100644
@@ -13,20 +13,33 @@
13 13
14 14 <div class="card-body">
15 15 <form class="downloads-form" action="/downloads/readings.csv" method="get">
16 Removed: <div class="download-date-fields">
16 Added: <div class="download-probe-field">
17 Added: <label for="download-probe">Probe</label>
18 Added: <select id="download-probe" name="probe">
19 Added: <option value="" selected>All probes</option>
20 Added: % for my $probe ($probes->@*) {
21 Added: <option value="<%= $probe->{key} %>"><%= $probe->{label} %></option>
22 Added: % }
23 Added: </select>
24 Added: </div>
25 Added: <fieldset class="download-range-fieldset">
26 Added: <legend>Date range</legend>
27 Added: <div class="download-range-control">
28 Added: <label><input type="radio" name="date_range" value="today" data-download-range data-from="<%= $to %>" data-to="<%= $to %>"><span>Today</span></label>
29 Added: <label><input type="radio" name="date_range" value="week" data-download-range data-from="<%= $from %>" data-to="<%= $to %>" checked><span>Past week</span></label>
30 Added: <label><input type="radio" name="date_range" value="month" data-download-range data-from="<%= $month_from %>" data-to="<%= $to %>"><span>Past month</span></label>
31 Added: <label><input type="radio" name="date_range" value="all" data-download-range data-from="<%= $oldest %>" data-to="<%= $to %>"><span>All time</span></label>
32 Added: <label><input type="radio" name="date_range" value="custom" data-download-range><span>Custom</span></label>
33 Added: </div>
34 Added: </fieldset>
35 Added: <div class="download-date-fields" data-custom-date-fields hidden>
17 36 <div class="download-date-field">
18 37 <label for="download-from">From</label>
19 Removed: <div class="date-input-control">
20 Removed: <input id="download-from" name="from" type="date" value="<%= $from %>" required>
21 Removed: <button class="secondary-button" type="button" data-date-shortcut="#download-from" data-date-value="<%= $oldest %>">Oldest</button>
22 Removed: </div>
38 Added: <input id="download-from" name="from" type="date" value="<%= $from %>" required>
23 39 </div>
24 40 <div class="download-date-field">
25 41 <label for="download-to">To</label>
26 Removed: <div class="date-input-control">
27 Removed: <input id="download-to" name="to" type="date" value="<%= $to %>" required>
28 Removed: <button class="secondary-button" type="button" data-date-shortcut="#download-to" data-date-value="<%= $to %>">Today</button>
29 Removed: </div>
42 Added: <input id="download-to" name="to" type="date" value="<%= $to %>" required>
30 43 </div>
31 44 </div>
32 45 <button class="downloads-button" type="submit">Download CSV</button>
roles/dashboard/templates/dashboard/probe-card.html.ep
index a474205b..9148337b 100644..100644
@@ -20,5 +20,13 @@
20 20 Waiting for data…
21 21 </p>
22 22 </section>
23 Added:
24 Added: <div class="probe-card-actions">
25 Added: <a
26 Added: class="secondary-button graph-download-button"
27 Added: href="/downloads/readings.csv?probe=<%= $probe->{key} %>&amp;timeframe=day&amp;range=1"
28 Added: data-raw-download
29 Added: data-probe="<%= $probe->{key} %>">Download raw data</a>
30 Added: </div>
23 31 </div>
24 32 </div>