added on WebSTLs (webstls.com) at 2026-07-01 22:26:36
| 1 | package MODS::AnonPaths; | |
| 2 | #====================================================================== | |
| 3 | # Anonymous visitor path rendering -- shared across all 7 sites. | |
| 4 | # | |
| 5 | # Public entry: MODS::AnonPaths::render_for_admin($db, $dbh, $days, | |
| 6 | # $selected_path, $entered_signups) | |
| 7 | # Returns one HTML string containing: | |
| 8 | # 1. <style> block with all the path-panel CSS (idempotent — caller can | |
| 9 | # include it once per page, repeated includes are harmless) | |
| 10 | # 2. "Top Acquisition Paths" panel with up-to-10 most-common sequences | |
| 11 | # 3. (Optional) per-step funnel drill-in when $selected_path is set | |
| 12 | # | |
| 13 | # Assumes the dbh is already bound to the site's schema and that | |
| 14 | # anon_sessions + anon_pageviews tables exist (see _anon_path_tracking.sql). | |
| 15 | #====================================================================== | |
| 16 | use strict; | |
| 17 | use warnings; | |
| 18 | use Time::Local (); | |
| 19 | ||
| 20 | my $ARROW = chr(0xE2) . chr(0x86) . chr(0x92); # → as UTF-8 bytes | |
| 21 | ||
| 22 | sub render_for_admin { | |
| 23 | my ($db, $dbh, $days, $selected_path, $entered_signups) = @_; | |
| 24 | $days = ($days && $days =~ /^\d+$/) ? $days + 0 : 30; | |
| 25 | $days = 365 if $days > 365; | |
| 26 | $days = 1 if $days < 1; | |
| 27 | $selected_path = '' unless defined $selected_path; | |
| 28 | $selected_path =~ s/[\x00-\x1f]//g; | |
| 29 | $selected_path = substr($selected_path, 0, 1000); | |
| 30 | $entered_signups = 0 unless $entered_signups; | |
| 31 | ||
| 32 | my @top_paths = _query_top_paths($db, $dbh, $days); | |
| 33 | my @drill_steps = (); | |
| 34 | my ($sel_total, $sel_signups, $sel_abandons) = (0, 0, 0); | |
| 35 | if (length $selected_path) { | |
| 36 | (@drill_steps) = _build_drilldown($db, $dbh, $days, $selected_path); | |
| 37 | if (@drill_steps) { | |
| 38 | $sel_total = $drill_steps[0]->{reached}; | |
| 39 | $sel_signups += $_->{signup} for @drill_steps; | |
| 40 | $sel_abandons += $_->{abandon} for @drill_steps; | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | my $css = _css(); | |
| 45 | my $panel = _render_panel(\@top_paths, $entered_signups, $selected_path, $days); | |
| 46 | my $drill = (@drill_steps && length $selected_path) | |
| 47 | ? _render_drilldown(\@drill_steps, $selected_path, $sel_total, $sel_signups, $sel_abandons) | |
| 48 | : ''; | |
| 49 | return $css . $panel . $drill; | |
| 50 | } | |
| 51 | ||
| 52 | sub _query_top_paths { | |
| 53 | my ($db, $dbh, $days) = @_; | |
| 54 | my @rows; | |
| 55 | local *OLDOUT; | |
| 56 | open(OLDOUT, '>&', \*STDOUT) or do {}; | |
| 57 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 58 | @rows = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 59 | SELECT path_sequence, COUNT(*) AS n, | |
| 60 | SUM(CASE WHEN end_reason='signed_up' THEN 1 ELSE 0 END) AS signups, | |
| 61 | SUM(CASE | |
| 62 | WHEN end_reason IN ('abandoned','bounced') THEN 1 | |
| 63 | WHEN end_reason='active' AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE) THEN 1 | |
| 64 | ELSE 0 | |
| 65 | END) AS abandons | |
| 66 | FROM ( | |
| 67 | SELECT s.id, s.end_reason, s.last_seen_at, | |
| 68 | GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $ARROW ') AS path_sequence | |
| 69 | FROM anon_sessions s | |
| 70 | JOIN anon_pageviews p ON p.session_id=s.id | |
| 71 | WHERE s.first_seen_at >= DATE_SUB(NOW(), INTERVAL $days DAY) | |
| 72 | GROUP BY s.id | |
| 73 | ) AS sess | |
| 74 | WHERE path_sequence IS NOT NULL AND path_sequence != '' | |
| 75 | GROUP BY path_sequence | |
| 76 | ORDER BY n DESC | |
| 77 | LIMIT 10 | |
| 78 | ~, $ENV{SCRIPT_NAME} || 'anonpaths', __LINE__) }; | |
| 79 | @rows = () unless @rows; | |
| 80 | open(STDOUT, '>&', \*OLDOUT) or do {}; | |
| 81 | return @rows; | |
| 82 | } | |
| 83 | ||
| 84 | sub _build_drilldown { | |
| 85 | my ($db, $dbh, $days, $selected_path) = @_; | |
| 86 | my @steps = split /\s+\Q$ARROW\E\s+/, $selected_path; | |
| 87 | return () unless @steps; | |
| 88 | my @all; | |
| 89 | { | |
| 90 | local *OLDOUT; | |
| 91 | open(OLDOUT, '>&', \*STDOUT) or do {}; | |
| 92 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 93 | @all = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 94 | SELECT s.id, s.end_reason, s.last_seen_at, | |
| 95 | GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $ARROW ') AS path_sequence | |
| 96 | FROM anon_sessions s | |
| 97 | JOIN anon_pageviews p ON p.session_id=s.id | |
| 98 | WHERE s.first_seen_at >= DATE_SUB(NOW(), INTERVAL $days DAY) | |
| 99 | GROUP BY s.id | |
| 100 | ~, $ENV{SCRIPT_NAME} || 'anonpaths', __LINE__) }; | |
| 101 | @all = () unless @all; | |
| 102 | open(STDOUT, '>&', \*OLDOUT) or do {}; | |
| 103 | } | |
| 104 | my @out; | |
| 105 | for (my $i = 0; $i < @steps; $i++) { | |
| 106 | my $prefix = join(" $ARROW ", @steps[0..$i]); | |
| 107 | my ($reached, $abandon_here, $signup_here) = (0, 0, 0); | |
| 108 | foreach my $s (@all) { | |
| 109 | my $seq = $s->{path_sequence} // ''; | |
| 110 | next unless index($seq, $prefix) == 0; | |
| 111 | $reached++; | |
| 112 | my $is_full = ($seq eq $prefix); | |
| 113 | my $is_abandon = | |
| 114 | $s->{end_reason} eq 'abandoned' || $s->{end_reason} eq 'bounced' || | |
| 115 | ($s->{end_reason} eq 'active' && _stale($s->{last_seen_at})); | |
| 116 | $abandon_here++ if $is_full && $is_abandon; | |
| 117 | $signup_here++ if $is_full && $s->{end_reason} eq 'signed_up'; | |
| 118 | } | |
| 119 | push @out, { idx => $i, step => $steps[$i], reached => $reached, abandon => $abandon_here, signup => $signup_here }; | |
| 120 | } | |
| 121 | return @out; | |
| 122 | } | |
| 123 | ||
| 124 | sub _stale { | |
| 125 | my ($ts) = @_; | |
| 126 | return 1 unless $ts; | |
| 127 | if ($ts =~ /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/) { | |
| 128 | my $ep = eval { Time::Local::timelocal($6,$5,$4,$3,$2-1,$1-1900) } || 0; | |
| 129 | return ($ep > 0 && (time - $ep) > 1800) ? 1 : 0; | |
| 130 | } | |
| 131 | return 0; | |
| 132 | } | |
| 133 | ||
| 134 | sub _h { | |
| 135 | my $s = shift // ''; | |
| 136 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 137 | return $s; | |
| 138 | } | |
| 139 | ||
| 140 | sub _fmt_n { | |
| 141 | my $n = int(shift // 0); | |
| 142 | 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/; | |
| 143 | return $n; | |
| 144 | } | |
| 145 | ||
| 146 | sub _render_panel { | |
| 147 | my ($rows, $entered, $selected, $days) = @_; | |
| 148 | if (!scalar @$rows) { | |
| 149 | return qq~ | |
| 150 | <div class="anp-wrap"> | |
| 151 | <div class="anp-head"> | |
| 152 | <div class="anp-title">Top Acquisition Paths</div> | |
| 153 | <div class="anp-sub">Page sequences anonymous visitors take before they sign up or abandon. Click a row to drill into its per-step funnel.</div> | |
| 154 | </div> | |
| 155 | <div class="anp-empty">No anonymous-visitor data yet for the last $days days. Once visitors land on a marketing page, their paths will appear here.</div> | |
| 156 | </div>~; | |
| 157 | } | |
| 158 | ||
| 159 | my $max = 1; | |
| 160 | foreach my $r (@$rows) { $max = $r->{n} if $r->{n} > $max; } | |
| 161 | my $cells = ''; | |
| 162 | my $rank = 0; | |
| 163 | foreach my $r (@$rows) { | |
| 164 | $rank++; | |
| 165 | my $path_disp = _h($r->{path_sequence}); | |
| 166 | my $cnt = _fmt_n($r->{n}); | |
| 167 | my $sn = _fmt_n($r->{signups} || 0); | |
| 168 | my $an = _fmt_n($r->{abandons} || 0); | |
| 169 | my $bar_pct = int(100.0 * $r->{n} / $max + 0.5); | |
| 170 | my $is_sel = (defined($selected) && length($selected) && $selected eq $r->{path_sequence}) ? 'is-selected' : ''; | |
| 171 | my $share_pct = $entered ? sprintf('%.1f', 100.0 * $r->{n} / $entered) : '0.0'; | |
| 172 | my $enc = $r->{path_sequence}; $enc =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/eg; | |
| 173 | $cells .= qq~ | |
| 174 | <a href="?days=$days&path=$enc" class="anp-row $is_sel" style="--pct:${bar_pct}%"> | |
| 175 | <div class="anp-rank">$rank</div> | |
| 176 | <div class="anp-name" title="$path_disp">$path_disp</div> | |
| 177 | <div class="anp-stat ok"><span class="dot"></span>$sn</div> | |
| 178 | <div class="anp-stat bad"><span class="dot"></span>$an</div> | |
| 179 | <div class="anp-share">${share_pct}%</div> | |
| 180 | <div class="anp-cnt">$cnt</div> | |
| 181 | </a>~; | |
| 182 | } | |
| 183 | return qq~ | |
| 184 | <div class="anp-wrap"> | |
| 185 | <div class="anp-head"> | |
| 186 | <div> | |
| 187 | <div class="anp-title">Top Acquisition Paths</div> | |
| 188 | <div class="anp-sub">Page sequences anonymous visitors take before they sign up or abandon. Click a row to see that path as a step-by-step funnel.</div> | |
| 189 | </div> | |
| 190 | <div class="anp-legend"> | |
| 191 | <span><span class="dot ok"></span> signed up</span> | |
| 192 | <span><span class="dot bad"></span> abandoned</span> | |
| 193 | </div> | |
| 194 | </div> | |
| 195 | <div class="anp-list">$cells</div> | |
| 196 | </div>~; | |
| 197 | } | |
| 198 | ||
| 199 | sub _render_drilldown { | |
| 200 | my ($steps, $path_raw, $total, $signups, $abandons) = @_; | |
| 201 | return '' unless ref($steps) eq 'ARRAY' && scalar @$steps; | |
| 202 | return '' unless length($path_raw // ''); | |
| 203 | ||
| 204 | my $path_disp = _h($path_raw); | |
| 205 | my $total_fmt = _fmt_n($total); | |
| 206 | my $signup_fmt = _fmt_n($signups); | |
| 207 | my $aban_fmt = _fmt_n($abandons); | |
| 208 | my $continued = $total - $signups - $abandons; $continued = 0 if $continued < 0; | |
| 209 | my $cont_fmt = _fmt_n($continued); | |
| 210 | my $entered = $total || 1; | |
| 211 | ||
| 212 | my $bars = ''; | |
| 213 | my $idx = 0; | |
| 214 | foreach my $s (@$steps) { | |
| 215 | $idx++; | |
| 216 | my $step_path = _h($s->{step}); | |
| 217 | my $r = $s->{reached}; my $sn = $s->{signup}; my $an = $s->{abandon}; | |
| 218 | my $reached_pct = $entered ? sprintf('%.1f', 100 * $r / $entered) : '0.0'; | |
| 219 | my $bar_w = $entered ? int(100 * $r / $entered) : 0; | |
| 220 | $bar_w = 4 if $bar_w < 4 && $r > 0; | |
| 221 | my $sn_n = _fmt_n($sn); my $an_n = _fmt_n($an); my $reached_n = _fmt_n($r); | |
| 222 | my $sn_block = $sn ? qq~<span class="anp-marker ok">✓ $sn_n</span>~ : ''; | |
| 223 | my $an_block = $an ? qq~<span class="anp-marker bad">✕ $an_n</span>~ : ''; | |
| 224 | $bars .= qq~ | |
| 225 | <div class="anp-step"> | |
| 226 | <div class="anp-step-head"> | |
| 227 | <div class="anp-step-num">$idx</div> | |
| 228 | <div class="anp-step-name" title="$step_path">$step_path</div> | |
| 229 | <div class="anp-step-meta">$reached_n · ${reached_pct}%</div> | |
| 230 | </div> | |
| 231 | <div class="anp-step-bar"><div class="anp-step-fill" style="width:${bar_w}%"></div></div> | |
| 232 | <div class="anp-step-markers">$sn_block $an_block</div> | |
| 233 | </div>~; | |
| 234 | } | |
| 235 | return qq~ | |
| 236 | <div class="anp-drill"> | |
| 237 | <div class="anp-drill-head"> | |
| 238 | <div class="lbl">Path drill-in</div> | |
| 239 | <div class="path-string">$path_disp</div> | |
| 240 | <div class="anp-drill-stats"> | |
| 241 | <span>Sessions <strong>$total_fmt</strong></span> | |
| 242 | <span class="ok">Signed up <strong>$signup_fmt</strong></span> | |
| 243 | <span class="bad">Abandoned <strong>$aban_fmt</strong></span> | |
| 244 | <span class="dim">Still active <strong>$cont_fmt</strong></span> | |
| 245 | </div> | |
| 246 | </div> | |
| 247 | <div class="anp-drill-steps">$bars</div> | |
| 248 | </div>~; | |
| 249 | } | |
| 250 | ||
| 251 | sub _css { | |
| 252 | return <<'CSS'; | |
| 253 | <style> | |
| 254 | .anp-wrap { position: relative; padding: 22px 24px 18px; background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 16px; margin: 0 0 18px; overflow: hidden; } | |
| 255 | .anp-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } | |
| 256 | .anp-title { font-size: 17px; font-weight: 700; color: #fff; } | |
| 257 | .anp-sub { font-size: 12px; color: #8893a4; margin-top: 4px; line-height: 1.55; max-width: 640px; } | |
| 258 | .anp-legend { display: flex; gap: 14px; font-size: 11px; color: #8893a4; } | |
| 259 | .anp-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: 1px; margin-right: 4px; } | |
| 260 | .anp-legend .dot.ok { background: #22c55e; } | |
| 261 | .anp-legend .dot.bad { background: #ef4444; } | |
| 262 | .anp-list { display: flex; flex-direction: column; gap: 4px; } | |
| 263 | .anp-row { position: relative; display: grid; grid-template-columns: 24px 1fr 70px 70px 60px 60px; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,0.02); border-radius: 8px; font-size: 13px; overflow: hidden; text-decoration: none; color: inherit; cursor: pointer; } | |
| 264 | .anp-row:hover { background: rgba(255,255,255,0.05); } | |
| 265 | .anp-row.is-selected { background: rgba(168,85,247,0.12); border: 1px solid rgba(168,85,247,0.3); } | |
| 266 | .anp-row::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--pct, 0%); background: linear-gradient(90deg, rgba(34,211,238,0.18) 0%, rgba(168,85,247,0.06) 100%); border-radius: 8px 0 0 8px; z-index: 0; } | |
| 267 | .anp-row > * { position: relative; z-index: 1; } | |
| 268 | .anp-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; } | |
| 269 | .anp-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 270 | .anp-stat { font-size: 11px; font-weight: 600; text-align: right; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; } | |
| 271 | .anp-stat.ok { color: #4ade80; } | |
| 272 | .anp-stat.bad { color: #f87171; } | |
| 273 | .anp-stat .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; } | |
| 274 | .anp-stat.ok .dot { background: #22c55e; } | |
| 275 | .anp-stat.bad .dot { background: #ef4444; } | |
| 276 | .anp-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; } | |
| 277 | .anp-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; } | |
| 278 | .anp-empty { text-align: center; padding: 30px 12px; color: #8893a4; font-size: 13px; } | |
| 279 | ||
| 280 | .anp-drill { position: relative; padding: 24px 26px; background: linear-gradient(160deg, rgba(168,85,247,0.06), rgba(255,255,255,0.01)); border: 1px solid rgba(168,85,247,0.18); border-radius: 16px; margin: 0 0 22px; } | |
| 281 | .anp-drill-head .lbl { font-size: 11px; color: #c4a3f0; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; } | |
| 282 | .anp-drill-head .path-string { color: #fff; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; word-break: break-all; margin-bottom: 14px; } | |
| 283 | .anp-drill-stats { display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px; color: #8893a4; padding-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 18px; } | |
| 284 | .anp-drill-stats strong { color: #fff; margin-left: 6px; } | |
| 285 | .anp-drill-stats .ok strong { color: #4ade80; } | |
| 286 | .anp-drill-stats .bad strong { color: #f87171; } | |
| 287 | .anp-drill-stats .dim strong { color: #cbd5e1; } | |
| 288 | .anp-drill-steps { display: flex; flex-direction: column; gap: 14px; } | |
| 289 | .anp-step { display: grid; gap: 6px; } | |
| 290 | .anp-step-head { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; } | |
| 291 | .anp-step-num { width: 26px; height: 26px; border-radius: 7px; background: rgba(168,85,247,0.25); color: #c4a3f0; font-weight: 800; font-size: 12px; display: flex; align-items: center; justify-content: center; } | |
| 292 | .anp-step-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 293 | .anp-step-meta { color: #8893a4; font-size: 12px; font-weight: 600; } | |
| 294 | .anp-step-bar { height: 10px; background: rgba(255,255,255,0.04); border-radius: 5px; overflow: hidden; } | |
| 295 | .anp-step-fill { height: 100%; background: linear-gradient(90deg, #a855f7 0%, #22d3ee 100%); border-radius: 5px; } | |
| 296 | .anp-step-markers { display: flex; gap: 8px; margin-left: 38px; } | |
| 297 | .anp-marker { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; } | |
| 298 | .anp-marker.ok { background: rgba(34,197,94,0.18); color: #4ade80; } | |
| 299 | .anp-marker.bad { background: rgba(239,68,68,0.18); color: #f87171; } | |
| 300 | </style> | |
| 301 | CSS | |
| 302 | } | |
| 303 | ||
| 304 | # --------------------------------------------------------------------- | |
| 305 | # Date-range helpers (ported from TF version of AnonPaths) used by | |
| 306 | # admin_earnings.cgi / admin_funnels.cgi to drive the from/to picker. | |
| 307 | # --------------------------------------------------------------------- | |
| 308 | ||
| 309 | sub render_range_controls { | |
| 310 | my ($preset, $from, $to) = @_; | |
| 311 | $preset //= '30'; $from //= ''; $to //= ''; | |
| 312 | my %sel; | |
| 313 | foreach (qw(7 14 30 90 this_month last_month custom)) { $sel{$_} = ''; } | |
| 314 | $sel{$preset} = ' selected' if exists $sel{$preset}; | |
| 315 | my $custom_disp = ($preset eq 'custom') ? '' : 'display:none'; | |
| 316 | return qq~ | |
| 317 | <label class="afnl-field"> | |
| 318 | <span class="afnl-field-lbl">Range</span> | |
| 319 | <select name="preset" onchange="afnlPresetChange(this)"> | |
| 320 | <option value="7"$sel{7}>Last 7 days</option> | |
| 321 | <option value="14"$sel{14}>Last 14 days</option> | |
| 322 | <option value="30"$sel{30}>Last 30 days</option> | |
| 323 | <option value="90"$sel{90}>Last 90 days</option> | |
| 324 | <option value="this_month"$sel{this_month}>This month</option> | |
| 325 | <option value="last_month"$sel{last_month}>Last month</option> | |
| 326 | <option value="custom"$sel{custom}>Custom...</option> | |
| 327 | </select> | |
| 328 | </label> | |
| 329 | <label class="afnl-field afnl-range-custom" style="$custom_disp"> | |
| 330 | <span class="afnl-field-lbl">From</span> | |
| 331 | <input type="date" name="from" value="$from"> | |
| 332 | </label> | |
| 333 | <label class="afnl-field afnl-range-custom" style="$custom_disp"> | |
| 334 | <span class="afnl-field-lbl">To</span> | |
| 335 | <input type="date" name="to" value="$to"> | |
| 336 | </label> | |
| 337 | <button type="submit" class="afnl-cta-pill afnl-range-custom" style="$custom_disp; padding: 8px 16px;">Apply</button> | |
| 338 | <script> | |
| 339 | function afnlPresetChange(sel) { | |
| 340 | var custom = sel.value === 'custom'; | |
| 341 | var els = document.querySelectorAll('.afnl-range-custom'); | |
| 342 | els.forEach(function(e) { e.style.display = custom ? '' : ''; if (!custom) e.style.display = 'none'; }); | |
| 343 | if (!custom) sel.form.submit(); | |
| 344 | } | |
| 345 | </script>~; | |
| 346 | } | |
| 347 | ||
| 348 | sub parse_range_params { | |
| 349 | my ($params) = @_; | |
| 350 | my $preset = $params->{preset} || ''; | |
| 351 | my $from = $params->{from} || ''; | |
| 352 | my $to = $params->{to} || ''; | |
| 353 | my $days = $params->{days} || ''; | |
| 354 | if ($preset eq 'this_month') { | |
| 355 | my @t = localtime(); | |
| 356 | my $fy = $t[5] + 1900; my $fm = $t[4] + 1; | |
| 357 | $from = sprintf('%04d-%02d-01', $fy, $fm); | |
| 358 | $to = sprintf('%04d-%02d-%02d', $fy, $fm, $t[3]); | |
| 359 | return ({ from => $from, to => $to }, 'this_month', $from, $to); | |
| 360 | } elsif ($preset eq 'last_month') { | |
| 361 | my @t = localtime(); | |
| 362 | my $y = $t[5] + 1900; my $m = $t[4] + 1; | |
| 363 | $m--; if ($m == 0) { $m = 12; $y--; } | |
| 364 | $from = sprintf('%04d-%02d-01', $y, $m); | |
| 365 | my $nm = $m + 1; my $ny = $y; if ($nm > 12) { $nm = 1; $ny++; } | |
| 366 | require Time::Local; | |
| 367 | my $ts_first_next = eval { Time::Local::timelocal(0,0,0,1,$nm-1,$ny-1900) } || 0; | |
| 368 | my @lt = localtime($ts_first_next - 86400); | |
| 369 | $to = sprintf('%04d-%02d-%02d', $lt[5]+1900, $lt[4]+1, $lt[3]); | |
| 370 | return ({ from => $from, to => $to }, 'last_month', $from, $to); | |
| 371 | } elsif ($preset eq 'custom' && $from =~ /^\d{4}-\d{2}-\d{2}$/ && $to =~ /^\d{4}-\d{2}-\d{2}$/) { | |
| 372 | return ({ from => $from, to => $to }, 'custom', $from, $to); | |
| 373 | } elsif ($preset =~ /^\d+$/) { | |
| 374 | return ({ days => $preset + 0 }, $preset, '', ''); | |
| 375 | } | |
| 376 | my $d = ($days =~ /^\d+$/) ? $days + 0 : 30; | |
| 377 | return ({ days => $d }, $d, '', ''); | |
| 378 | } | |
| 379 | ||
| 380 | ||
| 381 | 1; |