diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 72bb5bc..acf7fe7 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -28,7 +28,7 @@ pub use extract::{extract, ContextItem, Extraction}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, - NodeKind, SyncCursors, Task, TaskState, + NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; pub use oplog::Op; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index f6d7a5e..423c96b 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -248,6 +248,56 @@ pub struct NewTask { pub project_id: Option, } +/// A partial update to a task's schedule scalars (tech-spec §6 `task.set_schedule`). +/// +/// Each field is a **double option** so the three states are distinct: absent +/// (`None`) = leave unchanged; present-`null` (`Some(None)`) = clear; present +/// value (`Some(Some(v))`) = set. Attention has its own setter; project edits +/// are link add/remove — this struct covers the scalars with no setter today +/// (the "reschedule" gap). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SchedulePatch { + /// Earliest-actionable date, epoch ms (candidacy gate only, §7). + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "double_option" + )] + pub do_date: Option>, + /// Lateness-problem marker, epoch ms (the sole urgency signal, §7). + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "double_option" + )] + pub late_on: Option>, + /// RRULE for a recurring definition (§4.4); clear to make non-recurring. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "double_option" + )] + pub recurrence: Option>, +} + +impl SchedulePatch { + /// True when the patch touches nothing (every field absent). + pub fn is_empty(&self) -> bool { + self.do_date.is_none() && self.late_on.is_none() && self.recurrence.is_none() + } +} + +/// Deserialize a present field (even an explicit `null`) into `Some(...)`, so a +/// missing key (via `#[serde(default)]`) stays `None`. This is what makes the +/// [`SchedulePatch`] double-option distinguish "absent" from "null". +fn double_option<'de, T, D>(deserializer: D) -> std::result::Result>, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + /// Working-set health — the §6.2 tensions, surfaced honestly (tech-spec §7). /// Never masks overload nor manufactures calm. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 9c88975..262ebb6 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -30,8 +30,8 @@ use crate::clock::Clock; use crate::error::{Error, Result}; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task, - TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -231,6 +231,11 @@ impl Store for LocalStore { tasks::set_attention(&self.conn, &self.owner_id, now, node_id, attention) } + fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result { + let now = self.clock.now_ms(); + tasks::set_schedule(&self.conn, &self.owner_id, now, node_id, patch) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 351e0cb..e108c94 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -10,7 +10,9 @@ use serde_json::json; use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; use crate::extract; -use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; +use crate::model::{ + Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, +}; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; use crate::recurrence; @@ -496,3 +498,32 @@ pub(super) fn set_attention( record_set(conn, owner, now, node_id)?; require(conn, node_id) } + +/// Apply a partial schedule update (do-date / late-on / recurrence) — the +/// "reschedule" path (tech-spec §6). Reads the current row, overlays the +/// present `patch` fields (a double-option per field: absent = leave, `null` = +/// clear, value = set), writes all three columns, and records the LWW op. +pub(super) fn set_schedule( + conn: &Connection, + owner: &str, + now: i64, + node_id: &str, + patch: SchedulePatch, +) -> Result { + let mut task = require(conn, node_id)?; + if let Some(v) = patch.do_date { + task.do_date = v; + } + if let Some(v) = patch.late_on { + task.late_on = v; + } + if let Some(v) = patch.recurrence { + task.recurrence = v; + } + conn.execute( + "UPDATE tasks SET do_date = ?1, late_on = ?2, recurrence = ?3 WHERE node_id = ?4", + (&task.do_date, &task.late_on, &task.recurrence, node_id), + )?; + record_set(conn, owner, now, node_id)?; + require(conn, node_id) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 5113b61..ddbeb1a 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -6,8 +6,8 @@ use crate::error::Result; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task, - TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -71,6 +71,12 @@ pub trait Store { /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; + /// Apply a partial update to a task's schedule scalars — do-date, late-on, + /// recurrence (tech-spec §6 `task.set_schedule`). Each [`SchedulePatch`] + /// field is a double option: absent = unchanged, `null` = clear, value = + /// set. This is the "reschedule" path (the scalars with no dedicated setter). + fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a /// committed task, rewriting that source line into a `[[link]]` to the new /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs index e80a2e4..c089311 100644 --- a/crates/heph-core/tests/tasks_and_links.rs +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -2,13 +2,80 @@ //! wiki-link materialization (tech-spec §4–§6, slice 3). use heph_core::{ - Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store, TaskState, + Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, SchedulePatch, Store, + TaskState, }; fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() } +#[test] +fn set_schedule_sets_clears_and_leaves_fields_per_double_option() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Renew passport".into(), + do_date: Some(1_000), + late_on: Some(2_000), + recurrence: Some("FREQ=YEARLY".into()), + ..Default::default() + }) + .unwrap(); + let id = task.node_id; + + // Set do_date, clear recurrence, leave late_on untouched (absent). + let updated = s + .set_task_schedule( + &id, + SchedulePatch { + do_date: Some(Some(5_000)), + recurrence: Some(None), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.do_date, Some(5_000), "do_date set"); + assert_eq!(updated.late_on, Some(2_000), "late_on left unchanged"); + assert_eq!(updated.recurrence, None, "recurrence cleared"); + + // An empty patch is a no-op (leaves everything). + let same = s.set_task_schedule(&id, SchedulePatch::default()).unwrap(); + assert_eq!(same.do_date, Some(5_000)); + assert_eq!(same.late_on, Some(2_000)); + + // Clearing do_date makes the task always-actionable again. + let cleared = s + .set_task_schedule( + &id, + SchedulePatch { + do_date: Some(None), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.do_date, None); + + // Adding a recurrence back makes it a recurring definition. + let recurring = s + .set_task_schedule( + &id, + SchedulePatch { + recurrence: Some(Some("FREQ=WEEKLY".into())), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(recurring.recurrence.as_deref(), Some("FREQ=WEEKLY")); +} + +#[test] +fn set_schedule_on_a_missing_task_is_not_found() { + let mut s = store(); + let err = s.set_task_schedule("nope", SchedulePatch::default()); + assert!(err.is_err(), "expected NodeNotFound, got {err:?}"); +} + #[test] fn promote_mints_a_task_and_rewrites_the_line_into_a_link() { let mut s = store(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 8445292..f4b8248 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -17,8 +17,8 @@ use serde::de::DeserializeOwned; use serde_json::{json, Value}; use heph_core::{ - Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, Store, - SyncCursors, Task, TaskState, + Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, + SchedulePatch, Store, SyncCursors, Task, TaskState, }; use crate::oauth::{self, TokenStore}; @@ -160,6 +160,13 @@ impl Store for RemoteStore { ) } + fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result { + // Serialize the patch (absent fields are skipped), then inject `id`. + let mut params = serde_json::to_value(&patch).expect("SchedulePatch serializes"); + params["id"] = json!(node_id); + self.call_as("task.set_schedule", params) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index b4df8a8..7c2bcad 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, LinkType, NewNode, NewTask, Store, TaskState}; +use heph_core::{Attention, LinkType, NewNode, NewTask, SchedulePatch, Store, TaskState}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -261,6 +261,13 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + // `id` + the flattened `SchedulePatch` arrive in one object; parse + // the id, then the patch (which ignores the extra `id` key). + let id: IdParam = parse(params.clone())?; + let patch: SchedulePatch = parse(params)?; + json!(store.set_task_schedule(&id.id, patch)?) + } "task.skip" => { let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 7da9264..62cbb27 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -134,6 +134,36 @@ fn task_create_appears_in_next_with_context_link() { assert_eq!(doc["kind"], "doc"); } +#[test] +fn task_set_schedule_patches_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let task = c + .call( + "task.create", + json!({ "title": "Renew passport", "do_date": 1000, "late_on": 2000, "recurrence": "FREQ=YEARLY" }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + // Present value sets, explicit null clears, absent field is left alone. + let updated = c + .call( + "task.set_schedule", + json!({ "id": id, "do_date": 5000, "recurrence": null }), + ) + .unwrap(); + assert_eq!(updated["do_date"], 5000, "do_date set"); + assert_eq!(updated["late_on"], 2000, "late_on untouched (absent)"); + assert!(updated["recurrence"].is_null(), "recurrence cleared"); + + // The change is durable. + let got = c.call("task.get", json!({ "id": id })).unwrap(); + assert_eq!(got["do_date"], 5000); + assert!(got["recurrence"].is_null()); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon();