diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index d3c2b15..3315777 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -22,7 +22,10 @@ pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; -pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; +pub use model::{ + deterministic_id, Attention, Health, 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; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 28f8054..728db08 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -248,6 +248,29 @@ pub struct NewTask { pub project_id: Option, } +/// 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)] +pub struct Health { + /// Outstanding `orange` tasks (target ≤ 6). + pub orange_count: usize, + /// Outstanding white+orange+red — the working set (target ≤ ~30). + pub active_count: usize, + /// Outstanding `blue` tasks — on-deck/backlog (target < 100). + pub on_deck_count: usize, + /// Open merge conflicts (0 until sync lands). + pub conflict_count: usize, + /// Sync indicator (`"local"` until sync lands). + pub sync_status: String, +} + +/// Deterministic id for key-unique kinds (`journal`/`tag`) so two offline +/// replicas that independently create the same logical singleton converge +/// (tech-spec §3.1, [[design]] §3.1). Content nodes use random ULIDs instead. +pub fn deterministic_id(owner_id: &str, kind: NodeKind, key: &str) -> String { + format!("{}:{owner_id}:{key}", kind.as_str()) +} + /// Input for creating a node. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewNode { diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index f29b3f0..f21e820 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -25,7 +25,7 @@ use ulid::Ulid; use crate::clock::Clock; use crate::error::Result; -use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::ranking::RankedTask; use crate::store::Store; @@ -154,6 +154,24 @@ impl Store for LocalStore { tasks::next(&self.conn, &self.owner_id, now, scope, limit) } + fn list( + &self, + scope: Option<&str>, + attention: Option, + include_blue: bool, + ) -> Result> { + tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue) + } + + fn health(&self) -> Result { + tasks::health(&self.conn, &self.owner_id) + } + + fn journal_open_or_create(&mut self, date: &str) -> Result { + let now = self.clock.now_ms(); + nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) + } + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { let now = self.clock.now_ms(); links::add(&self.conn, now, src_id, dst_id, link_type) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 04ee6a1..5cfd48e 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; use super::{hlc_for, links, new_id}; use crate::error::{Error, Result}; -use crate::model::{NewNode, Node, NodeKind}; +use crate::model::{deterministic_id, NewNode, Node, NodeKind}; /// The `nodes` columns in a fixed order, shared by every SELECT here. pub(super) const COLUMNS: &str = @@ -74,6 +74,50 @@ pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) - Ok(node) } +/// Open today's (or `date`'s) journal node, creating it if absent. The id is +/// **deterministic** in `(owner, date)` so independent offline creations +/// converge (tech-spec §3.1). `date` must be an ISO `YYYY-MM-DD`. +pub(super) fn open_or_create_journal( + conn: &Connection, + owner: &str, + now: i64, + date: &str, +) -> Result { + if !is_iso_date(date) { + return Err(Error::Integrity(format!( + "journal date must be YYYY-MM-DD, got {date:?}" + ))); + } + let id = deterministic_id(owner, NodeKind::Journal, date); + if let Some(existing) = get(conn, &id)? { + return Ok(existing); + } + let node = Node { + id, + owner_id: owner.to_string(), + kind: NodeKind::Journal, + title: date.to_string(), + body: Some(String::new()), + created_at: now, + modified_at: now, + hlc: hlc_for(now), + tombstoned: false, + }; + insert(conn, &node)?; + Ok(node) +} + +fn is_iso_date(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 10 + && b[4] == b'-' + && b[7] == b'-' + && b.iter().enumerate().all(|(i, c)| match i { + 4 | 7 => *c == b'-', + _ => c.is_ascii_digit(), + }) +} + /// Fetch a node by id (tombstoned rows included). pub(super) fn get(conn: &Connection, id: &str) -> Result> { let node = conn diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index be836bd..dfbdb0c 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -7,7 +7,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; use super::{hlc_for, links, log, nodes}; use crate::error::{Error, Result}; -use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; +use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::ranking::{self, RankedTask}; use crate::recurrence; @@ -215,6 +215,85 @@ pub(super) fn next( Ok(ranking::rank(candidates, now, scope, limit)) } +/// Enumerate outstanding committed tasks for the Organizational view (the whole +/// set incl. backlog, tech-spec §6). Optional `scope` (project) and `attention` +/// filters; `include_blue` keeps on-deck items (default true for `list`). +pub(super) fn list( + conn: &Connection, + owner: &str, + scope: Option<&str>, + attention: Option, + include_blue: bool, +) -> Result> { + let sql = " + SELECT t.node_id, t.attention, t.do_date, t.late_on, t.state, t.recurrence, + (SELECT dst_id FROM links + WHERE src_id = t.node_id AND type = 'in-project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS project_id + FROM tasks t JOIN nodes n ON n.id = t.node_id + WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding' + ORDER BY n.created_at, n.id"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([owner], |row| { + let task = from_row(row)?; + let project: Option = row.get("project_id")?; + Ok((task, project)) + })?; + + let mut out = Vec::new(); + for row in rows { + let (task, project) = row?; + if let Some(s) = scope { + if project.as_deref() != Some(s) { + continue; + } + } + if let Some(a) = attention { + if task.attention != Some(a) { + continue; + } + } + if !include_blue && task.attention == Some(Attention::Blue) { + continue; + } + out.push(task); + } + Ok(out) +} + +/// Working-set health counts (tech-spec §7) — surfaced honestly. +pub(super) fn health(conn: &Connection, owner: &str) -> Result { + let mut stmt = conn.prepare( + "SELECT t.attention FROM tasks t JOIN nodes n ON n.id = t.node_id + WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'", + )?; + let attentions: Vec> = stmt + .query_map([owner], |r| r.get(0))? + .collect::>>()?; + + let mut orange_count = 0; + let mut active_count = 0; + let mut on_deck_count = 0; + for a in attentions.iter().flatten() { + match a.as_str() { + "orange" => { + orange_count += 1; + active_count += 1; + } + "red" | "white" => active_count += 1, + "blue" => on_deck_count += 1, + _ => {} + } + } + Ok(Health { + orange_count, + active_count, + on_deck_count, + conflict_count: 0, + sync_status: "local".to_string(), + }) +} + /// Load every non-tombstoned committed task for `owner` as a ranking candidate, /// joining in its project and canonical-context link targets. fn load_candidates(conn: &Connection, owner: &str) -> Result> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 6b251e2..8d2b2a2 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,7 +5,7 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; -use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::ranking::RankedTask; /// A backend that can store and retrieve nodes, tasks, and links. @@ -63,6 +63,22 @@ pub trait Store { /// node id; `red` items always appear regardless of `limit`. fn next(&self, scope: Option<&str>, limit: usize) -> Result>; + /// Enumerate outstanding committed tasks for the Organizational view — the + /// whole set incl. backlog (tech-spec §6). `include_blue` keeps on-deck. + fn list( + &self, + scope: Option<&str>, + attention: Option, + include_blue: bool, + ) -> Result>; + + /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). + fn health(&self) -> Result; + + /// Open (creating if absent) the journal node for an ISO `date`. The id is + /// deterministic in `(owner, date)` so offline replicas converge (§3.1). + fn journal_open_or_create(&mut self, date: &str) -> Result; + // --- links --- /// Add a typed link between two nodes. diff --git a/crates/heph-core/tests/query_surface.rs b/crates/heph-core/tests/query_surface.rs new file mode 100644 index 0000000..3ebe0d6 --- /dev/null +++ b/crates/heph-core/tests/query_surface.rs @@ -0,0 +1,111 @@ +//! list / health / journal — the Organizational + working-set surface (§6, §7). + +use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +fn task(s: &mut LocalStore, title: &str, attention: Attention) -> String { + s.create_task(NewTask { + title: title.into(), + attention: Some(attention), + ..Default::default() + }) + .unwrap() + .node_id +} + +#[test] +fn list_enumerates_outstanding_including_blue_by_default() { + let mut s = store(); + task(&mut s, "white", Attention::White); + task(&mut s, "blue", Attention::Blue); + let done = task(&mut s, "done", Attention::Red); + s.set_task_state(&done, TaskState::Done).unwrap(); + + // Default list: outstanding only, blue included; done excluded. + let all = s.list(None, None, true).unwrap(); + let titles: Vec<_> = all.iter().map(|t| t.attention.unwrap()).collect::>(); + assert_eq!(all.len(), 2); + assert!(titles.contains(&Attention::White)); + assert!(titles.contains(&Attention::Blue)); +} + +#[test] +fn list_can_exclude_blue_and_filter_by_attention() { + let mut s = store(); + task(&mut s, "white", Attention::White); + task(&mut s, "blue", Attention::Blue); + task(&mut s, "orange1", Attention::Orange); + task(&mut s, "orange2", Attention::Orange); + + assert_eq!(s.list(None, None, false).unwrap().len(), 3); // blue excluded + assert_eq!( + s.list(None, Some(Attention::Orange), true).unwrap().len(), + 2 + ); +} + +#[test] +fn list_scopes_to_a_project() { + let mut s = store(); + let project = s + .create_node(heph_core::NewNode { + kind: heph_core::NodeKind::Project, + title: "Work".into(), + body: None, + }) + .unwrap(); + s.create_task(NewTask { + title: "work task".into(), + attention: Some(Attention::White), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + task(&mut s, "life task", Attention::White); + + let scoped = s.list(Some(&project.id), None, true).unwrap(); + assert_eq!(scoped.len(), 1); +} + +#[test] +fn health_counts_the_working_set_honestly() { + let mut s = store(); + task(&mut s, "r", Attention::Red); + task(&mut s, "o1", Attention::Orange); + task(&mut s, "o2", Attention::Orange); + task(&mut s, "w", Attention::White); + task(&mut s, "b1", Attention::Blue); + task(&mut s, "b2", Attention::Blue); + + let h = s.health().unwrap(); + assert_eq!(h.orange_count, 2); + assert_eq!(h.active_count, 4); // red + 2 orange + white + assert_eq!(h.on_deck_count, 2); // 2 blue + assert_eq!(h.conflict_count, 0); + assert_eq!(h.sync_status, "local"); +} + +#[test] +fn journal_open_or_create_is_idempotent_with_deterministic_id() { + let mut s = store(); + let a = s.journal_open_or_create("2026-05-31").unwrap(); + let b = s.journal_open_or_create("2026-05-31").unwrap(); + assert_eq!(a.id, b.id); + assert_eq!(a.kind, heph_core::NodeKind::Journal); + assert_eq!(a.title, "2026-05-31"); + // The id is deterministic in (owner, date). + assert_eq!( + a.id, + heph_core::deterministic_id(s.owner_id(), heph_core::NodeKind::Journal, "2026-05-31") + ); +} + +#[test] +fn journal_rejects_non_iso_dates() { + let mut s = store(); + assert!(s.journal_open_or_create("May 31").is_err()); + assert!(s.journal_open_or_create("2026-5-1").is_err()); +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 981388b..9b28aa2 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -138,6 +138,26 @@ struct NextParams { limit: Option, } +#[derive(Deserialize)] +struct ListParams { + #[serde(default)] + scope: Option, + #[serde(default)] + attention: Option, + /// Keep on-deck (blue) items; defaults to true for the survey view. + #[serde(default = "default_true")] + include_blue: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize)] +struct JournalParams { + date: String, +} + #[derive(Deserialize)] struct LinkParams { id: String, @@ -207,6 +227,15 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ListParams = parse(params)?; + json!(store.list(p.scope.as_deref(), p.attention, p.include_blue)?) + } + "health" => json!(store.health()?), + "journal.open_or_create" => { + let p: JournalParams = parse(params)?; + json!(store.journal_open_or_create(&p.date)?) + } "links.outgoing" => { let p: LinkParams = parse(params)?; json!(store.outgoing_links(&p.id)?)