[Perl] DAQ system for the FAPG.
feat format export timestamps for spreadsheets
Export CSV timestamps as YYYY-MM-DD HH:MM:SS values. Store XLSX timestamps as native date-time cells with the same display format.
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Download.pm
@@ -124,17 +124,25 @@
124
124
sub render_download ( $self, $rows, $filename, $format, $all_probes ) {
125
125
my ( $headers, $table )
126
126
= $all_probes ? pivoted_readings( $self, $rows ) : raw_reading_table($rows);
127
Added:
my @timestamp_columns = $all_probes ? (0) : ( 0, 1 );
127
128
128
Removed:
return render_xlsx( $self, $headers, $table, $filename ) if $format eq 'xlsx';
129
Added:
return render_xlsx( $self, $headers, $table, $filename, \@timestamp_columns )
130
Added:
if $format eq 'xlsx';
129
131
130
Removed:
return render_csv( $self, $headers, $table, $filename );
132
Added:
return render_csv( $self, $headers, $table, $filename, \@timestamp_columns );
131
133
}
132
134
133
135
sub raw_reading_table ($rows) {
134
136
my @headers = qw(timestamp received_at node probe value unit);
135
137
my @table = map {
136
138
my $reading = $_;
137
Removed:
[ map { $reading->{$_} } @headers ];
139
Added:
[
140
Added:
map {
141
Added:
/\A(?:timestamp|received_at)\z/
142
Added:
? spreadsheet_timestamp( $reading->{$_} )
143
Added:
: $reading->{$_};
144
Added:
} @headers
145
Added:
];
138
146
} $rows->@*;
139
147
140
148
return ( \@headers, \@table );
@@ -153,17 +161,20 @@
153
161
154
162
my @table = map {
155
163
my $timestamp = $_;
156
Removed:
[ $timestamp, map { $values{$timestamp}{$_} } @probes ];
164
Added:
[ spreadsheet_timestamp($timestamp), map { $values{$timestamp}{$_} } @probes ];
157
165
} sort keys %values;
158
166
159
167
return ( [ 'timestamp', @probes ], \@table );
160
168
}
161
169
162
Removed:
sub render_csv ( $self, $headers, $table, $filename ) {
170
Added:
sub render_csv ( $self, $headers, $table, $filename, $timestamp_columns ) {
163
171
my @lines = ( join ',', $headers->@* );
172
Added:
my %timestamp_column = map { $_ => 1 } $timestamp_columns->@*;
164
173
165
174
for my $row ( $table->@* ) {
166
Removed:
push @lines, join ',', map { csv_value($_) } $row->@*;
175
Added:
push @lines, join ',', map {
176
Added:
csv_value( $row->[$_], $timestamp_column{$_} );
177
Added:
} 0 .. $#$row;
167
178
}
168
179
169
180
$self->res->headers->content_type('text/csv; charset=utf-8');
@@ -172,8 +183,8 @@
172
183
$self->render( data => join( "\n", @lines ) . "\n" );
173
184
}
174
185
175
Removed:
sub render_xlsx ( $self, $headers, $table, $filename ) {
176
Removed:
my $data = xlsx_data( $headers, $table );
186
Added:
sub render_xlsx ( $self, $headers, $table, $filename, $timestamp_columns ) {
187
Added:
my $data = xlsx_data( $headers, $table, $timestamp_columns );
177
188
178
189
$self->res->headers->content_type(
179
190
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
@@ -182,12 +193,12 @@
182
193
$self->render( data => $data );
183
194
}
184
195
185
Removed:
sub xlsx_data ( $headers, $table ) {
186
Removed:
my $sheet = worksheet_xml( [ $headers, $table->@* ] );
196
Added:
sub xlsx_data ( $headers, $table, $timestamp_columns = [] ) {
197
Added:
my $sheet = worksheet_xml( [ $headers, $table->@* ], $timestamp_columns );
187
198
my %parts = (
188
199
'[Content_Types].xml' => <<'XML',
189
200
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
190
Removed:
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>
201
Added:
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>
191
202
XML
192
203
'_rels/.rels' => <<'XML',
193
204
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -199,8 +210,12 @@
199
210
XML
200
211
'xl/_rels/workbook.xml.rels' => <<'XML',
201
212
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
202
Removed:
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>
213
Added:
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>
203
214
XML
215
Added:
'xl/styles.xml' => <<'XML',
216
Added:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
217
Added:
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy-mm-dd hh:mm:ss"/></numFmts><fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts><fills count="1"><fill><patternFill patternType="none"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" applyNumberFormat="1"/></cellXfs></styleSheet>
218
Added:
XML
204
219
'xl/worksheets/sheet1.xml' => $sheet,
205
220
);
206
221
my $data = '';
@@ -219,8 +234,9 @@
219
234
return $data;
220
235
}
221
236
222
Removed:
sub worksheet_xml ($rows) {
237
Added:
sub worksheet_xml ( $rows, $timestamp_columns ) {
223
238
my @rows;
239
Added:
my %timestamp_column = map { $_ => 1 } $timestamp_columns->@*;
224
240
225
241
for my $row_index ( 0 .. $#$rows ) {
226
242
my @cells;
@@ -230,7 +246,9 @@
230
246
next if !defined $value;
231
247
232
248
my $reference = spreadsheet_column( $column_index + 1 ) . ( $row_index + 1 );
233
Removed:
push @cells, xlsx_cell( $reference, $value );
249
Added:
push @cells,
250
Added:
xlsx_cell( $reference, $value,
251
Added:
$row_index && $timestamp_column{$column_index} );
234
252
}
235
253
236
254
push @rows, '<row r="' . ( $row_index + 1 ) . '">' . join( '', @cells ) . '</row>';
@@ -241,7 +259,11 @@
241
259
. join( '', @rows ) . '</sheetData></worksheet>';
242
260
}
243
261
244
Removed:
sub xlsx_cell ( $reference, $value ) {
262
Added:
sub xlsx_cell ( $reference, $value, $is_timestamp = 0 ) {
263
Added:
my $serial = excel_timestamp($value);
264
Added:
return qq{<c r="$reference" s="1"><v>$serial</v></c>}
265
Added:
if $is_timestamp && defined $serial;
266
Added:
245
267
return qq{<c r="$reference"><v>$value</v></c>}
246
268
if $value =~ /\A-?(?:\d+(?:\.\d*)?|\.\d+)\z/;
247
269
@@ -261,12 +283,33 @@
261
283
return $name;
262
284
}
263
285
286
Added:
sub spreadsheet_timestamp ($timestamp) {
287
Added:
return undef if !defined $timestamp;
288
Added:
289
Added:
$timestamp =~ s/T/ /;
290
Added:
$timestamp =~ s/Z\z//;
291
Added:
return $timestamp;
292
Added:
}
293
Added:
294
Added:
sub excel_timestamp ($timestamp) {
295
Added:
return undef
296
Added:
unless $timestamp =~ /\A(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\z/;
297
Added:
298
Added:
my $epoch = eval { timegm( $6, $5, $4, $3, $2 - 1, $1 ) };
299
Added:
return undef if !defined $epoch || $@;
300
Added:
301
Added:
my $excel_epoch = timegm( 0, 0, 0, 30, 11, 1899 );
302
Added:
return ( $epoch - $excel_epoch ) / ( 24 * 60 * 60 );
303
Added:
}
304
Added:
264
305
sub xml_value ($value) {
265
306
$value =~ s/&/&/gr =~ s/</</gr =~ s/>/>/gr;
266
307
}
267
308
268
Removed:
sub csv_value ($value) {
309
Added:
sub csv_value ( $value, $is_timestamp = 0 ) {
269
310
$value //= '';
311
Added:
return $value if $is_timestamp;
312
Added:
270
313
$value =~ s/"/""/g;
271
314
272
315
return qq{"$value"};
roles/dashboard/t/07-downloads-csv.t
@@ -18,8 +18,8 @@
18
18
->header_like( 'Content-Type' => qr{text/csv} )
19
19
->header_like( 'Content-Disposition' => qr{attachment} )
20
20
->content_like(qr/^timestamp,ph,do,orp,ec/m)
21
Removed:
->content_like(qr/2026-07-05T12:00:00Z/)
22
Removed:
->content_like(qr/2026-07-05T12:00:15Z/);
21
Added:
->content_like(qr/2026-07-05 12:00:00/)
22
Added:
->content_like(qr/2026-07-05 12:00:15/);
23
23
24
24
$t->get_ok('/downloads/readings.xlsx?from=2026-07-05&to=2026-07-05')
25
25
->status_is(200)
@@ -29,15 +29,20 @@
29
29
->content_like(qr/^PK/);
30
30
31
31
my $worksheet = xlsx_member( $t->tx->res->body, 'xl/worksheets/sheet1.xml' );
32
Added:
my $styles = xlsx_member( $t->tx->res->body, 'xl/styles.xml' );
32
33
like( $worksheet, qr{<c r="A1" t="inlineStr"><is><t>timestamp</t>},
33
34
'XLSX worksheet has a timestamp column' );
34
35
like( $worksheet, qr{<c r="B1" t="inlineStr"><is><t>ph</t>},
35
36
'XLSX worksheet has a pH column' );
37
Added:
like( $worksheet, qr{<c r="A2" s="1"><v>},
38
Added:
'XLSX timestamps are native date-time cells' );
39
Added:
like( $styles, qr{formatCode="yyyy-mm-dd hh:mm:ss"},
40
Added:
'XLSX date-time cells use a spreadsheet-friendly format' );
36
41
37
42
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05&probe=ph')
38
43
->status_is(200)
39
Removed:
->content_like(qr/2026-07-05T12:00:00Z/)
40
Removed:
->content_unlike(qr/2026-07-05T12:00:15Z/);
44
Added:
->content_like(qr/2026-07-05 12:00:00/)
45
Added:
->content_unlike(qr/2026-07-05 12:00:15/);
41
46
42
47
$t->get_ok('/downloads/readings.csv?probe=ph&timeframe=hour&range=1')
43
48
->status_is(200)