use chrono::{Duration, Utc};
use crate::error::{Error, Result};
use crate::model::Status;
use crate::storage::{self, StoredTask};
use super::task::{active_library_root, print_task_table};
fn is_open(t: &StoredTask) -> bool {
!matches!(t.task.meta.status, Status::Done | Status::Cancelled)
}
pub fn parse_within(s: &str) -> Result<Duration> {
let num = s.trim().trim_end_matches(['d', 'D']);
num.parse::<i64>()
.map(Duration::days)
.map_err(|_| Error::InvalidTaskFile(format!("invalid window '{s}', expected e.g. 7d")))
}
pub fn remind(within: Option<String>, overdue_only: bool, summary: bool) -> Result<()> {
let root = active_library_root()?;
let tasks = storage::load_library(&root)?.tasks;
let now = Utc::now();
let window = within.map(|w| parse_within(&w)).transpose()?.unwrap_or(Duration::days(3));
let overdue: Vec<&StoredTask> = tasks
.iter()
.filter(|t| is_open(t) && t.task.meta.due_date.is_some_and(|d| d < now))
.collect();
let due_soon: Vec<&StoredTask> = tasks
.iter()
.filter(|t| {
is_open(t) && t.task.meta.due_date.is_some_and(|d| d >= now && d <= now + window)
})
.collect();
if summary {
if overdue.is_empty() && due_soon.is_empty() {
return Ok(());
}
println!("tasks: {} overdue, {} due soon", overdue.len(), due_soon.len());
return Ok(());
}
if !overdue.is_empty() {
println!("OVERDUE:");
for t in &overdue {
let m = &t.task.meta;
println!(
" #{} {} (due {})",
m.seq,
m.title,
m.due_date.unwrap().date_naive()
);
}
}
if !overdue_only {
if !due_soon.is_empty() {
println!("DUE SOON (within {} day(s)):", window.num_days());
for t in &due_soon {
let m = &t.task.meta;
println!(
" #{} {} (due {})",
m.seq,
m.title,
m.due_date.unwrap().date_naive()
);
}
}
if overdue.is_empty() && due_soon.is_empty() {
println!("nothing due within {} day(s)", window.num_days());
}
} else if overdue.is_empty() {
println!("no overdue tasks");
}
Ok(())
}
pub fn overdue() -> Result<()> {
let root = active_library_root()?;
let now = Utc::now();
let tasks: Vec<StoredTask> = storage::load_library(&root)?
.tasks
.into_iter()
.filter(|t| is_open(t) && t.task.meta.due_date.is_some_and(|d| d < now))
.collect();
print_task_table(&tasks);
Ok(())
}
pub fn stats() -> Result<()> {
let root = active_library_root()?;
let tasks = storage::load_library(&root)?.tasks;
let now = Utc::now();
let total = tasks.len();
let count = |s: Status| tasks.iter().filter(|t| t.task.meta.status == s).count();
let done = count(Status::Done);
let cancelled = count(Status::Cancelled);
let overdue = tasks
.iter()
.filter(|t| is_open(t) && t.task.meta.due_date.is_some_and(|d| d < now))
.count();
println!("Task Statistics");
println!("────────────────────────────");
println!("Total tasks: {total}");
for s in [Status::Todo, Status::InProgress, Status::Blocked, Status::InReview, Status::Done, Status::Cancelled] {
let n = count(s);
if n > 0 {
println!(" {:<13} {n}", format!("{s}:"));
}
}
let closed = total.saturating_sub(cancelled);
if closed > 0 {
println!("Completion rate: {:.1}%", done as f64 * 100.0 / closed as f64);
}
println!("Overdue: {overdue}");
let mut tag_counts = std::collections::HashMap::<&str, usize>::new();
for t in &tasks {
for tag in &t.task.meta.tags {
*tag_counts.entry(tag.as_str()).or_default() += 1;
}
}
if !tag_counts.is_empty() {
let mut sorted: Vec<_> = tag_counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
println!("Top tags:");
for (tag, n) in sorted.into_iter().take(5) {
println!(" {:<12} {n}", tag);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_within_accepts_days() {
assert_eq!(parse_within("7d").unwrap(), Duration::days(7));
assert_eq!(parse_within("3").unwrap(), Duration::days(3));
assert!(parse_within("soon").is_err());
}
}