feat select download probes by checkbox

Replace the probe dropdown with multi-select checkboxes. Export selected probe sets as timestamp-aligned columns.

Commit
c9f8796f3c91ed961cd3a83f1bd1990694bf0a6d
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Download.pm
index 8df3aeb2..e72be1c3 100644..100644
@@ -21,7 +21,7 @@
21 21
22 22 my $from = $self->param('from') // '';
23 23 my $to = $self->param('to') // '';
24 Removed: my $probe = $self->param('probe') // '';
24 Added: my @probes = $self->every_param('probe')->@*;
25 25 my $from_epoch = date_epoch($from);
26 26 my $to_epoch = date_epoch($to);
27 27 my %known = map { $_->{key} => 1 } $self->probes->@*;
@@ -38,11 +38,10 @@
38 38
39 39 return $self->render(
40 40 status => 400,
41 Removed: text => 'The probe must be blank or a known probe type.',
42 Removed: ) if $probe ne '' && !$known{$probe};
41 Added: text => 'Every selected probe must be a known probe type.',
42 Added: ) if grep { !$known{$_} } @probes;
43 43
44 Removed: my $probe_filter = $probe eq '' ? '' : ' AND probe = ?';
45 Removed: my @probe_bind = $probe eq '' ? () : ($probe);
44 Added: my $probe_filter = @probes ? ' AND probe IN (' . join( ',', ('?') x @probes ) . ')' : '';
46 45 my $rows = raw_readings(
47 46 $self,
48 47 'COALESCE(received_at, timestamp) >= ? '
@@ -51,14 +50,15 @@
51 50 'COALESCE(received_at, timestamp)',
52 51 utc_timestamp($from_epoch),
53 52 utc_timestamp( $to_epoch + ( 24 * 60 * 60 ) ),
54 Removed: @probe_bind,
53 Added: @probes,
55 54 );
55 Added: my $probe_name = join '-', @probes;
56 56 my $filename
57 Removed: = $probe eq ''
57 Added: = !@probes
58 58 ? "fapg-daq-readings-$from-to-$to.$format"
59 Removed: : "fapg-daq-readings-$probe-$from-to-$to.$format";
59 Added: : "fapg-daq-readings-$probe_name-$from-to-$to.$format";
60 60
61 Removed: render_download( $self, $rows, $filename, $format, $probe eq '' );
61 Added: render_download( $self, $rows, $filename, $format, \@probes );
62 62 }
63 63
64 64 sub timeframe_readings ( $self, $format ) {
@@ -98,7 +98,7 @@
98 98 );
99 99 my $filename = "fapg-daq-$probe-$timeframe-$range.$format";
100 100
101 Removed: render_download( $self, $rows, $filename, $format, 0 );
101 Added: render_download( $self, $rows, $filename, $format, [$probe] );
102 102 }
103 103
104 104 sub raw_readings ( $self, $where, $order_by, @bind ) {
@@ -121,10 +121,13 @@
121 121 )->hashes->to_array;
122 122 }
123 123
124 Removed: sub render_download ( $self, $rows, $filename, $format, $all_probes ) {
124 Added: sub render_download ( $self, $rows, $filename, $format, $selected_probes ) {
125 Added: my $pivoted = $selected_probes->@* != 1;
125 126 my ( $headers, $table )
126 Removed: = $all_probes ? pivoted_readings( $self, $rows ) : raw_reading_table($rows);
127 Removed: my @timestamp_columns = $all_probes ? (0) : ( 0, 1 );
127 Added: = $pivoted
128 Added: ? pivoted_readings( $self, $rows, $selected_probes )
129 Added: : raw_reading_table($rows);
130 Added: my @timestamp_columns = $pivoted ? (0) : ( 0, 1 );
128 131
129 132 return render_xlsx( $self, $headers, $table, $filename, \@timestamp_columns )
130 133 if $format eq 'xlsx';
@@ -148,8 +151,10 @@
148 151 return ( \@headers, \@table );
149 152 }
150 153
151 Removed: sub pivoted_readings ( $self, $rows ) {
152 Removed: my @probes = map { $_->{key} } $self->probes->@*;
154 Added: sub pivoted_readings ( $self, $rows, $selected_probes ) {
155 Added: my %selected = map { $_ => 1 } $selected_probes->@*;
156 Added: my @probes = map { $_->{key} }
157 Added: grep { !%selected || $selected{ $_->{key} } } $self->probes->@*;
153 158 my %values;
154 159
155 160 for my $row ( $rows->@* ) {
roles/dashboard/t/06-downloads.t
index 5ee45ef0..495b7406 100644..100644
@@ -21,9 +21,7 @@
21 21 ->element_exists('.navbar-nav a.nav-link.active[href="/downloads"][aria-current="page"]')
22 22 ->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
23 23 ->element_exists('[data-downloads-error]')
24 Removed: ->text_is(
25 Removed: 'select#download-probe[name="probe"] option[value=""][selected]',
26 Removed: 'All probes' )
24 Added: ->element_exists('[data-download-probes]')
27 25 ->element_exists('input[name="date_range"][value="week"][checked]')
28 26 ->element_exists(
29 27 '[data-custom-date-fields][hidden] input[name="from"][type="date"]')
@@ -40,7 +38,9 @@
40 38 );
41 39
42 40 for my $probe (qw(ph do orp ec)) {
43 Removed: $t->element_exists(qq{select#download-probe option[value="$probe"]});
41 Added: $t->element_exists(
42 Added: qq{input#download-probe-${probe}[type="checkbox"][name="probe"][value="$probe"]}
43 Added: );
44 44 }
45 45
46 46 for my $range (qw(today week month all custom)) {
roles/dashboard/t/07-downloads-csv.t
index aa16a386..151c3da4 100644..100644
@@ -44,6 +44,11 @@
44 44 ->content_like(qr/2026-07-05 12:00:00/)
45 45 ->content_unlike(qr/2026-07-05 12:00:15/);
46 46
47 Added: $t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05&probe=ph&probe=do')
48 Added: ->status_is(200)
49 Added: ->content_like(qr/^timestamp,ph,do/m)
50 Added: ->content_unlike(qr/^timestamp,ph,do,orp,ec/m);
51 Added:
47 52 $t->get_ok('/downloads/readings.csv?probe=ph&timeframe=hour&range=1')
48 53 ->status_is(200)
49 54 ->header_like( 'Content-Disposition' => qr/fapg-daq-ph-hour-1\.csv/ )
roles/dashboard/templates/dashboard/downloads.html.ep
index 31bc6071..781c4c64 100644..100644
@@ -13,15 +13,18 @@
13 13 <div class="alert alert-danger d-none" role="alert" id="downloads-error" data-downloads-error></div>
14 14 <form action="/downloads/readings.csv" method="get" class="downloads-form d-flex flex-column gap-3">
15 15
16 Removed: <div>
17 Removed: <label class="form-label fw-bold" for="download-probe">Probe</label>
18 Removed: <select class="form-select w-auto" id="download-probe" name="probe">
19 Removed: <option value="" selected>All probes</option>
16 Added: <fieldset>
17 Added: <legend class="fw-bold col-form-label pt-0 mb-2">Probes</legend>
18 Added: <p class="form-text mt-0">Leave all boxes clear to download every probe.</p>
19 Added: <div class="d-flex flex-wrap gap-3" data-download-probes>
20 20 % for my $probe ($probes->@*) {
21 Removed: <option value="<%= $probe->{key} %>"><%= $probe->{label} %></option>
21 Added: <div class="form-check">
22 Added: <input class="form-check-input" id="download-probe-<%= $probe->{key} %>" name="probe" type="checkbox" value="<%= $probe->{key} %>">
23 Added: <label class="form-check-label" for="download-probe-<%= $probe->{key} %>"><%= $probe->{label} %></label>
24 Added: </div>
22 25 % }
23 Removed: </select>
24 Removed: </div>
26 Added: </div>
27 Added: </fieldset>
25 28
26 29 <fieldset>
27 30 <legend class="fw-bold col-form-label pt-0 mb-2">Date range</legend>