generated from eblume/project-template
heph-core: recurrence (roll-forward in place) + per-task logs
Some checks failed
Build / validate (pull_request) Failing after 4s
Some checks failed
Build / validate (pull_request) Failing after 4s
Slice 5 (tech-spec §4.4). Completing a recurring task rolls it forward in place instead of marking it done — the Todoist-corner-avoiding model. Pure recurrence module: - next_occurrence(rrule, anchor, after): lazy RRULE expansion (rrule + chrono/UTC) returning the next instance strictly after `after`, skipping missed occurrences; None when a finite series is exhausted. - reset_checkboxes(body): the fresh-checklist transform — unchecks every `- [x]`, idempotent, preserves indentation/bullet/line-endings. Storage roll-forward (one transaction, on set_state(done) of a recurring task): reset the canonical context doc's checklist, append the completed occurrence to the task's log, advance do_date to the next instance after now (skipping misses); finite series finally goes done. `skip` advances the same way without logging. Non-recurring done is unchanged. Per-task append-only log (`log-of` doc): log_append / log_tail — the resumption breadcrumb + recurring-completion narrative ([[design]] §6.4). Tests: 7 recurrence unit + 2 proptests (no checked marker survives reset; reset idempotent for any body) + 6 end-to-end incl. five-occurrence no-carry-forward and missed-collapse-to-one. 53 tests green. This completes the heph-core library layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f63f926d0
commit
d0debfceb9
12 changed files with 878 additions and 16 deletions
289
Cargo.lock
generated
289
Cargo.lock
generated
|
|
@ -14,6 +14,24 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
|
|
@ -63,6 +81,47 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz-build"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -161,13 +220,39 @@ dependencies = [
|
|||
name = "heph-core"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"proptest",
|
||||
"pulldown-cmark",
|
||||
"rrule",
|
||||
"rusqlite",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"ulid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
|
|
@ -180,6 +265,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
|
|
@ -203,6 +294,12 @@ version = "0.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
|
|
@ -224,6 +321,53 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parse-zoneinfo"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -264,7 +408,7 @@ dependencies = [
|
|||
"bit-vec",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
|
|
@ -305,6 +449,15 @@ version = "5.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
|
|
@ -312,7 +465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -322,9 +475,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
|
|
@ -340,7 +499,30 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -349,6 +531,20 @@ version = "0.8.10"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rrule"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cff1ca93145ff07cdc878b5f6bb90391a299cc8712538af0ad73ebf37613e46a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"regex",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
|
|
@ -400,6 +596,12 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -436,13 +638,33 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -462,7 +684,7 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
|
||||
dependencies = [
|
||||
"rand",
|
||||
"rand 0.9.4",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
|
@ -569,12 +791,65 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ ulid = "1"
|
|||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
rrule = "0.13"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ rusqlite.workspace = true
|
|||
ulid.workspace = true
|
||||
thiserror.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
rrule.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod error;
|
|||
pub mod extract;
|
||||
pub mod model;
|
||||
pub mod ranking;
|
||||
pub mod recurrence;
|
||||
pub mod sqlite;
|
||||
pub mod store;
|
||||
|
||||
|
|
@ -21,5 +22,6 @@ pub use error::{Error, Result};
|
|||
pub use extract::{extract, ContextItem, Extraction};
|
||||
pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState};
|
||||
pub use ranking::{rank, Dimension, RankedTask, RANKING};
|
||||
pub use recurrence::{next_occurrence, reset_checkboxes};
|
||||
pub use sqlite::LocalStore;
|
||||
pub use store::Store;
|
||||
|
|
|
|||
185
crates/heph-core/src/recurrence.rs
Normal file
185
crates/heph-core/src/recurrence.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! Recurrence — roll-forward in place (tech-spec §4.4, [[design]] §3.3).
|
||||
//!
|
||||
//! A recurring task is a **single node** carrying an RFC-5545 RRULE. On
|
||||
//! completing an occurrence it (1) resets its checklist to all-unchecked,
|
||||
//! (2) logs the occurrence, and (3) advances its do-date to the **next RRULE
|
||||
//! instance strictly after now**, *skipping* missed occurrences. So a missed
|
||||
//! daily routine is one gently-overdue item, never a pile, and **completion
|
||||
//! never carries forward**.
|
||||
//!
|
||||
//! This module holds the two pure pieces: [`next_occurrence`] (lazy RRULE
|
||||
//! expansion — only the next instance is ever computed) and
|
||||
//! [`reset_checkboxes`] (the fresh-checklist transform).
|
||||
|
||||
use chrono::TimeZone;
|
||||
use rrule::{RRule, Tz, Unvalidated};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Hard cap on how many series instances we'll skip past while searching for
|
||||
/// the next one after `now`. Protects against pathological iteration; a normal
|
||||
/// missed-routine gap is a handful of instances.
|
||||
const MAX_SKIP: usize = 100_000;
|
||||
|
||||
/// The next RRULE instance **strictly after** `after_ms`, in epoch ms, anchored
|
||||
/// at `anchor_ms` (the series DTSTART — in practice the task's current do-date,
|
||||
/// which is itself a series instance). `None` if the series is exhausted
|
||||
/// (a finite rule with `COUNT`/`UNTIL`).
|
||||
///
|
||||
/// Lazy: it walks instances from the anchor and returns the first past `after`,
|
||||
/// never materializing the series.
|
||||
pub fn next_occurrence(rrule: &str, anchor_ms: i64, after_ms: i64) -> Result<Option<i64>> {
|
||||
let anchor = to_dt(anchor_ms)?;
|
||||
let after = to_dt(after_ms)?;
|
||||
|
||||
let rule: RRule<Unvalidated> = rrule
|
||||
.parse()
|
||||
.map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?;
|
||||
let set = rule
|
||||
.build(anchor)
|
||||
.map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?;
|
||||
|
||||
for (i, occ) in set.into_iter().enumerate() {
|
||||
if i >= MAX_SKIP {
|
||||
return Err(Error::Integrity(format!(
|
||||
"recurrence search for {rrule:?} exceeded {MAX_SKIP} instances"
|
||||
)));
|
||||
}
|
||||
if occ.timestamp_millis() > after_ms && occ > after {
|
||||
return Ok(Some(occ.timestamp_millis()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn to_dt(ms: i64) -> Result<chrono::DateTime<Tz>> {
|
||||
Tz::UTC
|
||||
.timestamp_millis_opt(ms)
|
||||
.single()
|
||||
.ok_or_else(|| Error::Integrity(format!("invalid timestamp {ms}")))
|
||||
}
|
||||
|
||||
/// Return `body` with every checked GFM task marker (`- [x]` / `- [X]`) reset to
|
||||
/// unchecked (`- [ ]`). Idempotent; preserves indentation, bullet style
|
||||
/// (`-`/`*`/`+`), and line endings. Unchecked items are left untouched.
|
||||
pub fn reset_checkboxes(body: &str) -> String {
|
||||
body.split_inclusive('\n').map(uncheck_line).collect()
|
||||
}
|
||||
|
||||
fn uncheck_line(line: &str) -> String {
|
||||
let indent_len = line.len() - line.trim_start().len();
|
||||
let (indent, rest) = line.split_at(indent_len);
|
||||
let b = rest.as_bytes();
|
||||
let is_checked_item = b.len() >= 5
|
||||
&& matches!(b[0], b'-' | b'*' | b'+')
|
||||
&& b[1] == b' '
|
||||
&& b[2] == b'['
|
||||
&& matches!(b[3], b'x' | b'X')
|
||||
&& b[4] == b']';
|
||||
if is_checked_item {
|
||||
let bullet = &rest[0..1];
|
||||
let tail = &rest[5..]; // everything after "]" (incl. any trailing '\n')
|
||||
format!("{indent}{bullet} [ ]{tail}")
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
// 2024-01-01T00:00:00Z and friends, in epoch ms.
|
||||
const JAN1: i64 = 1_704_067_200_000;
|
||||
const ONE_DAY: i64 = 86_400_000;
|
||||
|
||||
#[test]
|
||||
fn daily_advances_one_day_when_completed_on_time() {
|
||||
// anchor = Jan 1; completing at Jan 1 noon → next instance Jan 2.
|
||||
let next = next_occurrence("FREQ=DAILY", JAN1, JAN1 + ONE_DAY / 2)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next, JAN1 + ONE_DAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_skips_missed_occurrences() {
|
||||
// anchor Jan 1, but we don't complete until 3.5 days later → next is
|
||||
// Jan 5 (one item, not a pile of four).
|
||||
let now = JAN1 + 3 * ONE_DAY + ONE_DAY / 2;
|
||||
let next = next_occurrence("FREQ=DAILY", JAN1, now).unwrap().unwrap();
|
||||
assert_eq!(next, JAN1 + 4 * ONE_DAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_advances_a_week() {
|
||||
let next = next_occurrence("FREQ=WEEKLY", JAN1, JAN1 + ONE_DAY)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next, JAN1 + 7 * ONE_DAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_rule_can_be_exhausted() {
|
||||
// COUNT=2 from the anchor → instances at Jan 1 and Jan 2; after Jan 2
|
||||
// there is no next.
|
||||
let after_last = JAN1 + 5 * ONE_DAY;
|
||||
assert_eq!(
|
||||
next_occurrence("FREQ=DAILY;COUNT=2", JAN1, after_last).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_rrule_is_an_error() {
|
||||
assert!(next_occurrence("FREQ=NONSENSE", JAN1, JAN1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_unchecks_all_checked_items() {
|
||||
let body = "- [x] a\n- [ ] b\n* [X] c\n + [x] nested\n";
|
||||
let expected = "- [ ] a\n- [ ] b\n* [ ] c\n + [ ] nested\n";
|
||||
assert_eq!(reset_checkboxes(body), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_is_idempotent() {
|
||||
let body = "- [x] a\n- [ ] b\n";
|
||||
let once = reset_checkboxes(body);
|
||||
assert_eq!(reset_checkboxes(&once), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_leaves_non_checkbox_text_alone() {
|
||||
let body = "# Heading\n\nSome [x] not a checkbox\n- regular item\n";
|
||||
assert_eq!(reset_checkboxes(body), body);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// The fresh-checklist invariant: after a reset, no checked task marker
|
||||
/// remains — completion can never carry forward (tech-spec §9).
|
||||
#[test]
|
||||
fn reset_leaves_no_checked_markers(body in ".{0,200}") {
|
||||
let out = reset_checkboxes(&body);
|
||||
for line in out.lines() {
|
||||
let t = line.trim_start();
|
||||
let bytes = t.as_bytes();
|
||||
let checked = bytes.len() >= 5
|
||||
&& matches!(bytes[0], b'-' | b'*' | b'+')
|
||||
&& bytes[1] == b' '
|
||||
&& bytes[2] == b'['
|
||||
&& matches!(bytes[3], b'x' | b'X')
|
||||
&& bytes[4] == b']';
|
||||
prop_assert!(!checked, "checked marker survived reset: {line:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset is idempotent for any input.
|
||||
#[test]
|
||||
fn reset_idempotent_for_any_body(body in ".{0,200}") {
|
||||
let once = reset_checkboxes(&body);
|
||||
prop_assert_eq!(reset_checkboxes(&once), once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,25 @@ pub(super) fn add(
|
|||
Ok(link)
|
||||
}
|
||||
|
||||
/// The destination of the first non-tombstoned link of `link_type` out of
|
||||
/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc).
|
||||
pub(super) fn first_dst(
|
||||
conn: &Connection,
|
||||
src_id: &str,
|
||||
link_type: LinkType,
|
||||
) -> Result<Option<String>> {
|
||||
let dst = conn
|
||||
.query_row(
|
||||
"SELECT dst_id FROM links
|
||||
WHERE src_id = ?1 AND type = ?2 AND tombstoned = 0
|
||||
ORDER BY created_at, id LIMIT 1",
|
||||
(src_id, link_type.as_str()),
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
/// All non-tombstoned links originating at `id`.
|
||||
pub(super) fn outgoing(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
||||
query(conn, "src_id", id)
|
||||
|
|
|
|||
83
crates/heph-core/src/sqlite/log.rs
Normal file
83
crates/heph-core/src/sqlite/log.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Per-task append-only log ([[design]] §6.4).
|
||||
//!
|
||||
//! A task's log is a `doc` node linked from the task by `log-of`. Entries are
|
||||
//! appended as lines; the log is the resumption breadcrumb store ([[design]]
|
||||
//! §6.1) and the narrative history of recurring completions (§4.4).
|
||||
//!
|
||||
//! These take `&Connection` so they compose inside a caller's transaction
|
||||
//! (the recurrence roll-forward appends a completion entry within its tx).
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{hlc_for, links, nodes};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{LinkType, NodeKind};
|
||||
|
||||
/// The task's log doc id, creating (and linking) it on first use.
|
||||
pub(super) fn ensure_doc(
|
||||
conn: &Connection,
|
||||
owner: &str,
|
||||
now: i64,
|
||||
task_id: &str,
|
||||
) -> Result<String> {
|
||||
if let Some(id) = links::first_dst(conn, task_id, LinkType::LogOf)? {
|
||||
return Ok(id);
|
||||
}
|
||||
let task =
|
||||
nodes::get(conn, task_id)?.ok_or_else(|| Error::NodeNotFound(task_id.to_string()))?;
|
||||
let doc = nodes::build(
|
||||
owner,
|
||||
now,
|
||||
NodeKind::Doc,
|
||||
format!("{} — log", task.title),
|
||||
Some(String::new()),
|
||||
);
|
||||
nodes::insert(conn, &doc)?;
|
||||
links::add(conn, now, task_id, &doc.id, LinkType::LogOf)?;
|
||||
Ok(doc.id)
|
||||
}
|
||||
|
||||
/// Append `text` as a new entry (one line) to the task's log.
|
||||
pub(super) fn append(
|
||||
conn: &Connection,
|
||||
owner: &str,
|
||||
now: i64,
|
||||
task_id: &str,
|
||||
text: &str,
|
||||
) -> Result<()> {
|
||||
let doc_id = ensure_doc(conn, owner, now, task_id)?;
|
||||
let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?;
|
||||
let new_body = append_line(doc.body.as_deref().unwrap_or(""), text);
|
||||
conn.execute(
|
||||
"UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
|
||||
(&new_body, now, hlc_for(now), &doc_id),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The task's latest `n` log entries (oldest→newest), empty if it has no log.
|
||||
pub(super) fn tail(conn: &Connection, task_id: &str, n: usize) -> Result<Vec<String>> {
|
||||
let Some(doc_id) = links::first_dst(conn, task_id, LinkType::LogOf)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?;
|
||||
let body = doc.body.unwrap_or_default();
|
||||
let entries: Vec<String> = body
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let start = entries.len().saturating_sub(n);
|
||||
Ok(entries[start..].to_vec())
|
||||
}
|
||||
|
||||
/// Append a single entry line to a log body, ensuring separation.
|
||||
fn append_line(body: &str, text: &str) -> String {
|
||||
let mut s = body.to_string();
|
||||
if !s.is_empty() && !s.ends_with('\n') {
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str(text);
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
//! delegating layer so a transaction can span several of them.
|
||||
|
||||
mod links;
|
||||
mod log;
|
||||
mod migrations;
|
||||
mod nodes;
|
||||
mod tasks;
|
||||
|
|
@ -129,7 +130,12 @@ impl Store for LocalStore {
|
|||
|
||||
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task> {
|
||||
let now = self.clock.now_ms();
|
||||
tasks::set_state(&self.conn, now, node_id, state)
|
||||
tasks::set_state(&mut self.conn, &self.owner_id, now, node_id, state)
|
||||
}
|
||||
|
||||
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task> {
|
||||
let now = self.clock.now_ms();
|
||||
tasks::skip(&self.conn, now, node_id)
|
||||
}
|
||||
|
||||
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task> {
|
||||
|
|
@ -154,6 +160,18 @@ impl Store for LocalStore {
|
|||
fn backlinks(&self, id: &str) -> Result<Vec<Link>> {
|
||||
links::backlinks(&self.conn, id)
|
||||
}
|
||||
|
||||
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> {
|
||||
let now = self.clock.now_ms();
|
||||
let tx = self.conn.transaction()?;
|
||||
log::append(&tx, &self.owner_id, now, task_id, text)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>> {
|
||||
log::tail(&self.conn, task_id, n)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
|
||||
use rusqlite::{Connection, OptionalExtension, Row};
|
||||
|
||||
use super::{links, nodes};
|
||||
use super::{hlc_for, links, log, nodes};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState};
|
||||
use crate::ranking::{self, RankedTask};
|
||||
use crate::recurrence;
|
||||
|
||||
fn from_row(row: &Row) -> rusqlite::Result<Task> {
|
||||
let attention = match row.get::<_, Option<String>>("attention")? {
|
||||
|
|
@ -100,20 +101,104 @@ fn require(conn: &Connection, node_id: &str) -> Result<Task> {
|
|||
get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string()))
|
||||
}
|
||||
|
||||
/// Set a task's lifecycle state.
|
||||
/// Set a task's lifecycle state. Completing a **recurring** task rolls it
|
||||
/// forward in place (tech-spec §4.4) rather than marking it done.
|
||||
pub(super) fn set_state(
|
||||
conn: &Connection,
|
||||
conn: &mut Connection,
|
||||
owner: &str,
|
||||
now: i64,
|
||||
node_id: &str,
|
||||
state: TaskState,
|
||||
) -> Result<Task> {
|
||||
let updated = conn.execute(
|
||||
let task = require(conn, node_id)?;
|
||||
if state == TaskState::Done && task.recurrence.is_some() {
|
||||
return roll_forward(conn, owner, now, &task);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE tasks SET state = ?1 WHERE node_id = ?2",
|
||||
(state.as_str(), node_id),
|
||||
)?;
|
||||
if updated == 0 {
|
||||
return Err(Error::NodeNotFound(node_id.to_string()));
|
||||
nodes::touch(conn, now, node_id)?;
|
||||
require(conn, node_id)
|
||||
}
|
||||
|
||||
/// Roll a recurring task forward on completion (tech-spec §4.4): reset its
|
||||
/// checklist to all-unchecked, log the occurrence, and advance the do-date to
|
||||
/// the next RRULE instance strictly after `now` (skipping misses) — all in one
|
||||
/// transaction. If the series is exhausted, the task is finally marked done.
|
||||
fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Result<Task> {
|
||||
let rrule = task
|
||||
.recurrence
|
||||
.as_deref()
|
||||
.expect("roll_forward called on a recurring task");
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
// 1. Fresh checklist — reset the canonical context doc's checkboxes.
|
||||
if let Some(doc_id) = links::first_dst(&tx, &task.node_id, LinkType::CanonicalContext)? {
|
||||
if let Some(doc) = nodes::get(&tx, &doc_id)? {
|
||||
let body = doc.body.unwrap_or_default();
|
||||
let reset = recurrence::reset_checkboxes(&body);
|
||||
if reset != body {
|
||||
tx.execute(
|
||||
"UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
|
||||
(&reset, now, hlc_for(now), &doc_id),
|
||||
)?;
|
||||
links::sync_wiki_links(&tx, owner, &doc_id, &reset, now)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Narrative history — append the completed occurrence to the log.
|
||||
let entry = match task.do_date {
|
||||
Some(d) => format!("- completed occurrence (do-date {d})"),
|
||||
None => "- completed occurrence".to_string(),
|
||||
};
|
||||
log::append(&tx, owner, now, &task.node_id, &entry)?;
|
||||
|
||||
// 3. Advance the do-date (or finally finish a finite series).
|
||||
advance(&tx, now, &task.node_id, rrule, task.do_date)?;
|
||||
nodes::touch(&tx, now, &task.node_id)?;
|
||||
|
||||
tx.commit()?;
|
||||
require(conn, &task.node_id)
|
||||
}
|
||||
|
||||
/// Advance a recurring task to its next instance after `now`, or mark it `done`
|
||||
/// if the series is exhausted. Shared by completion roll-forward and `skip`.
|
||||
fn advance(
|
||||
conn: &Connection,
|
||||
now: i64,
|
||||
node_id: &str,
|
||||
rrule: &str,
|
||||
do_date: Option<i64>,
|
||||
) -> Result<()> {
|
||||
let anchor = do_date.unwrap_or(now);
|
||||
match recurrence::next_occurrence(rrule, anchor, now)? {
|
||||
Some(next) => {
|
||||
conn.execute(
|
||||
"UPDATE tasks SET do_date = ?1, state = 'outstanding' WHERE node_id = ?2",
|
||||
(next, node_id),
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"UPDATE tasks SET state = 'done' WHERE node_id = ?1",
|
||||
[node_id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Skip the current occurrence of a recurring task: advance the do-date the same
|
||||
/// way as completion but **without** logging a completion (tech-spec §4.4).
|
||||
pub(super) fn skip(conn: &Connection, now: i64, node_id: &str) -> Result<Task> {
|
||||
let task = require(conn, node_id)?;
|
||||
let rrule = task
|
||||
.recurrence
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Integrity(format!("skip on non-recurring task {node_id}")))?;
|
||||
advance(conn, now, node_id, rrule, task.do_date)?;
|
||||
nodes::touch(conn, now, node_id)?;
|
||||
require(conn, node_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,16 @@ pub trait Store {
|
|||
/// Fetch a task by its node id.
|
||||
fn get_task(&self, node_id: &str) -> Result<Option<Task>>;
|
||||
|
||||
/// Set a task's lifecycle state. (Recurrence roll-forward is layered on in
|
||||
/// a later slice — tech-spec §4.4.)
|
||||
/// Set a task's lifecycle state. Completing a **recurring** task rolls it
|
||||
/// forward in place — fresh checklist, logged occurrence, advanced do-date
|
||||
/// (tech-spec §4.4) — rather than marking it done.
|
||||
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task>;
|
||||
|
||||
/// Skip the current occurrence of a recurring task: advance its do-date
|
||||
/// without logging a completion (tech-spec §4.4). Errors on a non-recurring
|
||||
/// task.
|
||||
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task>;
|
||||
|
||||
/// Set a task's attention-state.
|
||||
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
|
||||
|
||||
|
|
@ -64,4 +70,13 @@ pub trait Store {
|
|||
|
||||
/// All non-tombstoned links pointing at `id` (backlinks).
|
||||
fn backlinks(&self, id: &str) -> Result<Vec<Link>>;
|
||||
|
||||
// --- per-task log ([[design]] §6.4) ---
|
||||
|
||||
/// Append a line to a task's append-only log (creating the log on first
|
||||
/// use). The log is the resumption breadcrumb store.
|
||||
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()>;
|
||||
|
||||
/// The task's latest `n` log entries (oldest→newest); empty if it has none.
|
||||
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>>;
|
||||
}
|
||||
|
|
|
|||
175
crates/heph-core/tests/recurrence.rs
Normal file
175
crates/heph-core/tests/recurrence.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! End-to-end recurrence: completing a recurring task rolls it forward in
|
||||
//! place — fresh checklist, logged occurrence, advanced do-date, never
|
||||
//! carrying completion forward (tech-spec §4.4, slice 5).
|
||||
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use heph_core::{Clock, LinkType, LocalStore, NewTask, Store, TaskState};
|
||||
|
||||
const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
|
||||
const ONE_DAY: i64 = 86_400_000;
|
||||
|
||||
/// A clock the test can advance between completions. Cloneable so the test
|
||||
/// keeps a handle while the store owns its own `Box<dyn Clock>`.
|
||||
#[derive(Clone)]
|
||||
struct StepClock(Arc<AtomicI64>);
|
||||
|
||||
impl StepClock {
|
||||
fn new(ms: i64) -> Self {
|
||||
StepClock(Arc::new(AtomicI64::new(ms)))
|
||||
}
|
||||
fn set(&self, ms: i64) {
|
||||
self.0.store(ms, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for StepClock {
|
||||
fn now_ms(&self) -> i64 {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
fn store_at(ms: i64) -> (LocalStore, StepClock) {
|
||||
let clock = StepClock::new(ms);
|
||||
let store = LocalStore::open_in_memory(Box::new(clock.clone())).unwrap();
|
||||
(store, clock)
|
||||
}
|
||||
|
||||
fn canonical_doc_id(s: &LocalStore, task_id: &str) -> String {
|
||||
s.outgoing_links(task_id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|l| l.link_type == LinkType::CanonicalContext)
|
||||
.unwrap()
|
||||
.dst_id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_a_recurring_task_rolls_forward_with_a_fresh_checklist() {
|
||||
let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2);
|
||||
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "Morning routine".into(),
|
||||
do_date: Some(JAN1),
|
||||
recurrence: Some("FREQ=DAILY".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let doc_id = canonical_doc_id(&s, &task.node_id);
|
||||
|
||||
// Put a checklist in the context doc and check items off.
|
||||
s.update_node(
|
||||
&doc_id,
|
||||
None,
|
||||
Some("- [x] brush teeth\n- [x] feed birds\n- [ ] coffee\n".into()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
||||
|
||||
// It stays outstanding and the do-date advanced to the next day.
|
||||
assert_eq!(rolled.state, TaskState::Outstanding);
|
||||
assert_eq!(rolled.do_date, Some(JAN1 + ONE_DAY));
|
||||
|
||||
// The checklist is fresh — every item unchecked. Completion did NOT carry.
|
||||
let doc = s.get_node(&doc_id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
doc.body.as_deref(),
|
||||
Some("- [ ] brush teeth\n- [ ] feed birds\n- [ ] coffee\n")
|
||||
);
|
||||
|
||||
// The completion was logged (the narrative breadcrumb).
|
||||
let log = s.log_tail(&task.node_id, 10).unwrap();
|
||||
assert_eq!(log.len(), 1);
|
||||
assert!(log[0].contains("completed occurrence"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missed_occurrences_collapse_to_one_not_a_pile() {
|
||||
// Complete 3.5 days late → next instance is day 4, not a backlog of 4.
|
||||
let (mut s, _clock) = store_at(JAN1 + 3 * ONE_DAY + ONE_DAY / 2);
|
||||
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "Water plants".into(),
|
||||
do_date: Some(JAN1),
|
||||
recurrence: Some("FREQ=DAILY".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
||||
assert_eq!(rolled.do_date, Some(JAN1 + 4 * ONE_DAY));
|
||||
assert_eq!(rolled.state, TaskState::Outstanding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_never_carries_forward_across_many_occurrences() {
|
||||
let (mut s, clock) = store_at(JAN1 + ONE_DAY / 2);
|
||||
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "Daily standup notes".into(),
|
||||
do_date: Some(JAN1),
|
||||
recurrence: Some("FREQ=DAILY".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let doc_id = canonical_doc_id(&s, &task.node_id);
|
||||
|
||||
for occurrence in 0..5 {
|
||||
// Check everything off this occurrence.
|
||||
s.update_node(&doc_id, None, Some("- [x] note A\n- [x] note B\n".into()))
|
||||
.unwrap();
|
||||
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
||||
assert_eq!(rolled.state, TaskState::Outstanding);
|
||||
|
||||
// Fresh checklist after every single completion.
|
||||
let doc = s.get_node(&doc_id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
doc.body.as_deref(),
|
||||
Some("- [ ] note A\n- [ ] note B\n"),
|
||||
"occurrence {occurrence} did not present a fresh checklist"
|
||||
);
|
||||
|
||||
// Advance "now" past the new do-date for the next loop.
|
||||
clock.set(rolled.do_date.unwrap() + ONE_DAY / 2);
|
||||
}
|
||||
|
||||
// Five completions → five log entries.
|
||||
assert_eq!(s.log_tail(&task.node_id, 100).unwrap().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_advances_without_logging() {
|
||||
let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2);
|
||||
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "Optional chore".into(),
|
||||
do_date: Some(JAN1),
|
||||
recurrence: Some("FREQ=DAILY".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let skipped = s.skip_recurrence(&task.node_id).unwrap();
|
||||
assert_eq!(skipped.do_date, Some(JAN1 + ONE_DAY));
|
||||
assert_eq!(skipped.state, TaskState::Outstanding);
|
||||
assert!(s.log_tail(&task.node_id, 10).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recurring_task_done_is_just_done() {
|
||||
let (mut s, _clock) = store_at(JAN1);
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "One-off".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
||||
assert_eq!(t.state, TaskState::Done);
|
||||
}
|
||||
|
|
@ -4,4 +4,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
|
|||
- Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks.
|
||||
- Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent).
|
||||
- "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite.
|
||||
- Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging.
|
||||
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue