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.

Commit
ea8833494a9e1f2f09df5f773cf93729d0017d28
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 024fdd38..8df3aeb2 100644..100644
@@ -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/&/&amp;/gr =~ s/</&lt;/gr =~ s/>/&gt;/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
index c14de04e..aa16a386 100644..100644
@@ -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)