generated from eblume/project-template
feat(core): task.set_schedule — reschedule do-date/late-on/recurrence
Some checks failed
Build / validate (pull_request) Has been cancelled
Some checks failed
Build / validate (pull_request) Has been cancelled
There was no way to change a task's do-date, late-on, or recurrence after creation (only attention/state had setters) — a real reschedule gap. Add a single patch method covering the three schedule scalars with no setter. - model: SchedulePatch with double-option fields (absent=leave, null=clear, value=set), serde-skips absent fields so the distinction round-trips - Store::set_task_schedule + LocalStore/RemoteStore impls; sqlite set_schedule overlays present fields then records the LWW task.set op (sync-correct) - rpc dispatch: task.set_schedule (id + flattened patch) - tests: core set/clear/leave + missing-task; rpc_socket round-trip asserting the absent/null/value semantics over the wire Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0aa7e725a5
commit
70d5af5bdc
9 changed files with 213 additions and 10 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -248,6 +248,56 @@ pub struct NewTask {
|
|||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Option<i64>>,
|
||||
/// 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<Option<i64>>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
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<Option<Option<T>>, 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)]
|
||||
|
|
|
|||
|
|
@ -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<Task> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Task> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Task>;
|
||||
|
||||
/// 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<Task>;
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Task> {
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<Va
|
|||
let p: SetAttentionParams = parse(params)?;
|
||||
json!(store.set_task_attention(&p.id, p.attention)?)
|
||||
}
|
||||
"task.set_schedule" => {
|
||||
// `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)?)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue