From 76bf1a5e6857e523a448595326f9e886c863564a Mon Sep 17 00:00:00 2001 From: PureTensor Date: Sun, 25 Oct 2026 04:50:56 +0000 Subject: [PATCH 1/2] v3.51.6: count a passed datetime as overdue in the morning digest The 07:00 split compared the raw deadline text with today's date. A datetime sorts after that date, so an instant that had already passed was filed under DUE TODAY, and so was a deadline the overdue filter cannot read. --- crates/ptask-bot/src/digest.rs | 137 +++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 6 deletions(-) diff --git a/crates/ptask-bot/src/digest.rs b/crates/ptask-bot/src/digest.rs index 45d00d9d..dc498844 100644 --- a/crates/ptask-bot/src/digest.rs +++ b/crates/ptask-bot/src/digest.rs @@ -42,12 +42,9 @@ fn render_morning(db: &Db) -> Result { return Ok(out); } - let (overdue, due_today): (Vec<&Task>, Vec<&Task>) = rows.iter().partition(|t| { - t.deadline - .as_deref() - .map(|d| d < today.as_str()) - .unwrap_or(false) - }); + let (overdue, due_today): (Vec<&Task>, Vec<&Task>) = rows + .iter() + .partition(|t| morning_row_is_overdue(t.deadline.as_deref(), &today, &now)); if !overdue.is_empty() { out.push_str(&format!("🚨 OVERDUE ({})\n", overdue.len())); @@ -126,6 +123,34 @@ fn render_evening_at(db: &Db, now: &jiff::Zoned) -> Result { Ok(out) } +/// Whether a morning-digest row belongs under OVERDUE. +/// +/// The list is `today | overdue`. Date-only deadlines are due all day, so +/// only a date before today is overdue. A datetime is overdue once its +/// instant has passed, even when the text starts with today's date +/// (`"YYYY-MM-DDT…"` sorts after `"YYYY-MM-DD"`, which used to hide it). +/// A deadline the filter cannot read is overdue too: that is the `overdue` +/// term in docs/dsl.md. +fn morning_row_is_overdue(deadline: Option<&str>, today: &str, now: &jiff::Zoned) -> bool { + let Some(d) = deadline.map(str::trim).filter(|s| !s.is_empty()) else { + return false; + }; + if d.len() == 10 && d.as_bytes().get(4) == Some(&b'-') && d.as_bytes().get(7) == Some(&b'-') { + // A real civil date is due all day. The same 10-character shape that + // is not a date is unreadable, and `overdue` counts that + // (`julianday` is NULL). A string compare would file "abcd-ef-gh" + // under today because it sorts after "YYYY-MM-DD". + if d.parse::().is_ok() { + return d < today; + } + return true; + } + match dates::parse_iso_to_utc(d) { + Some(when) => when.timestamp() < now.timestamp(), + None => true, + } +} + fn local_day_utc_bounds(now: &jiff::Zoned) -> Result<(String, String)> { let tz = now.time_zone().clone(); let start_local = now.date().to_zoned(tz.clone())?; @@ -218,6 +243,106 @@ mod tests { assert!(txt.contains("Clear runway")); } + /// A datetime that has already passed is overdue even when its text + /// starts with today's date. The morning split used to compare the raw + /// deadline string with `today`, and `"YYYY-MM-DDT..."` sorts after + /// `"YYYY-MM-DD"`, so the 07:00 digest filed that occurrence under + /// DUE TODAY. + #[test] + fn morning_digest_counts_a_passed_datetime_on_todays_date_as_overdue() { + let (_dir, db) = fresh_db(); + let today = dates::now_in_operator_tz().unwrap().date().to_string(); + // 00:00 at UTC+14 is 10:00 the previous UTC day, so on this London + // date the instant is already past. The text still sorts after + // `today`. Write it directly: create() normalises an offset into + // UTC and that rewrite hides the comparison. + let deadline = format!("{today}T00:00:00+14:00"); + let task = ptask_core::tasks::create( + &db, + NewTask::minimal("missed earlier today"), + &EventCtx::test(), + ) + .unwrap(); + db.with_conn(|c| { + c.execute( + "UPDATE tasks SET deadline=?1 WHERE id=?2", + rusqlite::params![deadline, task.id], + )?; + Ok(()) + }) + .unwrap(); + + let txt = render_morning(&db).unwrap(); + assert!( + txt.contains("🚨 OVERDUE"), + "a datetime already past must be overdue even when its text starts with today, got:\n{txt}" + ); + assert!(txt.contains("missed earlier today"), "got:\n{txt}"); + assert!( + !txt.contains("📅 DUE TODAY"), + "the passed datetime must not be filed under due today, got:\n{txt}" + ); + } + + /// The overdue filter treats a deadline it cannot read as overdue + /// (docs/dsl.md). The morning split must use that same rule. + #[test] + fn morning_digest_counts_an_unreadable_deadline_as_overdue() { + let (_dir, db) = fresh_db(); + let task = ptask_core::tasks::create( + &db, + NewTask::minimal("deadline we cannot read"), + &EventCtx::test(), + ) + .unwrap(); + db.with_conn(|c| { + c.execute( + "UPDATE tasks SET deadline=?1 WHERE id=?2", + rusqlite::params!["not-a-date", task.id], + )?; + Ok(()) + }) + .unwrap(); + + let txt = render_morning(&db).unwrap(); + assert!( + txt.contains("🚨 OVERDUE"), + "an unreadable deadline is overdue, got:\n{txt}" + ); + assert!(txt.contains("deadline we cannot read"), "got:\n{txt}"); + assert!(!txt.contains("📅 DUE TODAY"), "got:\n{txt}"); + } + + /// "abcd-ef-gh" has the date-only shape and sorts after today's date, so + /// a string compare files it under DUE TODAY. `julianday` cannot read it, + /// and docs/dsl.md counts that as overdue. + #[test] + fn morning_digest_counts_a_dash_shaped_unreadable_deadline_as_overdue() { + let (_dir, db) = fresh_db(); + let task = ptask_core::tasks::create( + &db, + NewTask::minimal("deadline shaped like a date"), + &EventCtx::test(), + ) + .unwrap(); + db.with_conn(|c| { + c.execute( + "UPDATE tasks SET deadline=?1 WHERE id=?2", + rusqlite::params!["abcd-ef-gh", task.id], + )?; + Ok(()) + }) + .unwrap(); + + let txt = render_morning(&db).unwrap(); + assert!( + txt.contains("🚨 OVERDUE"), + "an unreadable date-shaped deadline is overdue, got:\n{txt}" + ); + assert!(txt.contains("deadline shaped like a date"), "got:\n{txt}"); + assert!(!txt.contains("📅 DUE TODAY"), "got:\n{txt}"); + } + #[test] fn morning_digest_ignores_completed_due_today_for_empty_case() { let (_dir, db) = fresh_db(); From bfcc58cfb42925b5ac7c84724ac541eb1b85cbaf Mon Sep 17 00:00:00 2001 From: PureTensor Date: Sun, 25 Oct 2026 05:38:57 +0000 Subject: [PATCH 2/2] v3.51.7: count a passed datetime as overdue in the morning digest Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 14 +++++++------- Cargo.toml | 14 +++++++------- docs/gen/pt.1 | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f683b4a..178b6546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2708,7 +2708,7 @@ dependencies = [ [[package]] name = "ptask-bot" -version = "3.51.6" +version = "3.51.7" dependencies = [ "anyhow", "jiff", @@ -2724,7 +2724,7 @@ dependencies = [ [[package]] name = "ptask-cli" -version = "3.51.6" +version = "3.51.7" dependencies = [ "anyhow", "axum", @@ -2752,7 +2752,7 @@ dependencies = [ [[package]] name = "ptask-core" -version = "3.51.6" +version = "3.51.7" dependencies = [ "hex", "interim", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "ptask-distill" -version = "3.51.6" +version = "3.51.7" dependencies = [ "anyhow", "candle-core", @@ -2797,7 +2797,7 @@ dependencies = [ [[package]] name = "ptask-notify" -version = "3.51.6" +version = "3.51.7" dependencies = [ "lettre", "ptask-core", @@ -2810,7 +2810,7 @@ dependencies = [ [[package]] name = "ptask-server" -version = "3.51.6" +version = "3.51.7" dependencies = [ "anyhow", "axum", @@ -2842,7 +2842,7 @@ dependencies = [ [[package]] name = "ptask-tui" -version = "3.51.6" +version = "3.51.7" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 04d121cd..9bfef82a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.51.6" +version = "3.51.7" edition = "2024" license-file = "LICENSE" authors = ["PureTensor, Inc. "] @@ -52,12 +52,12 @@ rmcp = { version = "3.3.0", features = ["server", "macros", "schemars", "transpo futures-util = { version = "0.3", default-features = false } # Internal crates -ptask-core = { path = "crates/ptask-core", version = "3.51.6" } -ptask-tui = { path = "crates/ptask-tui", version = "3.51.6" } -ptask-server = { path = "crates/ptask-server", version = "3.51.6" } -ptask-bot = { path = "crates/ptask-bot", version = "3.51.6" } -ptask-distill = { path = "crates/ptask-distill", version = "3.51.6" } -ptask-notify = { path = "crates/ptask-notify", version = "3.51.6" } +ptask-core = { path = "crates/ptask-core", version = "3.51.7" } +ptask-tui = { path = "crates/ptask-tui", version = "3.51.7" } +ptask-server = { path = "crates/ptask-server", version = "3.51.7" } +ptask-bot = { path = "crates/ptask-bot", version = "3.51.7" } +ptask-distill = { path = "crates/ptask-distill", version = "3.51.7" } +ptask-notify = { path = "crates/ptask-notify", version = "3.51.7" } # HMAC for outbound webhook signing (v0.3.5). sha2 = "0.10" diff --git a/docs/gen/pt.1 b/docs/gen/pt.1 index 54e63dac..1d459597 100644 --- a/docs/gen/pt.1 +++ b/docs/gen/pt.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH pt 1 "pt 3.51.6" +.TH pt 1 "pt 3.51.7" .SH NAME pt \- Sovereign task manager for PureTensor .SH SYNOPSIS @@ -208,4 +208,4 @@ Generate shell completions (bash/zsh/fish) to stdout pt\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION -v3.51.6 +v3.51.7