Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/RangePicker.pm

O Operator
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
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package 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#======================================================================
28use strict;
29use warnings;
30use POSIX ();
31
32my %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#---------------------------------------------------------------------
37sub 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#---------------------------------------------------------------------
109sub 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&hellip;</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#---------------------------------------------------------------------
168sub _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
177sub _esc {
178 my $s = shift; $s //= '';
179 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
180 return $s;
181}
182
1831;