diff --git a/Cargo.lock b/Cargo.lock index ef511d1..07d5be6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chrono-english" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a56613410c9249673f4676ab8d27c70949814accb27b6b738009e2ff1034a1" +dependencies = [ + "chrono", + "scanlex", +] + [[package]] name = "chrono-humanize" version = "0.2.3" @@ -1099,6 +1109,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "scanlex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088c5d71572124929ea7549a8ce98e1a6fd33d0a38367b09027b382e67c033db" + [[package]] name = "scc" version = "2.3.4" @@ -1724,6 +1740,7 @@ version = "1.3.0" dependencies = [ "assert_cmd", "chrono", + "chrono-english", "chrono-humanize", "clap", "colored", diff --git a/Cargo.toml b/Cargo.toml index 4b2ba03..adb69c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" [dependencies] chrono = "0.4.38" +chrono-english = "0.1.8" chrono-humanize = "0.2.3" clap = { version = "4.4.2", features = ["derive", "string"] } colored = "2.2.0" diff --git a/migrations/2026-01-09-073505_adding_due_dates/down.sql b/migrations/2026-01-09-073505_adding_due_dates/down.sql new file mode 100644 index 0000000..d6fb8c6 --- /dev/null +++ b/migrations/2026-01-09-073505_adding_due_dates/down.sql @@ -0,0 +1 @@ +ALTER TABLE `todos` DROP COLUMN `due`; diff --git a/migrations/2026-01-09-073505_adding_due_dates/up.sql b/migrations/2026-01-09-073505_adding_due_dates/up.sql new file mode 100644 index 0000000..1b17d85 --- /dev/null +++ b/migrations/2026-01-09-073505_adding_due_dates/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE `todos` +ADD COLUMN `due` TIMESTAMPTZSQLITE DEFAULT NULL +; diff --git a/src/cli.rs b/src/cli.rs index bce232e..179315a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,8 @@ use crate::models::NewTodo; use chrono::{DateTime, Local, Utc}; use clap::{Parser, Subcommand}; -use colored::Colorize; +use colored::{ColoredString, Colorize}; +use std::cmp::Ordering; #[derive(Parser)] #[command( @@ -42,6 +43,9 @@ enum Commands { /// close the TODO right after creation #[clap(short, long, action)] complete: bool, + /// due date by which the TODO should be done + #[clap(short, long, action)] + due: Option, }, /// List current TODOs, flag priority: all > completed > open (default). #[clap(visible_alias = "ls")] @@ -86,6 +90,14 @@ enum Commands { #[clap()] id: String, }, + /// Set the due time + Due { + #[clap()] + id: String, + /// A human readable description of a time by which the TODO should be dune, like: "Monday + /// 9am". If not provided due time will be removed + due_text: Option, + }, } fn get_version_str() -> String { @@ -103,8 +115,12 @@ pub fn run_cli() { Commands::LocateDb => { println!("{}", crate::get_db_file().display()); } - Commands::Add { title, complete } => { - add_todo(title, complete); + Commands::Add { + title, + complete, + due, + } => { + add_todo(title, complete, due); } Commands::List { all, @@ -133,10 +149,13 @@ pub fn run_cli() { edit_todo(id.to_string()); } Commands::Complete { id } => { - complete_todo(&id.to_string()); + complete_todo(&id); } Commands::Reopen { id } => { - reopen_todo(&id.to_string()); + reopen_todo(&id); + } + Commands::Due { id, due_text } => { + set_due_todo(&id, due_text); // TODO: borrow due_text instead } } } @@ -150,6 +169,37 @@ fn format_datetime(ts: DateTime, precise: bool) -> String { format!("{}", local_tz.format("%d/%m/%Y %H:%M")) } +fn format_duetime(ts: DateTime, precise: bool) -> ColoredString { + let duetime = format_datetime(ts, precise); + let offset = ts - Utc::now(); + if offset.num_seconds() < 0 { + duetime.red() + } else if offset.num_seconds() < 60 * 60 * 24 { + // A day + // TODO: could this be orange and then next be yellow + duetime.yellow() + } else if offset.num_seconds() < 60 * 60 * 24 * 3 { + // 3 days + duetime.magenta() + } else if offset.num_seconds() < 60 * 60 * 24 * 7 { + // 3 days + duetime.green() + } else { + duetime.into() + } +} + +fn format_duetime_or_else( + ts: Option>, + else_item: String, + precise: bool, +) -> ColoredString { + match ts { + Some(ts) => format_duetime(ts, precise), + None => else_item.into(), + } +} + fn format_datetime_or_else(ts: Option>, else_item: String, precise: bool) -> String { match ts { Some(ts) => format_datetime(ts, precise), @@ -162,9 +212,10 @@ fn show_todo(id: &String) { let created_str: String = format_datetime(found_todo.created, false); let completed_str: String = format_datetime_or_else(found_todo.completed, "not yet".to_string(), false); + let due_str = format_duetime_or_else(found_todo.due, "no due date".to_string(), false); println!( - "{}\n{}\nIt was created: {}\nIt was completed: {}", - found_todo.title, found_todo.notes, created_str, completed_str, + "{}\n{}\nIt was created: {}\nIt was completed: {}\nIt's due on: {}", + found_todo.title, found_todo.notes, created_str, completed_str, due_str, ); } @@ -192,6 +243,20 @@ fn complete_todo(id: &String) { ) } +fn set_due_todo(id: &String, due_text: Option) { + let mut due_ts: Option> = None; + if let Some(due_str) = due_text { + due_ts = Some(crate::parse_due_str(&due_str)); + } + crate::set_due(id, due_ts); + println!( + // TODO: add undo message + "{} is due at: {}", + id.yellow(), + format_duetime_or_else(due_ts, "no set time".to_string(), false) + ) +} + fn reopen_todo(id: &String) { crate::reopen_todo(id); println!( @@ -207,7 +272,7 @@ pub fn delete_todo(id: &String) { println!("{} deleted", id.yellow()); } -pub fn add_todo(title: Option, complete_after_creation: bool) { +pub fn add_todo(title: Option, complete_after_creation: bool, due: Option) { // TODO: There should be a way to supply body easily just like in `git commit -m ""`, but // don't forget multiline messages with multiple -m's let title_str = match title { @@ -227,6 +292,13 @@ pub fn add_todo(title: Option, complete_after_creation: bool) { created: Utc::now(), }; let created_todo = crate::add_todo(&new_todo); + if let Some(due_text) = due { + let due_ts = crate::parse_due_str(&due_text); + crate::set_due( + &crate::encode_id(created_todo.id.try_into().unwrap()), + Some(due_ts), + ); + } if complete_after_creation { crate::complete_todo( &crate::encode_id(created_todo.id.try_into().unwrap()), @@ -246,7 +318,6 @@ pub fn add_todo(title: Option, complete_after_creation: bool) { pub fn list_todos(show_completed: Option) { let mut results = crate::get_todos(); - // show_completed parameter: // - None: show open (uncompleted) TODOs (default behavior) // - Some(true): show only completed TODOs @@ -265,8 +336,15 @@ pub fn list_todos(show_completed: Option) { } } - // Sort by id descending (same as the original database query) - results.sort_by(|a, b| b.id.cmp(&a.id)); + // Sort by due time descending, while pushing no due dates to the back + // When they are both None, keep relative order to maintain secondary sort from + // database query by id. + results.sort_by(|a, b| match (&a.due, &b.due) { + (Some(d1), Some(d2)) => d1.cmp(d2), // both have dates → compare them + (None, Some(_)) => Ordering::Greater, // a is None, b has a date → a goes after b + (Some(_), None) => Ordering::Less, // a has a date, b is None → a goes before b + (None, None) => Ordering::Equal, // both None → keep relative order (stable sort) + }); if results.is_empty() { println!( @@ -276,7 +354,7 @@ pub fn list_todos(show_completed: Option) { } else { let mut table = comfy_table::Table::new(); table.load_preset(comfy_table::presets::NOTHING); - table.set_header(vec!["id", "created", "title"]); + table.set_header(vec!["id", "created", "due", "title"]); for post in results { table.add_row(vec![ comfy_table::Cell::new( @@ -288,6 +366,7 @@ pub fn list_todos(show_completed: Option) { .to_string(), ), comfy_table::Cell::new(format_datetime(post.created, false)), + comfy_table::Cell::new(format_duetime_or_else(post.due, "".to_string(), false)), comfy_table::Cell::new(post.title), ]); } diff --git a/src/lib.rs b/src/lib.rs index 223c1f7..9d00f97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,12 @@ use self::schema::todos; pub const COMMENT_DISCLAIMER: &str = "# This is a comment, lines starting with a # will be ignored"; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); +fn parse_due_str(s: &String) -> DateTime { + chrono_english::parse_date_string(s, Local::now(), chrono_english::Dialect::Us) + .expect("Parsing due date didn't work") + .to_utc() +} + fn create_sqids_encoder_with_custom_alphabet() -> Sqids { Sqids::builder() .min_length(5) @@ -190,6 +196,25 @@ pub fn complete_todo(show_id: &String, ts: Option>) { .unwrap_or_else(|_| panic!("TODO: {} couldn't be completed", show_id)); } +pub fn set_due(show_id: &String, ts: Option>) { + use self::schema::todos::dsl::*; + let connection = &mut establish_connection(); + let decoded_id = decode_id(show_id); + diesel::update(todos.find(decoded_id)) + .set(due.eq(ts)) + .execute(connection) + .unwrap_or_else(|_| { + panic!( + "TODO: {}'s due couldn't be set to {}", + show_id, + match ts { + Some(due_ts) => due_ts.format("%c").to_string(), + None => "none".to_string(), + } + ) + }); +} + pub fn set_todo_title(update_id: &String, new_title: &String) { use self::schema::todos::dsl::*; let connection = &mut establish_connection(); diff --git a/src/models.rs b/src/models.rs index bc7c54b..c2de8e7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -12,6 +12,7 @@ pub struct Todos { // TODO: time tracking pub created: DateTime, pub completed: Option>, + pub due: Option>, } #[derive(Insertable)] diff --git a/src/schema.rs b/src/schema.rs index 8abce71..8dbfd39 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -5,5 +5,6 @@ diesel::table! { notes -> Text, created -> diesel::sql_types::TimestamptzSqlite, completed -> diesel::sql_types::Nullable, + due -> diesel::sql_types::Nullable, } } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 3e3ac23..bf723f5 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -7,7 +7,7 @@ use serial_test::serial; use tempdir::TempDir; use workingon::models::NewTodo; use workingon::schema::todos::dsl::*; -use workingon::{encode_id, establish_connection}; +use workingon::{encode_id, establish_connection, get_todo}; // Helper function to get the latest TODO from the database fn get_latest_todo() -> Option<(String, workingon::models::Todos)> { @@ -183,8 +183,6 @@ fn test_add_and_show_todo() { // Get the TODO ID directly from the database let (todo_id, _todo) = get_latest_todo().expect("No todo found"); - println!("{}", todo_id); - // Verify the TODO was added by listing Command::cargo_bin("workingon") .unwrap() @@ -423,3 +421,51 @@ fn test_add_and_complete() { .success() .stdout(predicate::str::contains("Completed TODO")); } + +#[test] +#[serial] +fn test_set_duetime() { + let tmp_dir = TempDir::new("workingon_test").expect("cannot make temp directory for test"); + + std::env::set_var( + "WORKINGON_DATA_DIR", + tmp_dir.path().to_string_lossy().to_string(), + ); + std::env::set_var("EDITOR", "-"); + + let created_todo = workingon::add_todo(&NewTodo { + title: "test_set_duetime", + notes: "", + created: Utc::now(), + }); + // Set same due date as in help message to make sure the example works + Command::cargo_bin("workingon") + .unwrap() + .env("EDITOR", "-") + .env("WORKINGON_DATA_DIR", tmp_dir.path()) + .args([ + "due", + &crate::encode_id(created_todo.id.try_into().unwrap()), + "Monday 9am", + ]) + .assert() + .success() + .stdout(predicate::str::contains("is due at:")); + let updated_todo = get_todo(&crate::encode_id(created_todo.id.try_into().unwrap())); + // Make sure command changed database + assert!(updated_todo.due.is_some()); + // Set no due date + Command::cargo_bin("workingon") + .unwrap() + .env("EDITOR", "-") + .env("WORKINGON_DATA_DIR", tmp_dir.path()) + .args([ + "due", + &crate::encode_id(created_todo.id.try_into().unwrap()), + ]) + .assert() + .success() + .stdout(predicate::str::contains("is due at: no set time")); + let updated_todo = get_todo(&crate::encode_id(created_todo.id.try_into().unwrap())); + assert!(updated_todo.due.is_none()); +} diff --git a/tests/lib_test.rs b/tests/lib_test.rs index 81c1029..cae65a4 100644 --- a/tests/lib_test.rs +++ b/tests/lib_test.rs @@ -1,4 +1,4 @@ -use chrono::Utc; +use chrono::{TimeDelta, Utc}; use diesel::prelude::*; use serial_test::serial; use std::env; @@ -535,3 +535,23 @@ fn test_add_and_complete() { let updated_todo = get_todo(&id_string); assert!(updated_todo.created == updated_todo.completed.unwrap()) } + +#[test] +#[serial] +fn test_set_due() { + let _tmp_dir = setup_test_env(); + + let created_todo = add_todo(&NewTodo { + title: "test_set_due", + notes: "", + created: Utc::now(), + }); + let id_string = encode_id(created_todo.id.try_into().unwrap()); + let a_week_later = created_todo.created + TimeDelta::seconds(60 * 60 * 24 * 7); + set_due(&id_string, Some(a_week_later)); + let updated_todo = get_todo(&id_string); + assert!(updated_todo.due.unwrap() == a_week_later); + set_due(&id_string, None); + let updated_todo = get_todo(&id_string); + assert!(updated_todo.due.is_none()); +}