diff --git a/src/context.rs b/src/context.rs index e5f2b89..ff164c6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -13,6 +13,9 @@ pub struct Context { pub repo_url: url::Url, /// URL of the repository with authentication information attached pub repo_auth_url: url::Url, + + /// Timestamp "now" to be used in comparisons + pub timestamp: jiff::Zoned, } impl Context { @@ -67,12 +70,15 @@ impl Context { let forgejo = forgejo_api::Forgejo::new(forgejo_api::Auth::Token(token), server_url) .context("Could not create Forgejo API access object")?; + let timestamp = jiff::Zoned::now(); + Ok(Self { forgejo, owner: owner.to_owned(), repo: repo.to_owned(), repo_url, repo_auth_url, + timestamp, }) } } diff --git a/src/main.rs b/src/main.rs index f7de45c..e7a429d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ #![allow(unused)] -mod task; -mod collect; mod ci_meta; +mod collect; mod context; +mod reminder; mod scheduler; +mod task; mod util; use context::Context; @@ -16,6 +17,8 @@ async fn run() -> anyhow::Result<()> { scheduler::reschedule_recurring_tasks(&ctx, &tasks).await?; + reminder::remind_due_tasks(&ctx, &tasks).await?; + Ok(()) } diff --git a/src/reminder.rs b/src/reminder.rs new file mode 100644 index 0000000..6c28026 --- /dev/null +++ b/src/reminder.rs @@ -0,0 +1,96 @@ +use crate::task; +use crate::util; + +use anyhow::Context as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BatchingInterval { + Monthly, + Weekly, + OnTheDay, +} + +pub async fn remind_due_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> anyhow::Result<()> { + for task in tasks { + let Some(due_date) = task.state.due_date_if_open() else { + continue; + }; + + let batching_interval = find_batching_interval(ctx, due_date, task); + log::debug!("Reminding {task} with interval {batching_interval:?}."); + + if !is_time_to_remind(ctx, due_date, batching_interval) { + log::debug!("Not yet time, skipping."); + continue; + } + + if is_overdue(ctx, due_date) { + log::info!("Task {task} is already overdue!"); + } + + log::warn!("TODO: Remind {task}"); + } + + Ok(()) +} + +fn find_batching_interval( + ctx: &crate::Context, + due_date: &jiff::Zoned, + task: &task::Task, +) -> BatchingInterval { + let time_until_due = due_date - &ctx.timestamp; + + if let Some(recurring) = &task.recurring { + match &recurring.interval { + task::RecurringInterval::Months(_) => BatchingInterval::Monthly, + task::RecurringInterval::Weeks(_) => BatchingInterval::Weekly, + task::RecurringInterval::Days(_) => BatchingInterval::OnTheDay, + } + } else { + // For tasks that are not recurring, the batching interval is determined based on how + // far in the future the task is due. + + let weeks_until_due = time_until_due + .total((jiff::Unit::Week, jiff::SpanRelativeTo::days_are_24_hours())) + .unwrap(); + if weeks_until_due >= 3. { + BatchingInterval::Monthly + } else if weeks_until_due >= 1. { + BatchingInterval::Weekly + } else { + BatchingInterval::OnTheDay + } + } +} + +fn is_time_to_remind( + ctx: &crate::Context, + due_date: &jiff::Zoned, + batching_interval: BatchingInterval, +) -> bool { + let batch_time = match batching_interval { + BatchingInterval::Monthly => due_date.first_of_month().unwrap(), + BatchingInterval::Weekly => start_of_week(due_date).unwrap(), + BatchingInterval::OnTheDay => due_date.clone(), + }; + + let batch_time = batch_time + .round( + jiff::ZonedRound::new() + .smallest(jiff::Unit::Day) + .mode(jiff::RoundMode::Floor), + ) + .unwrap(); + + ctx.timestamp >= batch_time +} + +fn is_overdue(ctx: &crate::Context, due_date: &jiff::Zoned) -> bool { + &ctx.timestamp >= due_date +} + +fn start_of_week(t: &jiff::Zoned) -> anyhow::Result { + Ok(t.tomorrow()? + .nth_weekday(-1, jiff::civil::Weekday::Monday)?) +} diff --git a/src/scheduler.rs b/src/scheduler.rs index 8ee2a6f..1079b81 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -15,12 +15,15 @@ pub async fn reschedule_recurring_tasks( let completed_date = match &task.state { // Already scheduled - task::State::Open { due: Some(_) } => continue, + task::State::Open { due: Some(_) } => { + log::debug!("Task {task} is already scheduled. No action."); + continue + }, // Invalid state, will warn about this task::State::Open { due: None } => { - log::warn!("Task {task} is recurring but has no due date. Updating from today."); - &jiff::Zoned::now() + log::warn!("Task {task} is recurring but has no due date. Scheduling from today."); + &ctx.timestamp } // Need scheduling @@ -36,6 +39,8 @@ pub async fn reschedule_recurring_tasks( if rescheduled == 0 { log::info!("No tasks need rescheduling."); + } else { + log::debug!("Rescheduled {rescheduled} tasks."); } Ok(()) diff --git a/src/task.rs b/src/task.rs index a4dab73..1699ff1 100644 --- a/src/task.rs +++ b/src/task.rs @@ -24,6 +24,15 @@ pub enum State { Completed { date: jiff::Zoned }, } +impl State { + pub fn due_date_if_open(&self) -> Option<&jiff::Zoned> { + match self { + State::Open { due } => due.as_ref(), + _ => None, + } + } +} + #[derive(Debug, Clone)] pub struct Recurring { pub interval: RecurringInterval,