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.30.5"
version = "3.30.6"
edition = "2024"
license-file = "LICENSE"
authors = ["PureTensor, Inc. <[email protected]>"]
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.30.5" }
ptask-tui = { path = "crates/ptask-tui", version = "3.30.5" }
ptask-server = { path = "crates/ptask-server", version = "3.30.5" }
ptask-bot = { path = "crates/ptask-bot", version = "3.30.5" }
ptask-distill = { path = "crates/ptask-distill", version = "3.30.5" }
ptask-notify = { path = "crates/ptask-notify", version = "3.30.5" }
ptask-core = { path = "crates/ptask-core", version = "3.30.6" }
ptask-tui = { path = "crates/ptask-tui", version = "3.30.6" }
ptask-server = { path = "crates/ptask-server", version = "3.30.6" }
ptask-bot = { path = "crates/ptask-bot", version = "3.30.6" }
ptask-distill = { path = "crates/ptask-distill", version = "3.30.6" }
ptask-notify = { path = "crates/ptask-notify", version = "3.30.6" }

# HMAC for outbound webhook signing (v0.3.5).
sha2 = "0.10"
Expand Down
99 changes: 95 additions & 4 deletions crates/ptask-core/src/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,9 +668,9 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result<DoneOutcome> {
}

// Look up the recurrence rule, if any.
let rec_row: Option<(String, String, Option<String>)> = tx
let rec_row: Option<(String, String, Option<String>, String)> = tx
.query_row(
"SELECT r.mode, r.original_input, t.deadline
"SELECT r.mode, r.original_input, t.deadline, t.status_v2
FROM pt_recurrence AS r
JOIN tasks AS t ON t.id = r.task_uuid
WHERE r.task_uuid = ?1",
Expand All @@ -680,6 +680,7 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result<DoneOutcome> {
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, String>(3)?,
))
},
)
Expand All @@ -689,7 +690,14 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result<DoneOutcome> {
other => Err(other),
})?;

if let Some((mode_str, original, current_deadline)) = rec_row {
if let Some((mode_str, original, current_deadline, status)) = rec_row {
// Resolve the current state inside this transaction: callers may
// hold a Task from before another actor dismissed the recurrence.
if matches!(status.as_str(), "done" | "dismissed") {
return Err(crate::Error::Other(
"recurring task is terminal; reopen it before completing an occurrence".into(),
));
}
let rec = crate::recurrence::parse(&original)
.map_err(|e| crate::Error::Other(format!("re-parse recurrence: {}", e)))?;
let completion_now = crate::dates::now_in_operator_tz()?;
Expand Down Expand Up @@ -724,7 +732,8 @@ pub fn mark_done(db: &Db, task: &Task, ctx: &EventCtx) -> Result<DoneOutcome> {
let next_iso = crate::dates::format_iso(&next_z);

tx.execute(
"UPDATE tasks SET deadline=?1, updated_at=?2 WHERE id=?3",
"UPDATE tasks SET deadline=?1, updated_at=?2, status='pending',
status_v2='todo', snoozed_until=NULL WHERE id=?3",
params![next_iso, now, task.id],
)?;
tx.execute(
Expand Down Expand Up @@ -2692,6 +2701,88 @@ mod tests {
.unwrap();
}

#[test]
fn recurring_completion_does_not_resurrect_a_dismissed_task() {
let (_dir, db) = fresh_db();
let ctx = EventCtx::test();
let mut new = NewTask::minimal("cancelled recurring work");
new.deadline = Some("2099-01-01T09:00:00Z".into());
let task = create_with_extensions(
&db,
new,
Extensions {
recurrence: Some(crate::recurrence::parse("every day").unwrap()),
..Default::default()
},
&ctx,
)
.unwrap();
dismiss(&db, &task.id, &ctx).unwrap();
let cursor = crate::event_log::current_cursor(&db).unwrap();
let schedule = load_detail(&db, &task.id).unwrap().recurrence_next;
assert!(
mark_done(&db, &task, &ctx).is_err(),
"stale Task must not reopen recurrence"
);
let after = resolve_for_lookup(&db, &task.id, true).unwrap();
assert_eq!(after.status, "dismissed");
assert_eq!(after.deadline, task.deadline);
assert_eq!(
load_detail(&db, &task.id).unwrap().recurrence_next,
schedule
);
assert_eq!(crate::event_log::current_cursor(&db).unwrap(), cursor);
}

#[test]
fn recurring_completion_releases_prior_claim_and_snooze() {
for rule in ["every day", "every! 5 days"] {
for state in ["claimed", "snoozed"] {
let (_dir, db) = fresh_db();
let ctx = EventCtx::test();
let mut new = NewTask::minimal("recurring work");
new.deadline = Some("2099-01-01T09:00:00Z".into());
let ext = Extensions {
recurrence: Some(crate::recurrence::parse(rule).unwrap()),
..Default::default()
};
let task = create_with_extensions(&db, new, ext, &ctx).unwrap();
if state == "claimed" {
claim(&db, &task.id, &ctx).unwrap();
} else {
snooze(&db, &task.id, "2099-02-01", &ctx).unwrap();
}
let outcome = mark_done(&db, &task, &ctx).unwrap();
let DoneOutcome::Advanced { next_deadline } = outcome else {
panic!("must recur")
};
let next = resolve_for_lookup(&db, &task.id, true).unwrap();
assert_eq!(next.status, "todo", "{rule}, {state}");
assert_eq!(next.pt_id, task.pt_id);
assert_eq!(next.deadline.as_deref(), Some(next_deadline.as_str()));
db.with_conn(|c| {
let (status, snoozed): (String, Option<String>) = c.query_row(
"SELECT status, snoozed_until FROM tasks WHERE id=?1",
[&task.id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
assert_eq!(status, "pending");
assert!(snoozed.is_none());
Ok(())
})
.unwrap();
assert_eq!(
load_detail(&db, &task.id)
.unwrap()
.recurrence_next
.as_deref(),
Some(next_deadline.as_str())
);
claim(&db, &task.id, &ctx).expect("new occurrence must be claimable");
}
}
}

#[test]
fn mark_done_advances_fixed_recurring_from_current_deadline() {
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.30.5"
.TH pt 1 "pt 3.30.6"
.SH NAME
pt \- Sovereign task manager for PureTensor
.SH SYNOPSIS
Expand Down Expand Up @@ -166,4 +166,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.30.5
v3.30.6
29 changes: 17 additions & 12 deletions docs/recurrence.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,39 @@ compiles natural-language input to an RRULE-style string + a mode flag
| `every weekday` | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` | fixed |
| `every monday` | `FREQ=WEEKLY;BYDAY=MO` | fixed |
| `every monday at 9am` | `FREQ=WEEKLY;BYDAY=MO` + time | fixed |
| `every other tuesday` | `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` | fixed |
| `every mon, wed, fri` | `FREQ=WEEKLY;BYDAY=MO,WE,FR` | fixed |
| `every 1, 15, 27` | `FREQ=MONTHLY;BYMONTHDAY=1,15,27` | fixed |
| `every last friday` | `FREQ=MONTHLY;BYDAY=-1FR` | fixed |
| `every 2 months` | `FREQ=MONTHLY;INTERVAL=2` | fixed |
| `every! 5 days` | `FREQ=DAILY;INTERVAL=5` | completion |
| `every! 2 weeks` | `FREQ=WEEKLY;INTERVAL=2` | completion |

Combine with the date parser for the start anchor:
`every weekday starting tomorrow 9am`.
Use a quick-add recurrence phrase, such as `pt add "Standup every weekday at 9am"`.
The parser does not support `every other tuesday`, `every last friday`, or a
`starting ...` suffix.

## Advancement

On `pt done` for a recurring task, the engine clones the row with a
new `deadline` and a fresh PT-N:
On `pt done` for a recurring task, the engine advances the same row to its
next `deadline`, preserving its UUID, PT-N and history:

- `fixed`: compute the next RRULE occurrence from the **original due**.
- `fixed`: advance from the **current deadline**, skipping missed occurrences
until the next deadline is after the completion time.
- `completion`: compute the next RRULE occurrence from **now**.

The completed instance keeps `status='done'`; the clone is `status='pending'`.
The next occurrence is `status_v2='todo'` (legacy `status='pending'`) with
`snoozed_until` cleared. A previous claim ends with completion, so the next
occurrence can be claimed again. A `task.recurrence_advanced` event records
the new deadline; no new task is created.

## Editing

```
pt edit <PT-N> --recurrence 'every weekday' # set / change
pt edit <PT-N> --recurrence none # stop
pt edit <PT-N> --deadline 2099-01-01 # move the next occurrence
```

(`pt edit` lands in the v1.0.x polish phase; until then mutate
`pt_recurrence` via `pt view`-driven SQL or wait for the verb.)
This also updates `pt_recurrence.next_occurrence`. Clearing the deadline on
a recurring task is rejected. There is no `pt edit --recurrence` flag;
create recurrence through quick-add, and dismiss the task to stop working it.

## Storage

Expand Down
Loading