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
14 changes: 7 additions & 7 deletions Cargo.lock

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

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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. <hal@puretensor.ai>"]
Expand Down Expand Up @@ -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"
Expand Down
137 changes: 131 additions & 6 deletions crates/ptask-bot/src/digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,9 @@ fn render_morning(db: &Db) -> Result<String> {
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()));
Expand Down Expand Up @@ -126,6 +123,34 @@ fn render_evening_at(db: &Db, now: &jiff::Zoned) -> Result<String> {
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::<jiff::civil::Date>().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())?;
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions docs/gen/pt.1
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading