Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions migrations/2026-01-09-073505_adding_due_dates/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `todos` DROP COLUMN `due`;
3 changes: 3 additions & 0 deletions migrations/2026-01-09-073505_adding_due_dates/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE `todos`
ADD COLUMN `due` TIMESTAMPTZSQLITE DEFAULT NULL
;
103 changes: 91 additions & 12 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<String>,
},
/// List current TODOs, flag priority: all > completed > open (default).
#[clap(visible_alias = "ls")]
Expand Down Expand Up @@ -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<String>,
},
}

fn get_version_str() -> String {
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -150,6 +169,37 @@ fn format_datetime(ts: DateTime<Utc>, precise: bool) -> String {
format!("{}", local_tz.format("%d/%m/%Y %H:%M"))
}

fn format_duetime(ts: DateTime<Utc>, 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<DateTime<Utc>>,
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<DateTime<Utc>>, else_item: String, precise: bool) -> String {
match ts {
Some(ts) => format_datetime(ts, precise),
Expand All @@ -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,
);
}

Expand Down Expand Up @@ -192,6 +243,20 @@ fn complete_todo(id: &String) {
)
}

fn set_due_todo(id: &String, due_text: Option<String>) {
let mut due_ts: Option<DateTime<Utc>> = 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!(
Expand All @@ -207,7 +272,7 @@ pub fn delete_todo(id: &String) {
println!("{} deleted", id.yellow());
}

pub fn add_todo(title: Option<String>, complete_after_creation: bool) {
pub fn add_todo(title: Option<String>, complete_after_creation: bool, due: Option<String>) {
// 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 {
Expand All @@ -227,6 +292,13 @@ pub fn add_todo(title: Option<String>, 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()),
Expand All @@ -246,7 +318,6 @@ pub fn add_todo(title: Option<String>, complete_after_creation: bool) {

pub fn list_todos(show_completed: Option<bool>) {
let mut results = crate::get_todos();

// show_completed parameter:
// - None: show open (uncompleted) TODOs (default behavior)
// - Some(true): show only completed TODOs
Expand All @@ -265,8 +336,15 @@ pub fn list_todos(show_completed: Option<bool>) {
}
}

// 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!(
Expand All @@ -276,7 +354,7 @@ pub fn list_todos(show_completed: Option<bool>) {
} 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(
Expand All @@ -288,6 +366,7 @@ pub fn list_todos(show_completed: Option<bool>) {
.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),
]);
}
Expand Down
25 changes: 25 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc> {
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)
Expand Down Expand Up @@ -190,6 +196,25 @@ pub fn complete_todo(show_id: &String, ts: Option<DateTime<Utc>>) {
.unwrap_or_else(|_| panic!("TODO: {} couldn't be completed", show_id));
}

pub fn set_due(show_id: &String, ts: Option<DateTime<Utc>>) {
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();
Expand Down
1 change: 1 addition & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub struct Todos {
// TODO: time tracking
pub created: DateTime<Utc>,
pub completed: Option<DateTime<Utc>>,
pub due: Option<DateTime<Utc>>,
}

#[derive(Insertable)]
Expand Down
1 change: 1 addition & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ diesel::table! {
notes -> Text,
created -> diesel::sql_types::TimestamptzSqlite,
completed -> diesel::sql_types::Nullable<diesel::sql_types::TimestamptzSqlite>,
due -> diesel::sql_types::Nullable<diesel::sql_types::TimestamptzSqlite>,
}
}
52 changes: 49 additions & 3 deletions tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)> {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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());
}
Loading