use chrono::{Datelike, Local};
use clap::Args;
use comfy_table::modifiers::UTF8_SOLID_INNER_BORDERS;
use comfy_table::presets::UTF8_FULL;
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
use terminal_size::{terminal_size, Width};
use crate::config;
use crate::db;
use crate::model::{SortKey, SortOrder, Status, Task};
const NARROW_THRESHOLD: u16 = 60;
#[derive(Args)]
pub struct ListArgs {
#[arg(short, long)]
pub all: bool,
#[arg(short, long)]
pub project: Option<String>,
#[arg(short, long, default_value = "id")]
pub sort: Vec<String>,
#[arg(long, conflicts_with = "desc")]
pub asc: bool,
#[arg(long, conflicts_with = "asc")]
pub desc: bool,
#[arg(long)]
pub important_only: bool,
}
pub fn run(args: ListArgs) {
let db_path = config::db_path();
let conn = match db::open(&db_path) {
Ok(c) => c,
Err(_) => {
eprintln!("Error: failed to write database: {}", db_path.display());
std::process::exit(1);
}
};
let sorts: Vec<SortKey> = args
.sort
.iter()
.map(|s| match s.as_str() {
"id" => SortKey::Id,
"due" => SortKey::Due,
"project" => SortKey::Project,
"created" | "age" => SortKey::Created,
other => {
eprintln!(
"Error: unknown sort key '{}'. Use: id, due, project, created",
other
);
std::process::exit(1);
}
})
.collect();
let order = if args.desc {
SortOrder::Desc
} else {
SortOrder::Asc
};
let tasks = match db::list_tasks(
&conn,
args.all,
args.project.as_deref(),
&sorts,
order,
args.important_only,
) {
Ok(t) => t,
Err(_) => {
eprintln!("Error: failed to read database: {}", db_path.display());
std::process::exit(1);
}
};
if tasks.is_empty() {
println!("No tasks. Add one with: my-task add \"task title\"");
return;
}
print_task_table(&tasks, args.all, &conn);
}
pub fn print_task_table(tasks: &[Task], all: bool, conn: &rusqlite::Connection) {
let mut tasks = tasks.to_vec();
for task in &mut tasks {
task.reminds = db::get_reminds_for_task(conn, task.id).unwrap_or_default();
}
let today = Local::now().date_naive();
let done_count = tasks
.iter()
.filter(|t| t.status == Status::Done || t.status == Status::Closed)
.count();
let project_colors = build_project_color_map(&tasks);
let term_width = terminal_size().map(|(Width(w), _)| w).unwrap_or(80);
let compact = term_width < NARROW_THRESHOLD;
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.apply_modifier(UTF8_SOLID_INNER_BORDERS)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_width(term_width);
if compact {
table.set_header(vec![
Cell::new("ID").add_attribute(Attribute::Bold),
Cell::new("Title").add_attribute(Attribute::Bold),
Cell::new("Due").add_attribute(Attribute::Bold),
]);
} else {
table.set_header(vec![
Cell::new("ID").add_attribute(Attribute::Bold),
Cell::new("Status").add_attribute(Attribute::Bold),
Cell::new("Project").add_attribute(Attribute::Bold),
Cell::new("Title").add_attribute(Attribute::Bold),
Cell::new("Due").add_attribute(Attribute::Bold),
Cell::new("Remind").add_attribute(Attribute::Bold),
Cell::new("Age").add_attribute(Attribute::Bold),
]);
}
for task in &tasks {
let is_done = task.status == Status::Done;
let is_closed = task.status == Status::Closed;
let is_inactive = is_done || is_closed;
let is_overdue = !is_inactive && task.due.is_some_and(|d| d < today);
let is_due_today = !is_inactive && task.due.is_some_and(|d| d == today);
let id_text = format!("#{}", task.id);
let project_text = task.project.as_deref().unwrap_or_default().to_string();
let due_text = task
.due
.map(|d| format!("{}/{}", d.month(), d.day()))
.unwrap_or_default();
let remind_text: String = task
.reminds
.iter()
.map(|d| format!("{}/{}", d.month(), d.day()))
.collect::<Vec<_>>()
.join(", ");
let age_text = if is_done {
task.done_at
.map(|d| format!("done {}/{}", d.month(), d.day()))
.unwrap_or_default()
} else if is_closed {
"closed".to_string()
} else {
let days = (today - task.created).num_days();
format!("{}d", days)
};
if compact {
let id_cell = if is_done {
Cell::new(&id_text).fg(Color::Green)
} else if is_closed {
Cell::new(&id_text).fg(Color::DarkGrey)
} else {
Cell::new(&id_text).fg(Color::Cyan)
};
let title_cell = if is_done {
Cell::new(&task.title).fg(Color::Green)
} else if is_closed {
Cell::new(&task.title).fg(Color::DarkGrey)
} else if is_overdue {
let cell = Cell::new(&task.title).fg(Color::Red);
if task.important {
cell.add_attribute(Attribute::Bold)
} else {
cell
}
} else if task.important {
Cell::new(&task.title)
.fg(Color::Magenta)
.add_attribute(Attribute::Bold)
} else {
Cell::new(&task.title)
};
let due_cell = if is_inactive {
Cell::new(&due_text).fg(if is_done {
Color::Green
} else {
Color::DarkGrey
})
} else if is_overdue {
Cell::new(&due_text).fg(Color::Red)
} else if is_due_today {
Cell::new(&due_text).fg(Color::Yellow)
} else if task.due.is_some() {
Cell::new(&due_text).fg(Color::Green)
} else {
Cell::new(&due_text)
};
table.add_row(vec![id_cell, title_cell, due_cell]);
} else if is_done {
let green = Color::Green;
table.add_row(vec![
Cell::new(id_text).fg(green),
Cell::new("DONE").fg(green),
Cell::new(&project_text).fg(project_colors
.get(project_text.as_str())
.copied()
.unwrap_or(Color::White)),
Cell::new(&task.title).fg(green),
Cell::new(due_text).fg(green),
Cell::new(&remind_text).fg(green),
Cell::new(age_text).fg(green),
]);
} else if is_closed {
let grey = Color::DarkGrey;
table.add_row(vec![
Cell::new(id_text).fg(grey),
Cell::new("CLOSED").fg(grey),
Cell::new(project_text).fg(grey),
Cell::new(&task.title).fg(grey),
Cell::new(due_text).fg(grey),
Cell::new(&remind_text).fg(grey),
Cell::new(age_text).fg(grey),
]);
} else {
let title_cell = if is_overdue {
let cell = Cell::new(&task.title).fg(Color::Red);
if task.important {
cell.add_attribute(Attribute::Bold)
} else {
cell
}
} else if task.important {
Cell::new(&task.title)
.fg(Color::Magenta)
.add_attribute(Attribute::Bold)
} else {
Cell::new(&task.title)
};
let due_cell = if is_overdue {
Cell::new(due_text).fg(Color::Red)
} else if is_due_today {
Cell::new(due_text).fg(Color::Yellow)
} else if task.due.is_some() {
Cell::new(due_text).fg(Color::Green)
} else {
Cell::new(due_text)
};
let days = (today - task.created).num_days();
let age_cell = if days > 30 {
Cell::new(age_text).fg(Color::Red)
} else if days > 7 {
Cell::new(age_text).fg(Color::Yellow)
} else {
Cell::new(age_text)
};
let remind_cell = Cell::new(&remind_text);
table.add_row(vec![
Cell::new(id_text).fg(Color::Cyan),
Cell::new("OPEN").fg(Color::Blue),
Cell::new(&project_text).fg(project_colors
.get(project_text.as_str())
.copied()
.unwrap_or(Color::White)),
title_cell,
due_cell,
remind_cell,
age_cell,
]);
}
}
let id_col = table.column_mut(0).expect("id column");
id_col.set_cell_alignment(CellAlignment::Right);
if !compact {
let age_col = table.column_mut(6).expect("age column");
age_col.set_cell_alignment(CellAlignment::Right);
}
println!("{table}");
println!();
if all && done_count > 0 {
println!("{} tasks ({} done)", tasks.len(), done_count);
} else {
println!("{} tasks", tasks.len());
}
}
const PROJECT_PALETTE: &[Color] = &[
Color::Rgb {
r: 255,
g: 107,
b: 107,
}, Color::Rgb {
r: 255,
g: 179,
b: 71,
}, Color::Rgb {
r: 255,
g: 217,
b: 61,
}, Color::Rgb {
r: 119,
g: 221,
b: 119,
}, Color::Rgb {
r: 77,
g: 208,
b: 225,
}, Color::Rgb {
r: 129,
g: 140,
b: 248,
}, Color::Rgb {
r: 192,
g: 132,
b: 252,
}, Color::Rgb {
r: 244,
g: 114,
b: 182,
}, Color::Rgb {
r: 251,
g: 146,
b: 60,
}, Color::Rgb {
r: 45,
g: 212,
b: 191,
}, ];
fn build_project_color_map(
tasks: &[crate::model::Task],
) -> std::collections::HashMap<String, Color> {
use rand::Rng;
let mut rng = rand::rng();
let mut map = std::collections::HashMap::new();
for task in tasks {
if let Some(ref name) = task.project {
if !name.is_empty() && !map.contains_key(name) {
let idx = rng.random_range(0..PROJECT_PALETTE.len());
map.insert(name.clone(), PROJECT_PALETTE[idx]);
}
}
}
map
}