304 lines
9.9 KiB
Perl
304 lines
9.9 KiB
Perl
package Mnemosyne::Digest;
|
|
use strict;
|
|
use warnings;
|
|
use DateTime;
|
|
use Mnemosyne::Schedule;
|
|
use Mnemosyne::Telegram;
|
|
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_tasks) = ('');
|
|
|
|
my $flush = sub {
|
|
return unless length $cur_text;
|
|
push @parts, { text => $cur_text, tasks => [@cur_tasks] };
|
|
($cur_text, @cur_tasks) = ('');
|
|
};
|
|
|
|
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 = "<b>$sec->{header}</b>\n\n";
|
|
my @block_tasks;
|
|
|
|
for my $task (@{ $sec->{tasks} }) {
|
|
my $line = _render_task_html($task, $sec->{key});
|
|
$block .= $line;
|
|
push @block_tasks, { id => $task->{id}, title => $task->{title} };
|
|
}
|
|
$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_tasks, @block_tasks;
|
|
}
|
|
|
|
$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 = "<i>since $since" . ($days > 0 ? " \x{B7} $days day" . ($days==1?'':'s') . " overdue" : '') . "</i>";
|
|
} 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 = "<i>$when \x{B7} $rel</i>";
|
|
} 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} // '' } // '';
|
|
}
|
|
|
|
return "\x{2022} $title" . ($detail ? " $detail" : '') . "\n";
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# 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) {
|
|
my $kb = @{ $part->{tasks} }
|
|
? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks})
|
|
: undef;
|
|
$telegram->send_message(
|
|
$chat_id,
|
|
$part->{text},
|
|
parse_mode => 'HTML',
|
|
defined $kb ? (reply_markup => $kb) : (),
|
|
);
|
|
}
|
|
|
|
$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;
|
|
$s =~ s/>/>/g;
|
|
return $s;
|
|
}
|
|
|
|
1;
|