From 4669c1bd62ad5d3cba94a16155d54ce02a7ae78d Mon Sep 17 00:00:00 2001 From: Alexander Holte-Davidsen Date: Wed, 24 Jun 2026 13:05:57 +0200 Subject: [PATCH] feat: support time-of-day in due dates (#58) Extend due-date parsing beyond whole-day deadlines: - ISO date-time: `YYYY-MM-DDTHH:MM[:SS]` and `YYYY-MM-DD HH:MM[:SS]` - Relative offsets: `today+HHMM`, `tomorrow+HHMM` (that day at HH:MM) and `now+HHMM` (HH hours, MM minutes from now); colon optional - A bare `now` keyword Due dates with a time render as `YYYY-MM-DD HH:MM` and are classified time-granularly (Overdue once the instant passes). Whole-day deadlines (local midnight) keep their date-granular behaviour, so `due:today` stays "due today" all day. Note: a `due:` token cannot contain a space, so the token form uses the `T` separator (`due:2026-07-01T14:30`); the `--due` flag accepts the quoted space form too. Updates CLI/TUI/MCP help text and docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +- crates/taskbook-client/src/main.rs | 2 + crates/taskbook-client/src/mcp/tools.rs | 4 +- .../taskbook-client/src/tui/command_parser.rs | 3 +- crates/taskbook-common/src/board.rs | 16 +- crates/taskbook-common/src/due.rs | 241 ++++++++++++++++-- 6 files changed, 239 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f9e9f6f..70d2be0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,7 @@ tb --task "Description" # Create task tb --task @board "Desc" # Create task in specific board tb --task "Desc" p:2 # Create with priority (1=normal, 2=medium, 3=high) tb --task "Desc" due:2026-07-01 # Create with due date (also due:today, due:tomorrow) +tb --task "Desc" due:tomorrow+0930 # Due with time via token: today/tomorrow/now+HHMM or YYYY-MM-DDTHH:MM (no spaces) tb --note "Description" # Create note tb --note # Create note in external editor ($EDITOR) tb --check [id...] # Toggle task complete @@ -142,7 +143,7 @@ tb --edit @ "New desc" # Edit description tb --edit-note @ # Edit note in external editor ($EDITOR) tb --move @ board # Move to board tb --priority @ <1-3> # Set priority -tb --due @ # Set due date (YYYY-MM-DD, today, tomorrow) or none to clear +tb --due @ # Set due date (YYYY-MM-DD[ HH:MM], today, tomorrow, now, *+HHMM) or none to clear tb --find # Search items tb --list # Filter (pending, done, task, note, starred) tb --timeline # Chronological view diff --git a/crates/taskbook-client/src/main.rs b/crates/taskbook-client/src/main.rs index ad095db..ae9e276 100644 --- a/crates/taskbook-client/src/main.rs +++ b/crates/taskbook-client/src/main.rs @@ -63,6 +63,7 @@ const HELP_TEXT: &str = r#" $ tb --copy 1 2 3 $ tb --delete 4 $ tb --due @3 2026-07-01 + $ tb --due @3 "2026-07-01 14:30" $ tb --due @3 none $ tb --edit @3 Merge PR #42 $ tb --find documentation @@ -76,6 +77,7 @@ const HELP_TEXT: &str = r#" $ tb --task @coding +urgent Improve documentation $ tb --task Make some buttercream $ tb --task Pay rent due:2026-07-01 + $ tb --task Call dentist due:tomorrow+0930 $ tb --tag @3 +urgent +frontend $ tb --tag @3 -urgent $ tb --list +urgent diff --git a/crates/taskbook-client/src/mcp/tools.rs b/crates/taskbook-client/src/mcp/tools.rs index 7ed9ab6..f110f9b 100644 --- a/crates/taskbook-client/src/mcp/tools.rs +++ b/crates/taskbook-client/src/mcp/tools.rs @@ -37,7 +37,7 @@ pub fn definitions() -> Value { "boards": { "type": "array", "items": { "type": "string" }, "description": "Boards to place the task on (defaults to My Board)" }, "priority": { "type": "integer", "minimum": 1, "maximum": 3, "description": "1 = normal, 2 = medium, 3 = high (default 1)" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for the task" }, - "due_date": { "type": "string", "description": "Due date: YYYY-MM-DD, today, or tomorrow" } + "due_date": { "type": "string", "description": "Due date: YYYY-MM-DD, 'YYYY-MM-DD HH:MM', today, tomorrow, now, or today+HHMM / tomorrow+HHMM / now+HHMM" } }, "required": ["description"] } @@ -76,7 +76,7 @@ pub fn definitions() -> Value { "id": { "type": "integer", "description": "Item id" }, "description": { "type": "string", "description": "New description" }, "priority": { "type": "integer", "minimum": 1, "maximum": 3, "description": "New priority (tasks only)" }, - "due_date": { "type": "string", "description": "New due date (YYYY-MM-DD, today, tomorrow) or 'none' to clear (tasks only)" }, + "due_date": { "type": "string", "description": "New due date (YYYY-MM-DD, 'YYYY-MM-DD HH:MM', today, tomorrow, now, today+HHMM / tomorrow+HHMM / now+HHMM) or 'none' to clear (tasks only)" }, "boards": { "type": "array", "items": { "type": "string" }, "description": "Replace the item's boards" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Replace the item's tags" }, "starred": { "type": "boolean", "description": "Star or unstar the item" } diff --git a/crates/taskbook-client/src/tui/command_parser.rs b/crates/taskbook-client/src/tui/command_parser.rs index b4221a5..3e52e4b 100644 --- a/crates/taskbook-client/src/tui/command_parser.rs +++ b/crates/taskbook-client/src/tui/command_parser.rs @@ -127,7 +127,8 @@ fn parse_task(args: &str) -> Result { let args = args.trim(); if args.is_empty() { return Err(ParseError { - message: "Usage: /task [@board] description [p:1-3] [due:YYYY-MM-DD]".to_string(), + message: "Usage: /task [@board] description [p:1-3] [due:YYYY-MM-DD[THH:MM]]" + .to_string(), }); } diff --git a/crates/taskbook-common/src/board.rs b/crates/taskbook-common/src/board.rs index 9078ad3..5a0b061 100644 --- a/crates/taskbook-common/src/board.rs +++ b/crates/taskbook-common/src/board.rs @@ -64,7 +64,9 @@ pub enum CliParseError { /// Words starting with `@` (and longer than 1 char) are treated as board names. /// Words starting with `+` (and longer than 1 char) are treated as tags. /// Words matching `p:1`, `p:2`, `p:3` set priority. -/// Words starting with `due:` set the due date (`YYYY-MM-DD`, `today`, `tomorrow`). +/// Words starting with `due:` set the due date (`YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, +/// `today`, `tomorrow`, `now`, or `today+HHMM` / `tomorrow+HHMM` / `now+HHMM`). +/// (A `due:` token cannot contain a space, so use the `T` separator for a time.) /// Everything else is the description. /// /// If no boards are found, defaults to [`DEFAULT_BOARD`]. @@ -284,6 +286,18 @@ mod tests { ); } + #[test] + fn test_parse_cli_input_with_due_date_and_time() { + // A `due:` token carries a time via the `T` separator (no spaces). + let input: Vec = vec!["Call".into(), "vet".into(), "due:2026-07-01T14:30".into()]; + let parsed = parse_cli_input(&input).unwrap(); + assert_eq!(parsed.description, "Call vet"); + assert_eq!( + parsed.due_date, + Some(crate::due::parse_due_date("2026-07-01T14:30").unwrap()) + ); + } + #[test] fn test_parse_cli_input_invalid_due_date_errors() { let input: Vec = vec!["task".into(), "due:soon".into()]; diff --git a/crates/taskbook-common/src/due.rs b/crates/taskbook-common/src/due.rs index 2099a8d..cb687b7 100644 --- a/crates/taskbook-common/src/due.rs +++ b/crates/taskbook-common/src/due.rs @@ -1,11 +1,13 @@ //! Due-date parsing, formatting and status classification. //! -//! Due dates are stored as epoch milliseconds at local midnight of the due -//! day, consistent with the `_timestamp` convention used elsewhere. +//! Due dates are stored as epoch milliseconds. A due date without an explicit +//! time of day is stored at local midnight of the due day (and treated as a +//! whole-day deadline); a due date with a time is stored at that exact local +//! instant. -use chrono::{DateTime, Local, NaiveDate, TimeZone}; +use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; -/// Classification of a due date relative to today (local time). +/// Classification of a due date relative to now (local time). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DueStatus { Overdue, @@ -13,49 +15,165 @@ pub enum DueStatus { Upcoming, } -/// Parse a due-date string into local-midnight epoch millis. +/// Parse a due-date string into epoch millis (local time). /// -/// Accepts `YYYY-MM-DD`, `today`, `tomorrow` (case-insensitive). +/// Accepted formats (case-insensitive for keywords): +/// - `YYYY-MM-DD` — whole-day deadline at local midnight +/// - `YYYY-MM-DDTHH:MM[:SS]` or `YYYY-MM-DD HH:MM[:SS]` — exact local instant +/// - `today`, `tomorrow`, `now` — relative to the current moment +/// - `today+HHMM`, `tomorrow+HHMM` — that day plus `HH` hours and `MM` minutes +/// (so `today+1430` is today at 14:30) +/// - `now+HHMM` — `HH` hours and `MM` minutes from the current moment +/// +/// The offset accepts an optional colon (`14:30` == `1430`). pub fn parse_due_date(raw: &str) -> Option { - let s = raw.trim().to_lowercase(); - let date = match s.as_str() { - "today" => Local::now().date_naive(), - "tomorrow" => Local::now().date_naive().succ_opt()?, - _ => NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()?, - }; + let s = raw.trim(); + if s.is_empty() { + return None; + } + + // Relative keywords (today/tomorrow/now), optionally with a +HHMM offset. + if let Some(millis) = parse_relative(&s.to_lowercase()) { + return Some(millis); + } + + // Absolute date-time (carries a time of day). + for fmt in [ + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + ] { + if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) { + return naive_to_millis(ndt); + } + } + + // Absolute whole-day deadline (local midnight). + let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?; date_to_millis(date) } +/// Parse `today` / `tomorrow` / `now`, with an optional `+HHMM` offset. +fn parse_relative(s: &str) -> Option { + let (base, offset) = match s.split_once('+') { + Some((b, o)) => (b.trim(), Some(o.trim())), + None => (s, None), + }; + + let base_dt: DateTime = match base { + "today" => start_of_day(Local::now().date_naive())?, + "tomorrow" => start_of_day(Local::now().date_naive().succ_opt()?)?, + "now" => Local::now(), + _ => return None, + }; + + let dt = match offset { + None => base_dt, + Some(off) => { + let (hours, minutes) = parse_hhmm(off)?; + base_dt + Duration::hours(hours) + Duration::minutes(minutes) + } + }; + Some(dt.timestamp_millis()) +} + +/// Parse an `HHMM` (or `HH:MM`) offset into (hours, minutes). +fn parse_hhmm(raw: &str) -> Option<(i64, i64)> { + let digits: String = raw.chars().filter(|c| *c != ':').collect(); + if !(3..=4).contains(&digits.len()) || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let split = digits.len() - 2; + let hours: i64 = digits[..split].parse().ok()?; + let minutes: i64 = digits[split..].parse().ok()?; + if minutes >= 60 { + return None; + } + Some((hours, minutes)) +} + +fn start_of_day(date: NaiveDate) -> Option> { + Local + .from_local_datetime(&date.and_hms_opt(0, 0, 0)?) + .earliest() +} + fn date_to_millis(date: NaiveDate) -> Option { - let midnight = date.and_hms_opt(0, 0, 0)?; + start_of_day(date).map(|dt| dt.timestamp_millis()) +} + +fn naive_to_millis(ndt: NaiveDateTime) -> Option { Local - .from_local_datetime(&midnight) + .from_local_datetime(&ndt) .earliest() .map(|dt| dt.timestamp_millis()) } -/// Format a due-date millis value as `YYYY-MM-DD` in local time. +/// Whether a due-date millis value carries an explicit time of day (i.e. it is +/// not exactly local midnight). +fn has_time_of_day(local: DateTime) -> bool { + local.time() != NaiveTime::MIN +} + +/// Format a due-date millis value in local time. +/// +/// Whole-day deadlines render as `YYYY-MM-DD`; dues with a time of day render +/// as `YYYY-MM-DD HH:MM`. pub fn format_due_date(millis: i64) -> String { DateTime::from_timestamp_millis(millis) - .map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d").to_string()) + .map(|dt| { + let local = dt.with_timezone(&Local); + if has_time_of_day(local) { + local.format("%Y-%m-%d %H:%M").to_string() + } else { + local.format("%Y-%m-%d").to_string() + } + }) .unwrap_or_default() } -/// Classify a due date against the current local date. +/// Classify a due date against the current local time. +/// +/// Whole-day deadlines (local midnight) are classified by date, so a task due +/// `today` stays `DueToday` for the whole day. Dues with an explicit time are +/// classified by the exact instant, so they flip to `Overdue` once the time +/// passes. pub fn due_status(due_millis: i64) -> DueStatus { - let due = - DateTime::from_timestamp_millis(due_millis).map(|dt| dt.with_timezone(&Local).date_naive()); - let today = Local::now().date_naive(); - match due { - Some(d) if d < today => DueStatus::Overdue, - Some(d) if d == today => DueStatus::DueToday, - _ => DueStatus::Upcoming, + let Some(due) = DateTime::from_timestamp_millis(due_millis).map(|dt| dt.with_timezone(&Local)) + else { + return DueStatus::Upcoming; + }; + let now = Local::now(); + let today = now.date_naive(); + + if has_time_of_day(due) { + if due < now { + DueStatus::Overdue + } else if due.date_naive() == today { + DueStatus::DueToday + } else { + DueStatus::Upcoming + } + } else { + match due.date_naive() { + d if d < today => DueStatus::Overdue, + d if d == today => DueStatus::DueToday, + _ => DueStatus::Upcoming, + } } } #[cfg(test)] mod tests { use super::*; + use chrono::Timelike; + + fn local_dt(millis: i64) -> DateTime { + DateTime::from_timestamp_millis(millis) + .unwrap() + .with_timezone(&Local) + } #[test] fn parse_iso_date_round_trips() { @@ -63,6 +181,31 @@ mod tests { assert_eq!(format_due_date(millis), "2026-07-01"); } + #[test] + fn parse_iso_datetime_round_trips() { + let millis = parse_due_date("2026-07-01T14:30").unwrap(); + assert_eq!(format_due_date(millis), "2026-07-01 14:30"); + } + + #[test] + fn parse_iso_datetime_space_separator() { + let millis = parse_due_date("2026-07-01 09:05").unwrap(); + assert_eq!(format_due_date(millis), "2026-07-01 09:05"); + } + + #[test] + fn parse_iso_datetime_with_seconds() { + // Seconds are accepted; display truncates to minutes. + let millis = parse_due_date("2026-07-01T14:30:45").unwrap(); + assert_eq!(format_due_date(millis), "2026-07-01 14:30"); + } + + #[test] + fn midnight_time_renders_as_date_only() { + let millis = parse_due_date("2026-07-01T00:00").unwrap(); + assert_eq!(format_due_date(millis), "2026-07-01"); + } + #[test] fn parse_today_and_tomorrow() { let today = Local::now().date_naive(); @@ -75,15 +218,49 @@ mod tests { ); } + #[test] + fn parse_today_plus_offset_sets_time_of_day() { + let millis = parse_due_date("today+1430").unwrap(); + let dt = local_dt(millis); + assert_eq!(dt.date_naive(), Local::now().date_naive()); + assert_eq!(dt.hour(), 14); + assert_eq!(dt.minute(), 30); + } + + #[test] + fn parse_tomorrow_plus_offset_with_colon() { + let millis = parse_due_date("tomorrow+09:05").unwrap(); + let dt = local_dt(millis); + assert_eq!( + dt.date_naive(), + Local::now().date_naive().succ_opt().unwrap() + ); + assert_eq!(dt.hour(), 9); + assert_eq!(dt.minute(), 5); + } + + #[test] + fn parse_now_plus_offset_adds_duration() { + let before = Local::now(); + let millis = parse_due_date("now+0130").unwrap(); + let dt = local_dt(millis); + let delta = dt - before; + // ~1h30m from now (allow a generous window for test execution time). + assert!(delta >= Duration::minutes(89) && delta <= Duration::minutes(91)); + } + #[test] fn parse_invalid_returns_none() { assert_eq!(parse_due_date("next week"), None); assert_eq!(parse_due_date("2026-13-99"), None); assert_eq!(parse_due_date(""), None); + assert_eq!(parse_due_date("today+99"), None); // offset too short + assert_eq!(parse_due_date("today+1499"), None); // minutes >= 60 + assert_eq!(parse_due_date("today+abcd"), None); } #[test] - fn status_classification() { + fn status_classification_whole_day() { let today = parse_due_date("today").unwrap(); assert_eq!(due_status(today), DueStatus::DueToday); let tomorrow = parse_due_date("tomorrow").unwrap(); @@ -91,4 +268,18 @@ mod tests { let yesterday = today - 24 * 60 * 60 * 1000; assert_eq!(due_status(yesterday), DueStatus::Overdue); } + + #[test] + fn status_timed_due_is_time_granular() { + let now = Local::now(); + // A timed due one hour ago today is overdue (not merely "due today"). + let past = (now - Duration::hours(1)).timestamp_millis(); + assert_eq!(due_status(past), DueStatus::Overdue); + // A timed due one hour from now today is due today. + let soon = (now + Duration::hours(1)).timestamp_millis(); + // Guard against the rare case where +1h crosses midnight. + if local_dt(soon).date_naive() == now.date_naive() { + assert_eq!(due_status(soon), DueStatus::DueToday); + } + } }