Improve probe chart timeframe controls

Commit
28e4f36d168facf5f9c7e74c418b5c27ce319df4
Author
Codex <codex@openai.com>
Author date
Committer
Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
index 49acb106..16fe4a78 100644..100644
@@ -7,107 +7,115 @@
7 7 use Time::Local qw(timegm);
8 8
9 9 my $STATUS_REACHABLE_SECONDS = 120;
10 Added: my $MAX_SERIES_RANGE = 24;
10 11 my %SERIES_TIMEFRAMES = (
11 Removed: hour => {
12 Removed: bucket_count => 60,
13 Removed: bucket_sql => '%Y-%m-%dT%H:%M:00Z',
14 Removed: bucket_stride => 60,
15 Removed: floor_to => 'minute',
16 Removed: },
17 Removed: day => {
18 Removed: bucket_count => 24,
19 Removed: bucket_sql => '%Y-%m-%dT%H:00:00Z',
20 Removed: bucket_stride => 60 * 60,
21 Removed: floor_to => 'hour',
22 Removed: },
23 Removed: week => {
24 Removed: bucket_count => 7,
25 Removed: bucket_sql => '%Y-%m-%dT00:00:00Z',
26 Removed: bucket_stride => 24 * 60 * 60,
27 Removed: floor_to => 'day',
28 Removed: },
29 Removed: year => {
30 Removed: bucket_count => 12,
31 Removed: bucket_sql => '%Y-%m-01T00:00:00Z',
32 Removed: bucket_stride => undef,
33 Removed: floor_to => 'month',
34 Removed: },
12 Added: hour => {
13 Added: bucket_count => 60,
14 Added: bucket_sql => '%Y-%m-%dT%H:%M:00Z',
15 Added: bucket_stride => 60,
16 Added: floor_to => 'minute',
17 Added: },
18 Added: day => {
19 Added: bucket_count => 24,
20 Added: bucket_sql => '%Y-%m-%dT%H:00:00Z',
21 Added: bucket_stride => 60 * 60,
22 Added: floor_to => 'hour',
23 Added: },
24 Added: week => {
25 Added: bucket_count => 7,
26 Added: bucket_sql => '%Y-%m-%dT00:00:00Z',
27 Added: bucket_stride => 24 * 60 * 60,
28 Added: floor_to => 'day',
29 Added: },
30 Added: year => {
31 Added: bucket_count => 12,
32 Added: bucket_sql => '%Y-%m-01T00:00:00Z',
33 Added: bucket_stride => undef,
34 Added: floor_to => 'month',
35 Added: },
35 36 );
36 37
37 38 sub list ($self) {
38 Removed: my $probe = $self->param('probe') // '';
39 Added: my $probe = $self->param('probe') // '';
39 40
40 Removed: my %known = map { $_->{key} => 1 } $self->probes->@*;
41 Added: my %known = map { $_->{key} => 1 } $self->probes->@*;
41 42
42 Removed: return $self->render(
43 Removed: status => 404,
44 Removed: json => { error => "Unknown probe type: $probe", },
45 Removed: ) unless $known{$probe};
43 Added: return $self->render(
44 Added: status => 404,
45 Added: json => { error => "Unknown probe type: $probe", },
46 Added: ) unless $known{$probe};
46 47
47 Removed: my $limit = $self->param('limit') // 300;
48 Added: my $limit = $self->param('limit') // 300;
48 49
49 Removed: $limit = 300 unless $limit =~ /^\d+$/;
50 Removed: $limit = 2_000 if $limit > 2_000;
50 Added: $limit = 300 unless $limit =~ /^\d+$/;
51 Added: $limit = 2_000 if $limit > 2_000;
51 52
52 Removed: my @where = ('probe = ?');
53 Removed: my @bind = ($probe);
54 Removed: my $since = $self->param('since');
53 Added: my @where = ('probe = ?');
54 Added: my @bind = ($probe);
55 Added: my $since = $self->param('since');
55 56
56 Removed: if ( defined $since
57 Removed: && $since =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ )
58 Removed: {
59 Removed: push @where, 'COALESCE(received_at, timestamp) >= ?';
60 Removed: push @bind, $since;
61 Removed: }
57 Added: if ( defined $since
58 Added: && $since =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ )
59 Added: {
60 Added: push @where, 'COALESCE(received_at, timestamp) >= ?';
61 Added: push @bind, $since;
62 Added: }
62 63
63 Removed: my $rows = $self->sqlite->db->query(
64 Removed: q{
64 Added: my $rows = $self->sqlite->db->query(
65 Added: q{
65 66 SELECT timestamp, received_at, node, probe, value, unit
66 67 FROM readings
67 68 WHERE
68 69 }
69 Removed: . join( "\n AND ", @where ) . q{
70 Added: . join( "\n AND ", @where ) . q{
70 71 ORDER BY COALESCE(received_at, timestamp) DESC, id DESC
71 72 LIMIT ?
72 73 },
73 Removed: @bind,
74 Removed: $limit,
75 Removed: )->hashes->to_array;
74 Added: @bind,
75 Added: $limit,
76 Added: )->hashes->to_array;
76 77
77 Removed: $rows = [ reverse $rows->@* ];
78 Added: $rows = [ reverse $rows->@* ];
78 79
79 Removed: $self->render(
80 Removed: json => {
81 Removed: probe => $probe,
82 Removed: readings => $rows,
83 Removed: },
84 Removed: );
80 Added: $self->render(
81 Added: json => {
82 Added: probe => $probe,
83 Added: readings => $rows,
84 Added: },
85 Added: );
85 86 }
86 87
87 88 sub series ($self) {
88 Removed: my $probe = $self->param('probe') // '';
89 Added: my $probe = $self->param('probe') // '';
89 90
90 Removed: my %known = map { $_->{key} => 1 } $self->probes->@*;
91 Added: my %known = map { $_->{key} => 1 } $self->probes->@*;
91 92
92 Removed: return $self->render(
93 Removed: status => 404,
94 Removed: json => { error => "Unknown probe type: $probe", },
95 Removed: ) unless $known{$probe};
93 Added: return $self->render(
94 Added: status => 404,
95 Added: json => { error => "Unknown probe type: $probe", },
96 Added: ) unless $known{$probe};
96 97
97 Removed: my $timeframe = $self->param('timeframe') // 'day';
98 Removed: my $window = series_window($timeframe);
98 Added: my $timeframe = $self->param('timeframe') // 'day';
99 Added: my $range = $self->param('range') // 1;
99 100
100 Removed: return $self->render(
101 Removed: status => 400,
102 Removed: json => { error => "Unknown timeframe: $timeframe", },
103 Removed: ) unless defined $window;
101 Added: return $self->render(
102 Added: status => 400,
103 Added: json => { error => 'Range must be an integer from 1 to 24', },
104 Added: ) if $range !~ /\A\d+\z/ || $range < 1 || $range > $MAX_SERIES_RANGE;
104 105
105 Removed: my $unit = ( grep { $_->{key} eq $probe } $self->probes->@* )[0]{unit};
106 Removed: my $points = series_points($window);
107 Removed: my %index = map { $points->[$_]{timestamp} => $_ } 0 .. $points->$#*;
106 Added: my $window = series_window( $timeframe, $range );
108 107
109 Removed: my $rows = $self->sqlite->db->query(
110 Removed: q{
108 Added: return $self->render(
109 Added: status => 400,
110 Added: json => { error => "Unknown timeframe: $timeframe", },
111 Added: ) unless defined $window;
112 Added:
113 Added: my $unit = ( grep { $_->{key} eq $probe } $self->probes->@* )[0]{unit};
114 Added: my $points = series_points($window);
115 Added: my %index = map { $points->[$_]{timestamp} => $_ } 0 .. $points->$#*;
116 Added:
117 Added: my $rows = $self->sqlite->db->query(
118 Added: q{
111 119 SELECT
112 120 strftime(?, COALESCE(received_at, timestamp)) AS bucket,
113 121 AVG(value) AS value,
@@ -119,47 +127,48 @@
119 127 GROUP BY bucket
120 128 ORDER BY bucket
121 129 },
122 Removed: $window->{bucket_sql},
123 Removed: $probe,
124 Removed: $window->{start_iso},
125 Removed: $window->{end_iso},
126 Removed: )->hashes->to_array;
130 Added: $window->{bucket_sql},
131 Added: $probe,
132 Added: $window->{start_iso},
133 Added: $window->{end_iso},
134 Added: )->hashes->to_array;
127 135
128 Removed: for my $row ( $rows->@* ) {
129 Removed: my $point_index = $index{ $row->{bucket} };
130 Removed: next if !defined $point_index;
136 Added: for my $row ( $rows->@* ) {
137 Added: my $point_index = $index{ $row->{bucket} };
138 Added: next if !defined $point_index;
131 139
132 Removed: $points->[$point_index] = {
133 Removed: timestamp => $row->{bucket},
134 Removed: value => 0 + $row->{value},
135 Removed: unit => $unit,
136 Removed: probe => $probe,
137 Removed: count => 0 + $row->{count},
138 Removed: };
139 Removed: }
140 Added: $points->[$point_index] = {
141 Added: timestamp => $row->{bucket},
142 Added: value => 0 + $row->{value},
143 Added: unit => $unit,
144 Added: probe => $probe,
145 Added: count => 0 + $row->{count},
146 Added: };
147 Added: }
140 148
141 Removed: $self->render(
142 Removed: json => {
143 Removed: probe => $probe,
144 Removed: timeframe => $timeframe,
145 Removed: readings => $points,
146 Removed: },
147 Removed: );
149 Added: $self->render(
150 Added: json => {
151 Added: probe => $probe,
152 Added: timeframe => $timeframe,
153 Added: range => 0 + $range,
154 Added: readings => $points,
155 Added: },
156 Added: );
148 157 }
149 158
150 159 sub status ($self) {
151 Removed: my $probe = $self->param('probe') // '';
160 Added: my $probe = $self->param('probe') // '';
152 161
153 Removed: my %known = map { $_->{key} => 1 } $self->status_items->@*;
162 Added: my %known = map { $_->{key} => 1 } $self->status_items->@*;
154 163
155 Removed: return $self->render(
156 Removed: status => 404,
157 Removed: json => { error => "Unknown status item: $probe", },
158 Removed: ) unless $known{$probe};
164 Added: return $self->render(
165 Added: status => 404,
166 Added: json => { error => "Unknown status item: $probe", },
167 Added: ) unless $known{$probe};
159 168
160 Removed: my $row = eval {
161 Removed: $self->sqlite->db->query(
162 Removed: q{
169 Added: my $row = eval {
170 Added: $self->sqlite->db->query(
171 Added: q{
163 172 SELECT timestamp, received_at, node, probe, status, message, error, device
164 173 FROM node_status
165 174 WHERE probe = ?
@@ -167,176 +176,158 @@
167 176 ORDER BY COALESCE(timestamp, received_at) DESC, id DESC
168 177 LIMIT 1
169 178 },
170 Removed: $probe,
171 Removed: )->hash;
172 Removed: };
179 Added: $probe,
180 Added: )->hash;
181 Added: };
173 182
174 Removed: $row = undef if $@;
175 Removed: mark_unreachable_if_stale($row) if $row;
183 Added: $row = undef if $@;
184 Added: mark_unreachable_if_stale($row) if $row;
176 185
177 Removed: $self->render(
178 Removed: json => {
179 Removed: probe => $probe,
180 Removed: status => $row,
181 Removed: },
182 Removed: );
186 Added: $self->render(
187 Added: json => {
188 Added: probe => $probe,
189 Added: status => $row,
190 Added: },
191 Added: );
183 192 }
184 193
185 194 sub mark_unreachable_if_stale ($row) {
186 Removed: my $received_at = $row->{received_at} // $row->{timestamp};
187 Removed: return if !defined $received_at || $received_at ge reachable_since();
195 Added: my $received_at = $row->{received_at} // $row->{timestamp};
196 Added: return if !defined $received_at || $received_at ge reachable_since();
188 197
189 Removed: $row->{status} = 'unreachable';
190 Removed: $row->{message}
191 Removed: = "No status received in the last $STATUS_REACHABLE_SECONDS seconds";
198 Added: $row->{status} = 'unreachable';
199 Added: $row->{message} = "No status received in the last $STATUS_REACHABLE_SECONDS seconds";
192 200 }
193 201
194 202 sub reachable_since {
195 Removed: return strftime( '%Y-%m-%dT%H:%M:%SZ',
196 Removed: gmtime( time - $STATUS_REACHABLE_SECONDS ) );
203 Added: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime( time - $STATUS_REACHABLE_SECONDS ) );
197 204 }
198 205
199 Removed: sub series_window ($timeframe) {
200 Removed: my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
201 Removed: my @now = gmtime;
202 Removed: my ( $sec, $min, $hour, $mday, $mon, $year ) = @now[ 0 .. 5 ];
206 Added: sub series_window ( $timeframe, $range ) {
207 Added: my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
208 Added: my @now = gmtime;
209 Added: my ( $sec, $min, $hour, $mday, $mon, $year ) = @now[ 0 .. 5 ];
203 210
204 Removed: if ( $config->{floor_to} eq 'minute' ) {
205 Removed: my $end_epoch = timegm( 0, $min, $hour, $mday, $mon, $year ) + 60;
211 Added: if ( $config->{floor_to} eq 'minute' ) {
212 Added: my $end_epoch = timegm( 0, $min, $hour, $mday, $mon, $year ) + 60;
206 213
207 Removed: return {
208 Removed: bucket_count => $config->{bucket_count},
209 Removed: bucket_sql => $config->{bucket_sql},
210 Removed: bucket_stride => $config->{bucket_stride},
211 Removed: start_epoch => $end_epoch
212 Removed: - ( $config->{bucket_count} * $config->{bucket_stride} ),
213 Removed: end_epoch => $end_epoch,
214 Removed: start_iso => utc_timestamp(
215 Removed: $end_epoch
216 Removed: - ( $config->{bucket_count} * $config->{bucket_stride} )
217 Removed: ),
218 Removed: end_iso => utc_timestamp($end_epoch),
219 Removed: };
220 Removed: }
214 Added: return {
215 Added: bucket_count => $config->{bucket_count} * $range,
216 Added: bucket_sql => $config->{bucket_sql},
217 Added: bucket_stride => $config->{bucket_stride},
218 Added: start_epoch => $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} ),
219 Added: end_epoch => $end_epoch,
220 Added: start_iso => utc_timestamp(
221 Added: $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} )
222 Added: ),
223 Added: end_iso => utc_timestamp($end_epoch),
224 Added: };
225 Added: }
221 226
222 Removed: if ( $config->{floor_to} eq 'hour' ) {
223 Removed: my $end_epoch
224 Removed: = timegm( 0, 0, $hour, $mday, $mon, $year ) + ( 60 * 60 );
227 Added: if ( $config->{floor_to} eq 'hour' ) {
228 Added: my $end_epoch = timegm( 0, 0, $hour, $mday, $mon, $year ) + ( 60 * 60 );
225 229
226 Removed: return {
227 Removed: bucket_count => $config->{bucket_count},
228 Removed: bucket_sql => $config->{bucket_sql},
229 Removed: bucket_stride => $config->{bucket_stride},
230 Removed: start_epoch => $end_epoch
231 Removed: - ( $config->{bucket_count} * $config->{bucket_stride} ),
232 Removed: end_epoch => $end_epoch,
233 Removed: start_iso => utc_timestamp(
234 Removed: $end_epoch
235 Removed: - ( $config->{bucket_count} * $config->{bucket_stride} )
236 Removed: ),
237 Removed: end_iso => utc_timestamp($end_epoch),
238 Removed: };
239 Removed: }
230 Added: return {
231 Added: bucket_count => $config->{bucket_count} * $range,
232 Added: bucket_sql => $config->{bucket_sql},
233 Added: bucket_stride => $config->{bucket_stride},
234 Added: start_epoch => $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} ),
235 Added: end_epoch => $end_epoch,
236 Added: start_iso => utc_timestamp(
237 Added: $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} )
238 Added: ),
239 Added: end_iso => utc_timestamp($end_epoch),
240 Added: };
241 Added: }
240 242
241 Removed: if ( $config->{floor_to} eq 'day' ) {
242 Removed: my $end_epoch
243 Removed: = timegm( 0, 0, 0, $mday, $mon, $year ) + ( 24 * 60 * 60 );
243 Added: if ( $config->{floor_to} eq 'day' ) {
244 Added: my $end_epoch = timegm( 0, 0, 0, $mday, $mon, $year ) + ( 24 * 60 * 60 );
244 245
245 Removed: return {
246 Removed: bucket_count => $config->{bucket_count},
247 Removed: bucket_sql => $config->{bucket_sql},
248 Removed: bucket_stride => $config->{bucket_stride},
249 Removed: start_epoch => $end_epoch
250 Removed: - ( $config->{bucket_count} * $config->{bucket_stride} ),
251 Removed: end_epoch => $end_epoch,
252 Removed: start_iso => utc_timestamp(
253 Removed: $end_epoch
254 Removed: - ( $config->{bucket_count} * $config->{bucket_stride} )
255 Removed: ),
256 Removed: end_iso => utc_timestamp($end_epoch),
257 Removed: };
258 Removed: }
246 Added: return {
247 Added: bucket_count => $config->{bucket_count} * $range,
248 Added: bucket_sql => $config->{bucket_sql},
249 Added: bucket_stride => $config->{bucket_stride},
250 Added: start_epoch => $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} ),
251 Added: end_epoch => $end_epoch,
252 Added: start_iso => utc_timestamp(
253 Added: $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} )
254 Added: ),
255 Added: end_iso => utc_timestamp($end_epoch),
256 Added: };
257 Added: }
259 258
260 Removed: if ( $config->{floor_to} eq 'month' ) {
261 Removed: my ( $start_year, $start_month )
262 Removed: = normalize_month( $year, $mon - 11 );
263 Removed: my ( $end_year, $end_month ) = normalize_month( $year, $mon + 1 );
259 Added: if ( $config->{floor_to} eq 'month' ) {
260 Added: my ( $start_year, $start_month ) =
261 Added: normalize_month( $year, $mon - ( $config->{bucket_count} * $range - 1 ) );
262 Added: my ( $end_year, $end_month ) = normalize_month( $year, $mon + 1 );
264 263
265 Removed: return {
266 Removed: bucket_count => $config->{bucket_count},
267 Removed: bucket_sql => $config->{bucket_sql},
268 Removed: bucket_stride => $config->{bucket_stride},
269 Removed: start_epoch => timegm( 0, 0, 0, 1, $start_month, $start_year ),
270 Removed: end_epoch => timegm( 0, 0, 0, 1, $end_month, $end_year ),
271 Removed: start_iso => utc_timestamp(
272 Removed: timegm( 0, 0, 0, 1, $start_month, $start_year )
273 Removed: ),
274 Removed: end_iso =>
275 Removed: utc_timestamp( timegm( 0, 0, 0, 1, $end_month, $end_year ) ),
276 Removed: };
277 Removed: }
264 Added: return {
265 Added: bucket_count => $config->{bucket_count} * $range,
266 Added: bucket_sql => $config->{bucket_sql},
267 Added: bucket_stride => $config->{bucket_stride},
268 Added: start_epoch => timegm( 0, 0, 0, 1, $start_month, $start_year ),
269 Added: end_epoch => timegm( 0, 0, 0, 1, $end_month, $end_year ),
270 Added: start_iso => utc_timestamp( timegm( 0, 0, 0, 1, $start_month, $start_year ) ),
271 Added: end_iso => utc_timestamp( timegm( 0, 0, 0, 1, $end_month, $end_year ) ),
272 Added: };
273 Added: }
278 274
279 Removed: return undef;
275 Added: return undef;
280 276 }
281 277
282 278 sub series_points ($window) {
283 Removed: my @points;
279 Added: my @points;
284 280
285 Removed: if ( defined $window->{bucket_stride} ) {
286 Removed: for my $i ( 0 .. $window->{bucket_count} - 1 ) {
287 Removed: push @points,
288 Removed: {
289 Removed: timestamp => utc_timestamp(
290 Removed: $window->{start_epoch} + ( $i * $window->{bucket_stride} )
291 Removed: ),
292 Removed: value => undef,
293 Removed: unit => undef,
294 Removed: probe => undef,
295 Removed: count => 0,
296 Removed: };
297 Removed: }
298 Removed:
299 Removed: return \@points;
281 Added: if ( defined $window->{bucket_stride} ) {
282 Added: for my $i ( 0 .. $window->{bucket_count} - 1 ) {
283 Added: push @points,
284 Added: {
285 Added: timestamp => utc_timestamp( $window->{start_epoch} + ( $i * $window->{bucket_stride} ) ),
286 Added: value => undef,
287 Added: unit => undef,
288 Added: probe => undef,
289 Added: count => 0,
290 Added: };
300 291 }
301 292
302 Removed: my @cursor = gmtime( $window->{start_epoch} );
303 Removed: my ( $year, $month ) = ( $cursor[5], $cursor[4] );
293 Added: return \@points;
294 Added: }
304 295
305 Removed: for my $i ( 0 .. $window->{bucket_count} - 1 ) {
306 Removed: my ( $bucket_year, $bucket_month )
307 Removed: = normalize_month( $year, $month + $i );
296 Added: my @cursor = gmtime( $window->{start_epoch} );
297 Added: my ( $year, $month ) = ( $cursor[5], $cursor[4] );
308 298
309 Removed: push @points,
310 Removed: {
311 Removed: timestamp => utc_timestamp(
312 Removed: timegm( 0, 0, 0, 1, $bucket_month, $bucket_year )
313 Removed: ),
314 Removed: value => undef,
315 Removed: unit => undef,
316 Removed: probe => undef,
317 Removed: count => 0,
318 Removed: };
319 Removed: }
299 Added: for my $i ( 0 .. $window->{bucket_count} - 1 ) {
300 Added: my ( $bucket_year, $bucket_month ) = normalize_month( $year, $month + $i );
320 301
321 Removed: return \@points;
302 Added: push @points,
303 Added: {
304 Added: timestamp => utc_timestamp( timegm( 0, 0, 0, 1, $bucket_month, $bucket_year ) ),
305 Added: value => undef,
306 Added: unit => undef,
307 Added: probe => undef,
308 Added: count => 0,
309 Added: };
310 Added: }
311 Added:
312 Added: return \@points;
322 313 }
323 314
324 315 sub normalize_month ( $year, $month ) {
325 Removed: while ( $month < 0 ) {
326 Removed: $month += 12;
327 Removed: $year--;
328 Removed: }
316 Added: while ( $month < 0 ) {
317 Added: $month += 12;
318 Added: $year--;
319 Added: }
329 320
330 Removed: while ( $month > 11 ) {
331 Removed: $month -= 12;
332 Removed: $year++;
333 Removed: }
321 Added: while ( $month > 11 ) {
322 Added: $month -= 12;
323 Added: $year++;
324 Added: }
334 325
335 Removed: return ( $year, $month );
326 Added: return ( $year, $month );
336 327 }
337 328
338 329 sub utc_timestamp ($epoch) {
339 Removed: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
330 Added: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
340 331 }
341 332
342 333 1;
roles/dashboard/public/css/app.css
index e9844a43..d1dcde3f 100644..100644
@@ -492,6 +492,51 @@
492 492 margin-bottom: 1rem;
493 493 }
494 494
495 Added: .probes-timeframe-control .timeframe-option {
496 Added: display: flex;
497 Added: min-width: 4.75rem;
498 Added: }
499 Added:
500 Added: .probes-timeframe-control .timeframe-option + .timeframe-option {
501 Added: border-left: 1px solid var(--border);
502 Added: }
503 Added:
504 Added: .probes-timeframe-control .timeframe-button {
505 Added: width: 100%;
506 Added: border-left: 0;
507 Added: }
508 Added:
509 Added: .probes-timeframe-control .timeframe-option.is-expanded {
510 Added: min-width: 12rem;
511 Added: }
512 Added:
513 Added: .timeframe-adjustment {
514 Added: display: none;
515 Added: min-width: 2.75rem;
516 Added: border: 0;
517 Added: padding: 0.45rem 0.75rem;
518 Added: background: var(--accent-soft);
519 Added: color: var(--accent);
520 Added: cursor: pointer;
521 Added: font: inherit;
522 Added: font-size: 1.15rem;
523 Added: font-weight: 700;
524 Added: }
525 Added:
526 Added: .timeframe-option.is-expanded .timeframe-adjustment {
527 Added: display: block;
528 Added: }
529 Added:
530 Added: .timeframe-adjustment:hover:not(:disabled) {
531 Added: background: var(--accent);
532 Added: color: white;
533 Added: }
534 Added:
535 Added: .timeframe-adjustment:disabled {
536 Added: color: var(--muted);
537 Added: cursor: not-allowed;
538 Added: }
539 Added:
495 540 .probes-grid .card + .card {
496 541 margin-top: 0;
497 542 }
@@ -833,6 +878,19 @@
833 878 grid-template-columns: 1fr 1fr;
834 879 width: 100%;
835 880 border-radius: 0.5rem;
881 Added: }
882 Added:
883 Added: .probes-timeframe-control {
884 Added: display: block;
885 Added: }
886 Added:
887 Added: .probes-timeframe-control .timeframe-option {
888 Added: min-width: 0;
889 Added: }
890 Added:
891 Added: .probes-timeframe-control .timeframe-option.is-expanded {
892 Added: grid-column: span 2;
893 Added: min-width: 0;
836 894 }
837 895
838 896 .timeframe-button {
roles/dashboard/public/js/dashboard.js
index 2679edf6..eebc6cdf 100644..100644
@@ -56,8 +56,13 @@
56 56 }
57 57 };
58 58 const SMOOTHING_WINDOW = 7;
59 Added: const MAX_TIMEFRAME_RANGE = 24;
59 60 let activeTimeframe = DEFAULT_TIMEFRAME;
61 Added: let expandedTimeframe = null;
60 62 let smoothingEnabled = true;
63 Added: const timeframeRanges = Object.fromEntries(
64 Added: Object.keys(TIMEFRAMES).map(timeframe => [timeframe, 1])
65 Added: );
61 66
62 67 function sensorStatusItems() {
63 68 return Array.from(document.querySelectorAll("[data-sensor-status]"));
@@ -167,6 +172,15 @@
167 172 return TIMEFRAMES[activeTimeframe] ? activeTimeframe : DEFAULT_TIMEFRAME;
168 173 }
169 174
175 Added: function selectedTimeframeRange() {
176 Added: return timeframeRanges[selectedTimeframe()] || 1;
177 Added: }
178 Added:
179 Added: function timeframeRangeLabel(timeframe, range) {
180 Added: const unit = timeframe === "hour" ? "hour" : timeframe === "day" ? "day" : timeframe === "week" ? "week" : "year";
181 Added: return `${range} ${unit}${range === 1 ? "" : "s"}`;
182 Added: }
183 Added:
170 184 function formatChartTime(timestamp) {
171 185 const date = new Date(timestamp);
172 186 const timeframe = selectedTimeframe();
@@ -180,21 +194,18 @@
180 194
181 195 if (timeframe === "day") {
182 196 return date.toLocaleTimeString([], {
183 Removed: hour: "2-digit",
184 Removed: minute: "2-digit"
197 Added: hour: "2-digit"
185 198 });
186 199 }
187 200
188 201 if (timeframe === "week") {
189 202 return date.toLocaleString([], {
190 Removed: weekday: "short",
191 Removed: hour: "2-digit"
203 Added: weekday: "short"
192 204 });
193 205 }
194 206
195 207 return date.toLocaleDateString([], {
196 Removed: month: "short",
197 Removed: year: "numeric"
208 Added: month: "short"
198 209 });
199 210 }
200 211
@@ -636,7 +647,8 @@
636 647
637 648 try {
638 649 const seriesParams = new URLSearchParams({
639 Removed: timeframe: selectedTimeframe()
650 Added: timeframe: selectedTimeframe(),
651 Added: range: String(selectedTimeframeRange())
640 652 });
641 653 const readingsParams = new URLSearchParams({
642 654 limit: "10"
@@ -751,16 +763,57 @@
751 763 loadProbe(probe);
752 764 }
753 765
754 Removed: function setTimeframe(timeframe) {
766 Added: function updateTimeframeControls() {
767 Added: document.querySelectorAll("[data-timeframe-option]").forEach(option => {
768 Added: const timeframe = option.dataset.timeframeOption;
769 Added: const range = timeframeRanges[timeframe] || 1;
770 Added: const expanded = timeframe === expandedTimeframe;
771 Added: const button = option.querySelector(".timeframe-button");
772 Added:
773 Added: option.classList.toggle("is-active", timeframe === activeTimeframe);
774 Added: option.classList.toggle("is-expanded", expanded);
775 Added: button.classList.toggle("is-active", timeframe === activeTimeframe);
776 Added: button.textContent = expanded
777 Added: ? timeframeRangeLabel(timeframe, range)
778 Added: : button.dataset.timeframeLabel;
779 Added:
780 Added: option.querySelector('[data-timeframe-adjustment="decrease"]').disabled = range <= 1;
781 Added: option.querySelector('[data-timeframe-adjustment="increase"]').disabled = range >= MAX_TIMEFRAME_RANGE;
782 Added: });
783 Added: }
784 Added:
785 Added: function setTimeframe(timeframe, expand = false) {
755 786 activeTimeframe = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
787 Added: expandedTimeframe = expand ? activeTimeframe : null;
756 788
757 789 document.querySelectorAll(".timeframe-button").forEach(button => {
758 790 button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
759 791 });
760 792
793 Added: updateTimeframeControls();
761 794 displayedProbes().forEach(loadProbe);
762 795 }
763 796
797 Added: function adjustTimeframeRange(timeframe, adjustment) {
798 Added: if (!TIMEFRAMES[timeframe]) {
799 Added: return;
800 Added: }
801 Added:
802 Added: activeTimeframe = timeframe;
803 Added: expandedTimeframe = timeframe;
804 Added: timeframeRanges[timeframe] = Math.min(
805 Added: MAX_TIMEFRAME_RANGE,
806 Added: Math.max(1, (timeframeRanges[timeframe] || 1) + adjustment)
807 Added: );
808 Added:
809 Added: document.querySelectorAll(".timeframe-button").forEach(button => {
810 Added: button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
811 Added: });
812 Added:
813 Added: updateTimeframeControls();
814 Added: displayedProbes().forEach(loadProbe);
815 Added: }
816 Added:
764 817 function setSmoothing(enabled) {
765 818 smoothingEnabled = enabled;
766 819
@@ -797,7 +850,14 @@
797 850
798 851 document.querySelectorAll(".timeframe-button").forEach(button => {
799 852 button.addEventListener("click", () => {
800 Removed: setTimeframe(button.dataset.timeframe);
853 Added: setTimeframe(button.dataset.timeframe, Boolean(button.closest("[data-timeframe-option]")));
854 Added: });
855 Added: });
856 Added:
857 Added: document.querySelectorAll("[data-timeframe-adjustment]").forEach(button => {
858 Added: button.addEventListener("click", () => {
859 Added: const adjustment = button.dataset.timeframeAdjustment === "increase" ? 1 : -1;
860 Added: adjustTimeframeRange(button.dataset.timeframe, adjustment);
801 861 });
802 862 });
803 863
roles/dashboard/t/01-graph-page.t
index b91afeb8..a8bba209 100644..100644
@@ -56,6 +56,9 @@
56 56 ->element_exists('div.probes-grid')
57 57 ->element_exists('div.probes-timeframe-control > div.timeframe-control')
58 58 ->element_exists_not('div.card div.timeframe-control')
59 Added: ->element_exists('[data-timeframe-option="week"]')
60 Added: ->element_exists('[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
61 Added: ->element_exists('[data-timeframe-adjustment="increase"][data-timeframe="week"]')
59 62 ->element_exists_not('section.message-log')
60 63 ->content_unlike(qr/Reading Messages/);
61 64
roles/dashboard/t/04-readings-series.t
index 4afcade1..6d60bb76 100644..100644
@@ -33,7 +33,16 @@
33 33 ->json_is( '/readings/11/count' => 2 )
34 34 ->json_hasnt('/readings/12');
35 35
36 Added: $t->get_ok('/api/readings/ec/series?timeframe=week&range=2')
37 Added: ->status_is(200)
38 Added: ->json_is( '/range' => 2 )
39 Added: ->json_is( '/readings/13/value' => 1050 )
40 Added: ->json_is( '/readings/13/count' => 2 )
41 Added: ->json_hasnt('/readings/14');
42 Added:
36 43 $t->get_ok('/api/readings/ec/series?timeframe=century')->status_is(400);
44 Added: $t->get_ok('/api/readings/ec/series?timeframe=week&range=0')->status_is(400);
45 Added: $t->get_ok('/api/readings/ec/series?timeframe=week&range=25')->status_is(400);
37 46
38 47 done_testing;
39 48
roles/dashboard/templates/dashboard/probes.html.ep
index 14f5be75..65aea078 100644..100644
@@ -5,10 +5,27 @@
5 5
6 6 <div class="probes-timeframe-control">
7 7 <div class="timeframe-control" role="group" aria-label="All probe chart timeframes">
8 Removed: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
9 Removed: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
10 Removed: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
11 Removed: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
8 Added: % for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ year => 'Yearly' ] ) {
9 Added: <div class="timeframe-option" data-timeframe-option="<%= $timeframe->[0] %>">
10 Added: <button
11 Added: class="timeframe-adjustment"
12 Added: type="button"
13 Added: data-timeframe-adjustment="decrease"
14 Added: data-timeframe="<%= $timeframe->[0] %>"
15 Added: aria-label="Decrease <%= $timeframe->[0] %> range">−</button>
16 Added: <button
17 Added: class="timeframe-button<%= $timeframe->[0] eq 'day' ? ' is-active' : '' %>"
18 Added: type="button"
19 Added: data-timeframe="<%= $timeframe->[0] %>"
20 Added: data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
21 Added: <button
22 Added: class="timeframe-adjustment"
23 Added: type="button"
24 Added: data-timeframe-adjustment="increase"
25 Added: data-timeframe="<%= $timeframe->[0] %>"
26 Added: aria-label="Increase <%= $timeframe->[0] %> range">+</button>
27 Added: </div>
28 Added: % }
12 29 </div>
13 30 </div>
14 31