package Mnemosyne::Digest; use strict; use warnings; use DateTime; use Mnemosyne::Schedule; use Carp qw(croak); use constant TELEGRAM_MAX_LEN => 4096; my @MONTHS = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); my @WEEKDAYS = qw(Mon Tue Wed Thu Fri Sat Sun); # ----------------------------------------------------------------------- # Public interface # ----------------------------------------------------------------------- # build($class, $db, $today_dt) → \%digest # # Returns: # { # date => $today_dt, # sections => [ # { key => 'overdue', header => '...', tasks => [$enriched_task, ...] }, # { key => 'due', header => '...', tasks => [...] }, # { key => 'upcoming', header => '...', tasks => [...] }, # { key => 'floating', header => '...', tasks => [...] }, # ], # all_clear => 0|1, # } # # Each $enriched_task is the DB task hashref plus: # status => 'overdue'|'due'|'upcoming' # date => DateTime or undef # days_until => integer (negative = N days overdue; positive = N days away) sub build { my ($class, $db, $today) = @_; my $horizon = ($db->config_get('upcoming_horizon') // 7) + 0; my $medium_days = ($db->config_get('medium_float_days') // 3) + 0; my $tasks = $db->active_tasks; my $last_comps = $db->last_completions; # { task_id => ISO string } my (@overdue, @due, @upcoming, @floating); for my $task (@$tasks) { my $lc_str = $last_comps->{ $task->{id} }; my $lc_dt = $lc_str ? _parse_dt($lc_str) : undef; my $r = Mnemosyne::Schedule->resolve( $task, $today, last_completed_dt => $lc_dt, upcoming_horizon => $horizon, medium_float_days => $medium_days, ); my $enriched = { %$task, %$r }; my $st = $r->{status}; if ($task->{class} eq 'floating') { push @floating, $enriched if $st eq 'due'; } else { if ($st eq 'overdue') { push @overdue, $enriched } elsif ($st eq 'due') { push @due, $enriched } elsif ($st eq 'upcoming') { push @upcoming, $enriched } } } # Sort each bucket @overdue = sort { $a->{days_until} <=> $b->{days_until} || $a->{title} cmp $b->{title} } @overdue; # most-overdue first @due = sort { $a->{title} cmp $b->{title} } @due; @upcoming = sort { $a->{days_until} <=> $b->{days_until} || $a->{title} cmp $b->{title} } @upcoming; # soonest first my %pri = (high => 1, medium => 2, low => 3); @floating = sort { ($pri{$a->{priority}//''} // 9) <=> ($pri{$b->{priority}//''} // 9) || $a->{title} cmp $b->{title} } @floating; my $date_str = _fmt_date_long($today); my @sections; push @sections, { key => 'overdue', header => "\x{1F534} Overdue", tasks => \@overdue } if @overdue; push @sections, { key => 'due', header => "\x{1F4C5} Today \x{2014} $date_str", tasks => \@due } if @due; push @sections, { key => 'upcoming', header => "\x{1F440} Upcoming (next $horizon days)", tasks => \@upcoming } if @upcoming; push @sections, { key => 'floating', header => "\x{1F4AD} On the radar", tasks => \@floating } if @floating; return { date => $today, sections => \@sections, all_clear => (@sections == 0) ? 1 : 0, }; } # already_sent_today($class, $db, $today_dt) → bool sub already_sent_today { my ($class, $db, $today) = @_; my $last = $db->config_get('last_digest_sent') // ''; return $last eq $today->strftime('%Y-%m-%d'); } # ----------------------------------------------------------------------- # Rendering — plain text (terminal / debug) # ----------------------------------------------------------------------- # render_text($class, $digest) → $string sub render_text { my ($class, $digest) = @_; my $width = 50; my $rule = "\x{2501}" x $width; # ━━━ my $out = ''; $out .= "$rule\n"; $out .= _center("Day at a Glance", $width) . "\n"; $out .= _center(_fmt_date_long($digest->{date}), $width) . "\n"; $out .= "$rule\n"; if ($digest->{all_clear}) { $out .= "\n \x{2705} All clear \x{2014} nothing on the radar today.\n"; } else { for my $sec (@{ $digest->{sections} }) { $out .= "\n$sec->{header}\n\n"; for my $task (@{ $sec->{tasks} }) { $out .= _render_task_text($task, $sec->{key}); } } } $out .= "\n$rule\n"; return $out; } sub _render_task_text { my ($task, $section_key) = @_; my $title = $task->{title}; my $detail = ''; if ($section_key eq 'overdue') { my $days = abs($task->{days_until} // 0); my $since = $task->{date} ? _fmt_date($task->{date}) : '?'; $detail = "since $since" . ($days > 0 ? " \x{B7} $days day" . ($days == 1 ? '' : 's') . " overdue" : ''); } elsif ($section_key eq 'upcoming') { my $days = $task->{days_until} // 0; my $when = $task->{date} ? _fmt_date($task->{date}) : '?'; my $rel = $days == 1 ? 'tomorrow' : "in $days days"; $detail = "$when \x{B7} $rel"; } elsif ($section_key eq 'floating') { my %stars = (high => "\x{2605}\x{2605}\x{2605}", medium => "\x{2605}\x{2605}", low => "\x{2605}"); $detail = ($stars{ $task->{priority} // '' } // '?') . ' ' . ($task->{priority} // ''); } my $line; if ($detail) { $line = sprintf(" \x{2022} %-28s %s\n", $title, $detail); } else { $line = " \x{2022} $title\n"; } return $line; } # ----------------------------------------------------------------------- # Rendering — Telegram HTML # ----------------------------------------------------------------------- # render_telegram($class, $digest) → \@parts # Each part: { text => $html_string, task_ids => [$id, ...] } # task_ids is the ordered list of actionable tasks in this message (for inline keyboard). sub render_telegram { my ($class, $digest) = @_; my @parts; my ($cur_text, @cur_ids) = (''); my $flush = sub { return unless length $cur_text; push @parts, { text => $cur_text, task_ids => [@cur_ids] }; ($cur_text, @cur_ids) = (''); }; if ($digest->{all_clear}) { $cur_text = "\x{2705} All clear \x{2014} nothing on the radar today."; $flush->(); return \@parts; } for my $sec (@{ $digest->{sections} }) { my $block = "$sec->{header}\n\n"; my @block_ids; for my $task (@{ $sec->{tasks} }) { my ($line, $is_actionable) = _render_task_html($task, $sec->{key}); $block .= $line; push @block_ids, $task->{id} if $is_actionable; } $block .= "\n"; # Start a new message if this block would push us over the limit if (length($cur_text) + length($block) > TELEGRAM_MAX_LEN) { $flush->(); } $cur_text .= $block; push @cur_ids, @block_ids; } $flush->(); return \@parts; } sub _render_task_html { my ($task, $section_key) = @_; my $title = _html_esc($task->{title}); my $detail = ''; if ($section_key eq 'overdue') { my $days = abs($task->{days_until} // 0); my $since = $task->{date} ? _fmt_date($task->{date}) : '?'; $detail = "since $since" . ($days > 0 ? " \x{B7} $days day" . ($days==1?'':'s') . " overdue" : '') . ""; } elsif ($section_key eq 'upcoming') { my $days = $task->{days_until} // 0; my $when = $task->{date} ? _fmt_date($task->{date}) : '?'; my $rel = $days == 1 ? 'tomorrow' : "in $days days"; $detail = "$when \x{B7} $rel"; } elsif ($section_key eq 'floating') { my %stars = (high => "\x{2605}\x{2605}\x{2605}", medium => "\x{2605}\x{2605}", low => "\x{2605}"); $detail = $stars{ $task->{priority} // '' } // ''; } my $line = "\x{2022} $title" . ($detail ? " $detail" : '') . "\n"; return ($line, 1); # 1 = actionable (all digest tasks get a Mark Done button) } # ----------------------------------------------------------------------- # Send (stub — requires Telegram.pm) # ----------------------------------------------------------------------- sub send { my ($class, $db, $telegram, $chat_id, $today) = @_; croak "send() requires a Telegram client" unless $telegram; return if $class->already_sent_today($db, $today); my $digest = $class->build($db, $today); my $parts = $class->render_telegram($digest); for my $part (@$parts) { $telegram->send_message( $chat_id, $part->{text}, parse_mode => 'HTML', # reply_markup built by Telegram.pm once that module exists: # reply_markup => Mnemosyne::Telegram->mark_done_keyboard($part->{task_ids}), ); } $db->config_set('last_digest_sent', $today->strftime('%Y-%m-%d')); } # ----------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------- sub _parse_dt { my ($s) = @_; return undef unless defined $s && $s =~ /^(\d{4})-(\d{2})-(\d{2})/; return DateTime->new(year => $1+0, month => $2+0, day => $3+0); } sub _fmt_date { my ($dt) = @_; return sprintf('%s %s %d', $WEEKDAYS[$dt->day_of_week - 1], $MONTHS[$dt->month - 1], $dt->day); } sub _fmt_date_long { my ($dt) = @_; return sprintf('%s, %s %d %d', $WEEKDAYS[$dt->day_of_week - 1], $MONTHS[$dt->month - 1], $dt->day, $dt->year); } sub _center { my ($str, $width) = @_; my $pad = int(($width - length($str)) / 2); $pad = 0 if $pad < 0; return (' ' x $pad) . $str; } sub _html_esc { my ($s) = @_; $s =~ s/&/&/g; $s =~ s//>/g; return $s; } 1;