Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/RangePicker.pm
Diff
/var/www/vhosts/3dshawn.com/site1/MODS/RangePicker.pm
added on local at 2026-07-10 23:21:51
Added
+183
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to e78c23ea94cd
to e78c23ea94cd
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | package MODS::RangePicker; | |
| 2 | #====================================================================== | |
| 3 | # DriftSense -- canonical range-picker helper (portfolio .tr-chip pattern). | |
| 4 | # | |
| 5 | # Usage from a CGI: | |
| 6 | # | |
| 7 | # use MODS::RangePicker; | |
| 8 | # my $r = MODS::RangePicker::parse($cgi); # reads: days, on, from, to | |
| 9 | # # $r = { days, on, from, to, is_custom, is_day, | |
| 10 | # # sql_lower_epoch, sql_upper_epoch, sql_where, range_label, | |
| 11 | # # preset_24h, preset_7d, preset_30d, preset_90d, preset_1y, preset_custom } | |
| 12 | # | |
| 13 | # my $picker_html = MODS::RangePicker::picker_html( | |
| 14 | # action => '/file_changes.cgi', | |
| 15 | # range => $r, | |
| 16 | # hidden => { q => $q }, # any params to preserve across chip clicks | |
| 17 | # ts_col => 'date_time', # column name to show in "Showing:" | |
| 18 | # ); | |
| 19 | # | |
| 20 | # Then in the CGI SQL: | |
| 21 | # | |
| 22 | # my $where = $r->{sql_where}->('date_time'); | |
| 23 | # # -> "date_time >= FROM_UNIXTIME(<lo>) AND date_time <= FROM_UNIXTIME(<hi>)" | |
| 24 | # | |
| 25 | # All timestamps handled as UTC epoch seconds so the same code works | |
| 26 | # whether the DB session tz is UTC or America/Chicago. | |
| 27 | #====================================================================== | |
| 28 | use strict; | |
| 29 | use warnings; | |
| 30 | use POSIX (); | |
| 31 | ||
| 32 | my %PRESET_DAYS = ( '1' => 1, '7' => 1, '30' => 1, '90' => 1, '365' => 1 ); | |
| 33 | ||
| 34 | #--------------------------------------------------------------------- | |
| 35 | # parse($cgi) -- returns hashref of parsed + normalized range state | |
| 36 | #--------------------------------------------------------------------- | |
| 37 | sub parse { | |
| 38 | my ($cgi) = @_; | |
| 39 | my $days = int($cgi->param('days') || 0); | |
| 40 | my $on = $cgi->param('on') || ''; | |
| 41 | my $from = $cgi->param('from') || ''; | |
| 42 | my $to = $cgi->param('to') || ''; | |
| 43 | ||
| 44 | my ($lo, $hi); | |
| 45 | my $now = time; | |
| 46 | my $is_custom = 0; | |
| 47 | my $is_day = 0; | |
| 48 | my $label = ''; | |
| 49 | ||
| 50 | if ($on && $on =~ /^(\d{4})-(\d{2})-(\d{2})$/) { | |
| 51 | # Single-day drilldown (from timeline click). | |
| 52 | require Time::Local; | |
| 53 | $lo = eval { Time::Local::timelocal(0, 0, 0, $3, $2 - 1, $1 - 1900) } || 0; | |
| 54 | $hi = $lo + 86400 - 1; | |
| 55 | $is_day = 1; | |
| 56 | $label = "Day of $on"; | |
| 57 | $days = 1; | |
| 58 | } | |
| 59 | elsif ($from || $to) { | |
| 60 | # Custom range. | |
| 61 | $lo = _parse_datetime_local($from) || ($now - 7 * 86400); | |
| 62 | $hi = _parse_datetime_local($to) || $now; | |
| 63 | $is_custom = 1; | |
| 64 | my $lo_h = POSIX::strftime('%b %d %H:%M', localtime($lo)); | |
| 65 | my $hi_h = POSIX::strftime('%b %d %H:%M', localtime($hi)); | |
| 66 | $label = "$lo_h -- $hi_h"; | |
| 67 | $days = 0; # signals "not a preset" | |
| 68 | } | |
| 69 | else { | |
| 70 | # Preset window ending now. | |
| 71 | $days = 7 unless $PRESET_DAYS{"$days"}; | |
| 72 | $lo = $now - $days * 86400; | |
| 73 | $hi = $now; | |
| 74 | $label = $days == 1 ? 'Last 24 hours' | |
| 75 | : $days == 365 ? 'Last year' | |
| 76 | : "Last $days days"; | |
| 77 | } | |
| 78 | ||
| 79 | return { | |
| 80 | days => $days, | |
| 81 | on => $on, | |
| 82 | from => $from, | |
| 83 | to => $to, | |
| 84 | is_custom => $is_custom, | |
| 85 | is_day => $is_day, | |
| 86 | sql_lower_epoch => $lo, | |
| 87 | sql_upper_epoch => $hi, | |
| 88 | range_label => $label, | |
| 89 | preset_24h => (!$is_custom && !$is_day && $days == 1) ? 1 : 0, | |
| 90 | preset_7d => (!$is_custom && !$is_day && $days == 7) ? 1 : 0, | |
| 91 | preset_30d => (!$is_custom && !$is_day && $days == 30) ? 1 : 0, | |
| 92 | preset_90d => (!$is_custom && !$is_day && $days == 90) ? 1 : 0, | |
| 93 | preset_1y => (!$is_custom && !$is_day && $days == 365) ? 1 : 0, | |
| 94 | preset_custom => $is_custom ? 1 : 0, | |
| 95 | # SQL WHERE builder -- pass the column name. | |
| 96 | sql_where => sub { | |
| 97 | my $col = shift; | |
| 98 | return "$col >= FROM_UNIXTIME($lo) AND $col <= FROM_UNIXTIME($hi)"; | |
| 99 | }, | |
| 100 | # datetime-local prefill for the custom inputs | |
| 101 | from_input => $from || POSIX::strftime('%Y-%m-%dT%H:%M', localtime($lo)), | |
| 102 | to_input => $to || POSIX::strftime('%Y-%m-%dT%H:%M', localtime($hi)), | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | #--------------------------------------------------------------------- | |
| 107 | # picker_html(%args) -- emits the standardized chip picker | |
| 108 | #--------------------------------------------------------------------- | |
| 109 | sub picker_html { | |
| 110 | my %a = @_; | |
| 111 | my $action = $a{action} || '/'; | |
| 112 | my $r = $a{range} or return ''; | |
| 113 | my $hidden = $a{hidden} || {}; | |
| 114 | my $extra = $a{extra_html} || ''; | |
| 115 | ||
| 116 | my $qs_extra = ''; | |
| 117 | my $hidden_inputs = ''; | |
| 118 | foreach my $k (sort keys %$hidden) { | |
| 119 | my $v = defined $hidden->{$k} ? $hidden->{$k} : ''; | |
| 120 | next if $v eq ''; | |
| 121 | my $ev = _esc($v); | |
| 122 | $qs_extra .= "&$k=$ev"; | |
| 123 | $hidden_inputs .= qq~<input type="hidden" name="$k" value="$ev">~; | |
| 124 | } | |
| 125 | ||
| 126 | my $act = sub { $_[0] ? ' is-active' : '' }; | |
| 127 | my $lbl = _esc($r->{range_label} || ''); | |
| 128 | my $from_in = _esc($r->{from_input} || ''); | |
| 129 | my $to_in = _esc($r->{to_input} || ''); | |
| 130 | ||
| 131 | my $custom_display = $r->{preset_custom} ? 'inline-flex' : 'none'; | |
| 132 | ||
| 133 | return qq~ | |
| 134 | <form id="rangeForm" method="get" action="$action" class="range-bar"> | |
| 135 | $hidden_inputs | |
| 136 | <span class="rb-label">Range</span> | |
| 137 | <a class="tr-chip@{[ $act->($r->{preset_24h}) ]}" href="?days=1$qs_extra">24h</a> | |
| 138 | <a class="tr-chip@{[ $act->($r->{preset_7d}) ]}" href="?days=7$qs_extra">7d</a> | |
| 139 | <a class="tr-chip@{[ $act->($r->{preset_30d}) ]}" href="?days=30$qs_extra">30d</a> | |
| 140 | <a class="tr-chip@{[ $act->($r->{preset_90d}) ]}" href="?days=90$qs_extra">90d</a> | |
| 141 | <a class="tr-chip@{[ $act->($r->{preset_1y}) ]}" href="?days=365$qs_extra">1y</a> | |
| 142 | <button type="button" class="tr-chip@{[ $act->($r->{preset_custom}) ]}" id="customBtn">Custom…</button> | |
| 143 | <div id="customWrap" style="display:$custom_display;gap:6px;align-items:center;margin-left:6px"> | |
| 144 | <input type="datetime-local" name="from" value="$from_in"> | |
| 145 | <span style="color:var(--text-muted);font-size:12px">to</span> | |
| 146 | <input type="datetime-local" name="to" value="$to_in"> | |
| 147 | <button type="submit" class="tr-chip is-active" style="border:none">Apply</button> | |
| 148 | </div> | |
| 149 | $extra | |
| 150 | <span style="margin-left:auto;font-size:11.5px;color:var(--text-muted)">Showing: <strong style="color:var(--text)">$lbl</strong></span> | |
| 151 | </form> | |
| 152 | <script> | |
| 153 | (function(){ | |
| 154 | var btn = document.getElementById('customBtn'); | |
| 155 | var wrap = document.getElementById('customWrap'); | |
| 156 | if (!btn || !wrap) return; | |
| 157 | btn.addEventListener('click', function(){ | |
| 158 | var open = wrap.style.display !== 'none'; | |
| 159 | wrap.style.display = open ? 'none' : 'inline-flex'; | |
| 160 | if (!open) { var f = wrap.querySelector('input[type=datetime-local]'); if (f) f.focus(); } | |
| 161 | }); | |
| 162 | })(); | |
| 163 | </script> | |
| 164 | ~; | |
| 165 | } | |
| 166 | ||
| 167 | #--------------------------------------------------------------------- | |
| 168 | sub _parse_datetime_local { | |
| 169 | my $s = shift; | |
| 170 | return 0 unless $s; | |
| 171 | # HTML datetime-local format: YYYY-MM-DDTHH:MM(:SS)? | |
| 172 | return 0 unless $s =~ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/; | |
| 173 | require Time::Local; | |
| 174 | return eval { Time::Local::timelocal($6 || 0, $5, $4, $3, $2 - 1, $1 - 1900) } || 0; | |
| 175 | } | |
| 176 | ||
| 177 | sub _esc { | |
| 178 | my $s = shift; $s //= ''; | |
| 179 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 180 | return $s; | |
| 181 | } | |
| 182 | ||
| 183 | 1; |