From a5fc57852577752f00906b933080bf1f0303a4cf Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 06:39:07 -0700 Subject: [PATCH] =?UTF-8?q?feat(views):=20filter=20views=20(=C2=A78.2)=20?= =?UTF-8?q?=E2=80=94=20saved=20agenda=20slices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the owner's saved filters first-class so the agenda isn't one flat list. `list` now takes a ListFilter predicate-as-data (heph-core::filter): attention include/exclude sets, project-id scope, exclude_projects, and an actionable do-date gate. New Store::view(name) resolves a built-in ViewSpec — looking project names up to ids and subtree-expanding them through parent links — then lists. Five built-ins seeded from the Todoist queries (design §6.2.1): tom, ondeck, chores, work, tasks (Schedule dropped — time-of-day isn't modeled on date-grained do-dates). Surfaced as `heph view ` (no name lists them), the `view` RPC + RemoteStore forward, and `:Heph view ` in nvim. The list RPC/RemoteStore/CLI/heph.nvim migrate to the filter wire; legacy --scope/--attention/--no-blue map onto it (nvim view.lua updated). Tests: filter unit predicate, a views integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket list/view dispatch test, two nvim e2e specs. 154 Rust tests + 18 nvim e2e green; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/error.rs | 4 + crates/heph-core/src/filter.rs | 278 +++++++++++++++++++++++ crates/heph-core/src/lib.rs | 2 + crates/heph-core/src/sqlite/links.rs | 45 ++++ crates/heph-core/src/sqlite/mod.rs | 16 +- crates/heph-core/src/sqlite/tasks.rs | 77 +++++-- crates/heph-core/src/store.rs | 23 +- crates/heph-core/tests/query_surface.rs | 28 ++- crates/heph-core/tests/views.rs | 161 +++++++++++++ crates/heph/src/main.rs | 35 ++- crates/hephd/src/remote.rs | 20 +- crates/hephd/src/rpc.rs | 29 ++- crates/hephd/tests/client_mode.rs | 2 +- crates/hephd/tests/rpc_socket.rs | 38 ++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 1 + docs/reference/tech-spec.md | 31 +-- heph.nvim/lua/heph/command.lua | 8 + heph.nvim/lua/heph/view.lua | 28 ++- heph.nvim/tests/e2e/view_spec.lua | 45 ++++ 20 files changed, 773 insertions(+), 99 deletions(-) create mode 100644 crates/heph-core/src/filter.rs create mode 100644 crates/heph-core/tests/views.rs create mode 100644 heph.nvim/tests/e2e/view_spec.lua diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs index d0c3eb5..8397e4b 100644 --- a/crates/heph-core/src/error.rs +++ b/crates/heph-core/src/error.rs @@ -23,6 +23,10 @@ pub enum Error { #[error("data integrity: {0}")] Integrity(String), + /// A caller passed an invalid argument (e.g. an unknown filter-view name). + #[error("invalid argument: {0}")] + InvalidArg(String), + /// A remote backend (a `RemoteStore` in `client` mode) failed or returned an /// error response (tech-spec §3.1). #[error("remote: {0}")] diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs new file mode 100644 index 0000000..bd463ad --- /dev/null +++ b/crates/heph-core/src/filter.rs @@ -0,0 +1,278 @@ +//! Filter views — saved agenda slices (tech-spec §8.2). +//! +//! A view is a **predicate expressed as data** (mirroring §7's "order as +//! data"): the engine [`Store::list`](crate::store::Store::list) takes a +//! [`ListFilter`] and returns the matching outstanding tasks as +//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / On Deck +//! / Chores / Work Tasks / Tasks) are derived from the owner's Todoist filter +//! queries (see `docs/explanation/design.md` §6.2.1) and realized in heph terms +//! (attention: p1→red, p2→orange, p4→white, p3→blue). + +use serde::{Deserialize, Serialize}; + +use crate::model::Attention; +use crate::ranking::RankedTask; + +/// A list predicate, expressed as data. Every field defaults to "no +/// constraint"; the implicit constraints (outstanding, non-tombstoned) are +/// applied by the store query itself, not here. +/// +/// `scope`/`exclude_projects` are project **node ids** that the caller has +/// already subtree-expanded ([`crate::store::Store::view`] does this from +/// project names); [`ListFilter::matches`] is then pure set-membership. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ListFilter { + /// Keep only these attention states. Empty = any attention (including + /// attention-less tasks). A whitelist *excludes* attention-less tasks. + pub attention_in: Vec, + /// Drop these attention states. `[Blue]` expresses "≠ blue" while still + /// keeping attention-less tasks (unlike an `attention_in` whitelist). + pub attention_not: Vec, + /// Keep only tasks whose project is one of these ids (subtree-expanded). + /// Empty = any project, including project-less tasks. + pub scope: Vec, + /// Drop tasks whose project is one of these ids (subtree-expanded). + pub exclude_projects: Vec, + /// Apply the §7 do-date candidacy gate: `do_date` is `None` or `<= now`. + pub actionable: bool, +} + +impl ListFilter { + /// Does `task` satisfy this predicate at `now`? Pure — `scope`/`exclude` + /// must already be project-id sets (subtree expansion happens upstream). + pub fn matches(&self, task: &RankedTask, now: i64) -> bool { + if !self.attention_in.is_empty() + && !task + .attention + .is_some_and(|a| self.attention_in.contains(&a)) + { + return false; + } + if let Some(a) = task.attention { + if self.attention_not.contains(&a) { + return false; + } + } + if !self.scope.is_empty() + && !task + .project_id + .as_ref() + .is_some_and(|p| self.scope.contains(p)) + { + return false; + } + if let Some(p) = &task.project_id { + if self.exclude_projects.contains(p) { + return false; + } + } + if self.actionable && task.do_date.is_some_and(|d| d > now) { + return false; + } + true + } +} + +/// A built-in named view: a [`ListFilter`] template whose `scope`/`exclude` are +/// project **names** (resolved to ids + subtree-expanded by the store). The +/// owner's sixth Todoist filter, `Schedule`, is intentionally dropped — its +/// time-of-day selection has no representation in heph's date-grained +/// `do_date` (tech-spec §8.2). +pub struct ViewSpec { + /// The short CLI name (`heph view `). + pub name: &'static str, + /// A human title for the filter pane / `heph view` listing. + pub title: &'static str, + /// Attention whitelist (see [`ListFilter::attention_in`]). + pub attention_in: &'static [Attention], + /// Attention blacklist (see [`ListFilter::attention_not`]). + pub attention_not: &'static [Attention], + /// Project names to scope to (subtree-expanded by the store). + pub scope_names: &'static [&'static str], + /// Project names to exclude (subtree-expanded by the store). + pub exclude_names: &'static [&'static str], + /// Whether the §7 do-date candidacy gate applies. + pub actionable: bool, +} + +/// The five built-in views (tech-spec §8.2), each realized from the verbatim +/// Todoist query in design §6.2.1. +pub const BUILTIN_VIEWS: &[ViewSpec] = &[ + // (p1|p2) & (no date|today|overdue) + ViewSpec { + name: "tom", + title: "Top of Mind", + attention_in: &[Attention::Red, Attention::Orange], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: true, + }, + // p3 & (no date|overdue|today) + ViewSpec { + name: "ondeck", + title: "On Deck", + attention_in: &[Attention::Blue], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: true, + }, + // (today|overdue|no date) & (#Chores|#Camano Chores) + ViewSpec { + name: "chores", + title: "Chores", + attention_in: &[], + attention_not: &[], + scope_names: &["Chores", "Camano Chores"], + exclude_names: &[], + actionable: true, + }, + // #Work & !p3 & (…) & !subtask + ViewSpec { + name: "work", + title: "Work Tasks", + attention_in: &[], + attention_not: &[Attention::Blue], + scope_names: &["Work"], + exclude_names: &[], + actionable: true, + }, + // !p3 & (…) & !(#Daily Routine|#Work Routine|#Chores|#Camano Chores|#Work|…) & !subtask + ViewSpec { + name: "tasks", + title: "Tasks", + attention_in: &[], + attention_not: &[Attention::Blue], + scope_names: &[], + exclude_names: &[ + "Chores", + "Camano Chores", + "Work", + "Work Routine", + "Daily Routine", + ], + actionable: true, + }, +]; + +/// Look up a built-in view by its short name (`tom|ondeck|chores|work|tasks`). +pub fn builtin(name: &str) -> Option<&'static ViewSpec> { + BUILTIN_VIEWS.iter().find(|v| v.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::TaskState; + + const NOW: i64 = 1_000_000; + + fn task(id: &str) -> RankedTask { + RankedTask { + node_id: id.to_string(), + title: id.to_string(), + attention: Some(Attention::White), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + tombstoned: false, + project_id: None, + canonical_context_id: None, + created_at: 0, + } + } + + #[test] + fn empty_filter_matches_anything() { + let f = ListFilter::default(); + assert!(f.matches(&task("a"), NOW)); + } + + #[test] + fn attention_in_is_a_whitelist_excluding_attentionless() { + let f = ListFilter { + attention_in: vec![Attention::Red, Attention::Orange], + ..Default::default() + }; + let mut red = task("red"); + red.attention = Some(Attention::Red); + let mut white = task("white"); + white.attention = Some(Attention::White); + let mut none = task("none"); + none.attention = None; + assert!(f.matches(&red, NOW)); + assert!(!f.matches(&white, NOW)); + assert!(!f.matches(&none, NOW)); + } + + #[test] + fn attention_not_keeps_attentionless() { + // "≠ blue" must keep attention-less tasks (unlike a whitelist). + let f = ListFilter { + attention_not: vec![Attention::Blue], + ..Default::default() + }; + let mut blue = task("blue"); + blue.attention = Some(Attention::Blue); + let mut none = task("none"); + none.attention = None; + assert!(!f.matches(&blue, NOW)); + assert!(f.matches(&none, NOW)); + } + + #[test] + fn scope_restricts_to_project_ids_and_drops_projectless() { + let f = ListFilter { + scope: vec!["p1".into(), "p2".into()], + ..Default::default() + }; + let mut in1 = task("in1"); + in1.project_id = Some("p1".into()); + let mut other = task("other"); + other.project_id = Some("nope".into()); + let none = task("none"); + assert!(f.matches(&in1, NOW)); + assert!(!f.matches(&other, NOW)); + assert!(!f.matches(&none, NOW)); + } + + #[test] + fn exclude_drops_listed_projects_but_keeps_projectless() { + let f = ListFilter { + exclude_projects: vec!["chores".into()], + ..Default::default() + }; + let mut chore = task("chore"); + chore.project_id = Some("chores".into()); + let none = task("none"); + assert!(!f.matches(&chore, NOW)); + assert!(f.matches(&none, NOW)); + } + + #[test] + fn actionable_gate_drops_future_do_dates_only_when_set() { + let mut future = task("future"); + future.do_date = Some(NOW + 1); + let on = ListFilter { + actionable: true, + ..Default::default() + }; + let off = ListFilter::default(); + assert!(!on.matches(&future, NOW)); + assert!(off.matches(&future, NOW)); + // a do_date of exactly now is still actionable + let mut today = task("today"); + today.do_date = Some(NOW); + assert!(on.matches(&today, NOW)); + } + + #[test] + fn builtin_lookup() { + assert_eq!(builtin("tom").unwrap().title, "Top of Mind"); + assert_eq!(builtin("ondeck").unwrap().attention_in, &[Attention::Blue]); + assert!(builtin("nope").is_none()); + assert_eq!(BUILTIN_VIEWS.len(), 5); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index acf7fe7..73978e0 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -13,6 +13,7 @@ mod crdt; pub mod error; pub mod export; pub mod extract; +pub mod filter; pub mod hlc; pub mod model; pub mod oplog; @@ -25,6 +26,7 @@ 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 filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index c5fa04d..26c78d9 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -191,3 +191,48 @@ pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result .optional()?; Ok(by_title) } + +/// Resolve a project **name** to its node id, restricted to `project`-kind +/// nodes (so a like-named task/doc never wins). `None` if no such project. +/// Used by filter-view scope/exclude resolution (tech-spec §8.2). +pub(super) fn resolve_project_id( + conn: &Connection, + owner: &str, + name: &str, +) -> Result> { + Ok(conn + .query_row( + "SELECT id FROM nodes + WHERE title = ?1 AND owner_id = ?2 AND kind = 'project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + (name, owner), + |r| r.get(0), + ) + .optional()?) +} + +/// Every project node id in the subtree rooted at `root` (inclusive): `root` +/// plus every project that reaches it through `parent` links (a child holds a +/// `parent` link to its parent, src=child → dst=parent). Powers the +/// project-subtree scope of filter views (tech-spec §8.2). Cycle-safe via the +/// visited set. +pub(super) fn project_subtree(conn: &Connection, root: &str) -> Result> { + let mut out = vec![root.to_string()]; + let mut frontier = vec![root.to_string()]; + let mut stmt = conn.prepare( + "SELECT src_id FROM links + WHERE dst_id = ?1 AND type = 'parent' AND tombstoned = 0", + )?; + while let Some(parent) = frontier.pop() { + let children: Vec = stmt + .query_map([&parent], |r| r.get(0))? + .collect::>>()?; + for child in children { + if !out.contains(&child) { + out.push(child.clone()); + frontier.push(child); + } + } + } + Ok(out) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 45c8c9e..04f7fa6 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -28,6 +28,7 @@ use ulid::Ulid; use crate::clock::Clock; use crate::error::{Error, Result}; +use crate::filter::ListFilter; use crate::hlc::Hlc; use crate::model::{ Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, @@ -260,13 +261,14 @@ 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 list(&self, filter: &ListFilter) -> Result> { + let now = self.clock.now_ms(); + tasks::list(&self.conn, &self.owner_id, now, filter) + } + + fn view(&self, name: &str) -> Result> { + let now = self.clock.now_ms(); + tasks::view(&self.conn, &self.owner_id, now, name) } fn health(&self) -> Result { diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index e108c94..9c18060 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -10,6 +10,7 @@ use serde_json::json; use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; use crate::extract; +use crate::filter::ListFilter; use crate::model::{ Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, }; @@ -354,17 +355,16 @@ 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) as **titled rows** ([`RankedTask`] shape — -/// the same the plugin renders for `next`, so the survey view needs no N+1 -/// `node.get`). Optional `scope` (project) and `attention` filters; -/// `include_blue` keeps on-deck items (default true for `list`). +/// Enumerate outstanding committed tasks matching `filter` (tech-spec §8.2), +/// as **titled rows** ([`RankedTask`] shape — the same the plugin renders for +/// `next`, so the survey view needs no N+1 `node.get`). An empty +/// [`ListFilter`] yields the whole outstanding set (the Organizational survey, +/// tech-spec §6). `now` feeds the `actionable` do-date gate. pub(super) fn list( conn: &Connection, owner: &str, - scope: Option<&str>, - attention: Option, - include_blue: bool, + now: i64, + filter: &ListFilter, ) -> Result> { let sql = " SELECT n.id, n.title, n.created_at, n.tombstoned, @@ -384,24 +384,59 @@ pub(super) fn list( let mut out = Vec::new(); for row in rows { let task = row?; - if let Some(s) = scope { - if task.project_id.as_deref() != Some(s) { - continue; - } + if filter.matches(&task, now) { + out.push(task); } - 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) } +/// Run a built-in filter view by name (tech-spec §8.2): resolve its project +/// names to ids (each subtree-expanded), build the [`ListFilter`], and list. +/// A view whose `scope_names` all fail to resolve yields no rows (the projects +/// don't exist), rather than silently widening to "any project". +pub(super) fn view( + conn: &Connection, + owner: &str, + now: i64, + name: &str, +) -> Result> { + let spec = crate::filter::builtin(name) + .ok_or_else(|| Error::InvalidArg(format!("unknown view {name:?}")))?; + + let scope = resolve_project_names(conn, owner, spec.scope_names)?; + if !spec.scope_names.is_empty() && scope.is_empty() { + return Ok(Vec::new()); + } + let exclude_projects = resolve_project_names(conn, owner, spec.exclude_names)?; + + let filter = ListFilter { + attention_in: spec.attention_in.to_vec(), + attention_not: spec.attention_not.to_vec(), + scope, + exclude_projects, + actionable: spec.actionable, + }; + list(conn, owner, now, &filter) +} + +/// Resolve project `names` to a deduped set of node ids, each expanded to its +/// project subtree. Names that don't resolve are skipped (a view tolerates a +/// store missing some of its projects). +fn resolve_project_names(conn: &Connection, owner: &str, names: &[&str]) -> Result> { + let mut ids = Vec::new(); + for name in names { + if let Some(id) = links::resolve_project_id(conn, owner, name)? { + for sub in links::project_subtree(conn, &id)? { + if !ids.contains(&sub) { + ids.push(sub); + } + } + } + } + Ok(ids) +} + /// Working-set health counts (tech-spec §7) — surfaced honestly. pub(super) fn health(conn: &Connection, owner: &str) -> Result { let mut stmt = conn.prepare( diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 3a1d20c..fbd6aeb 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,6 +5,7 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; +use crate::filter::ListFilter; use crate::model::{ Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, SyncCursors, Task, TaskState, @@ -99,16 +100,18 @@ 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), as **titled** [`RankedTask`] rows - /// (the same shape `next` returns, so the survey view needs no N+1 - /// `get_node`). `include_blue` keeps on-deck. - fn list( - &self, - scope: Option<&str>, - attention: Option, - include_blue: bool, - ) -> Result>; + /// Enumerate outstanding committed tasks matching a [`ListFilter`] — the + /// data-expressed predicate behind filter views (tech-spec §8.2). Returns + /// **titled** [`RankedTask`] rows (the same shape `next` returns, so the + /// survey view needs no N+1 `get_node`). An empty filter is the whole + /// outstanding set (the Organizational survey, tech-spec §6). + fn list(&self, filter: &ListFilter) -> Result>; + + /// Run a built-in named filter view (`tom|ondeck|chores|work|tasks`, + /// tech-spec §8.2): resolve its project names to ids (subtree-expanded), + /// build the [`ListFilter`], and return the matching rows. Errors on an + /// unknown view name. + fn view(&self, name: &str) -> Result>; /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; diff --git a/crates/heph-core/tests/query_surface.rs b/crates/heph-core/tests/query_surface.rs index a09493a..f540252 100644 --- a/crates/heph-core/tests/query_surface.rs +++ b/crates/heph-core/tests/query_surface.rs @@ -1,6 +1,6 @@ //! list / health / journal — the Organizational + working-set surface (§6, §7). -use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState}; +use heph_core::{Attention, FixedClock, ListFilter, LocalStore, NewTask, Store, TaskState}; fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() @@ -25,7 +25,7 @@ fn list_enumerates_outstanding_including_blue_by_default() { 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 all = s.list(&ListFilter::default()).unwrap(); let titles: Vec<_> = all.iter().map(|t| t.attention.unwrap()).collect::>(); assert_eq!(all.len(), 2); assert!(titles.contains(&Attention::White)); @@ -40,11 +40,16 @@ fn list_can_exclude_blue_and_filter_by_attention() { 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 - ); + let no_blue = ListFilter { + attention_not: vec![Attention::Blue], + ..Default::default() + }; + assert_eq!(s.list(&no_blue).unwrap().len(), 3); // blue excluded + let only_orange = ListFilter { + attention_in: vec![Attention::Orange], + ..Default::default() + }; + assert_eq!(s.list(&only_orange).unwrap().len(), 2); } #[test] @@ -54,7 +59,7 @@ fn list_rows_carry_title_and_canonical_context() { // The Organizational view needs titles + the one-keystroke context jump // without an N+1 node.get (tech-spec §6, §8). - let rows = s.list(None, None, true).unwrap(); + let rows = s.list(&ListFilter::default()).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].node_id, id); assert_eq!(rows[0].title, "Buy milk"); @@ -80,8 +85,11 @@ fn list_scopes_to_a_project() { .unwrap(); task(&mut s, "life task", Attention::White); - let scoped = s.list(Some(&project.id), None, true).unwrap(); - assert_eq!(scoped.len(), 1); + let scoped = ListFilter { + scope: vec![project.id.clone()], + ..Default::default() + }; + assert_eq!(s.list(&scoped).unwrap().len(), 1); } #[test] diff --git a/crates/heph-core/tests/views.rs b/crates/heph-core/tests/views.rs new file mode 100644 index 0000000..cdbd12b --- /dev/null +++ b/crates/heph-core/tests/views.rs @@ -0,0 +1,161 @@ +//! Filter views — the five built-in saved agenda slices (tech-spec §8.2). +//! +//! Builds a store mirroring the owner's project shape (Work + a Work subproject, +//! Chores / Camano Chores, the routine projects) with tasks across the attention +//! bands, then asserts each `view` returns exactly the right slice — including +//! project-subtree scope/exclude and the `actionable` do-date gate. + +use heph_core::{Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store}; + +const NOW: i64 = 1_700_000_000_000; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(NOW))).unwrap() +} + +fn project(s: &mut LocalStore, title: &str) -> String { + s.create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id +} + +/// Capture a task with an attention, optional project, and optional do_date. +fn task( + s: &mut LocalStore, + title: &str, + attention: Attention, + project_id: Option<&str>, + do_date: Option, +) -> String { + s.create_task(NewTask { + title: title.into(), + attention: Some(attention), + project_id: project_id.map(str::to_string), + do_date, + ..Default::default() + }) + .unwrap() + .node_id +} + +fn titles(rows: &[heph_core::RankedTask]) -> Vec { + let mut t: Vec = rows.iter().map(|r| r.title.clone()).collect(); + t.sort(); + t +} + +/// A store with the full project shape and one task per interesting case. +fn seeded() -> LocalStore { + let mut s = store(); + let work = project(&mut s, "Work"); + let work_sub = project(&mut s, "Work Sub"); + s.add_link(&work_sub, &work, LinkType::Parent).unwrap(); // child → parent + let chores = project(&mut s, "Chores"); + let camano = project(&mut s, "Camano Chores"); + let work_routine = project(&mut s, "Work Routine"); + project(&mut s, "Daily Routine"); + + task(&mut s, "red", Attention::Red, None, None); + task(&mut s, "orange", Attention::Orange, None, None); + task(&mut s, "white", Attention::White, None, None); + task(&mut s, "blue", Attention::Blue, None, None); + task(&mut s, "work task", Attention::White, Some(&work), None); + task( + &mut s, + "work sub task", + Attention::White, + Some(&work_sub), + None, + ); + task(&mut s, "chore", Attention::White, Some(&chores), None); + task( + &mut s, + "camano chore", + Attention::Orange, + Some(&camano), + None, + ); + task( + &mut s, + "routine", + Attention::White, + Some(&work_routine), + None, + ); + // A red task whose do_date is in the future — excluded by the actionable gate. + task( + &mut s, + "future red", + Attention::Red, + None, + Some(NOW + 86_400_000), + ); + s +} + +#[test] +fn top_of_mind_is_red_and_orange_and_actionable() { + let s = seeded(); + // Every red/orange task across all projects (the orange Camano chore counts + // too — ToM has no project scope), but NOT the future-dated red (actionable + // gate). + assert_eq!( + titles(&s.view("tom").unwrap()), + vec!["camano chore", "orange", "red"] + ); +} + +#[test] +fn on_deck_is_blue_only() { + let s = seeded(); + assert_eq!(titles(&s.view("ondeck").unwrap()), vec!["blue"]); +} + +#[test] +fn chores_scopes_to_the_two_chore_projects() { + let s = seeded(); + assert_eq!( + titles(&s.view("chores").unwrap()), + vec!["camano chore", "chore"] + ); +} + +#[test] +fn work_scopes_to_the_work_subtree() { + let s = seeded(); + // Both the direct Work task and the Work-Sub task (subtree expansion). + assert_eq!( + titles(&s.view("work").unwrap()), + vec!["work sub task", "work task"] + ); +} + +#[test] +fn tasks_excludes_routine_chore_and_work_subtree_projects() { + let s = seeded(); + // Non-blue, actionable, and not in any excluded project (incl. the Work + // subtree) → just the three project-less, present-dated tasks. + assert_eq!( + titles(&s.view("tasks").unwrap()), + vec!["orange", "red", "white"] + ); +} + +#[test] +fn unknown_view_is_an_error() { + let s = store(); + assert!(s.view("nope").is_err()); +} + +#[test] +fn scoped_view_is_empty_when_its_projects_are_absent() { + // A store with no Chores/Camano projects: the chores view scopes to nothing, + // and must return empty rather than widening to "any project". + let mut s = store(); + task(&mut s, "loose", Attention::White, None, None); + assert!(s.view("chores").unwrap().is_empty()); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 257f637..02dbb6d 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -76,6 +76,11 @@ enum Command { #[arg(long)] no_blue: bool, }, + /// Run a built-in filter view (tech-spec §8.2); omit the name to list views. + View { + /// View name: tom|ondeck|chores|work|tasks. Omit to list all views. + name: Option, + }, /// Mark a task done (recurring tasks roll forward). Done { /// Task node id. @@ -397,12 +402,34 @@ fn main() -> Result<()> { attention, no_blue, } => { - let result = client.call( - "list", - json!({ "scope": scope, "attention": attention, "include_blue": !no_blue }), - )?; + // `list` takes a ListFilter (tech-spec §8.2). Map the legacy flags: + // a single `--scope` id, a single `--attention` whitelist, and + // `--no-blue` as an attention exclusion. + let mut filter = json!({}); + if let Some(s) = scope { + filter["scope"] = json!([s]); + } + if let Some(a) = attention { + filter["attention_in"] = json!([a]); + } + if no_blue { + filter["attention_not"] = json!(["blue"]); + } + let result = client.call("list", filter)?; print_rows(result)?; } + Command::View { name } => match name { + Some(name) => { + let result = client.call("view", json!({ "name": name }))?; + print_rows(result)?; + } + None => { + println!("Available views (heph view ):"); + for v in heph_core::BUILTIN_VIEWS { + println!(" {:<8} {}", v.name, v.title); + } + } + }, Command::Done { id } => { set_state(&mut client, &id, "done")?; } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 550faba..959e6a3 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, NodeKind, Result, - SchedulePatch, Store, SyncCursors, Task, TaskState, + Attention, Conflict, Error, Health, Link, LinkType, ListFilter, NewNode, NewTask, Node, + NodeKind, Result, SchedulePatch, Store, SyncCursors, Task, TaskState, }; use crate::oauth::{self, TokenStore}; @@ -189,16 +189,12 @@ impl Store for RemoteStore { self.call_as("next", json!({ "scope": scope, "limit": limit })) } - fn list( - &self, - scope: Option<&str>, - attention: Option, - include_blue: bool, - ) -> Result> { - self.call_as( - "list", - json!({ "scope": scope, "attention": attention, "include_blue": include_blue }), - ) + fn list(&self, filter: &ListFilter) -> Result> { + self.call_as("list", json!(filter)) + } + + fn view(&self, name: &str) -> Result> { + self.call_as("view", json!({ "name": name })) } fn health(&self) -> Result { diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index cbd8320..fa7d160 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,9 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, LinkType, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState}; +use heph_core::{ + Attention, LinkType, ListFilter, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState, +}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -159,19 +161,12 @@ 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, -} +/// `list` takes a [`ListFilter`] directly as its params (tech-spec §8.2); an +/// empty object is the whole outstanding set. -fn default_true() -> bool { - true +#[derive(Deserialize)] +struct ViewParams { + name: String, } #[derive(Deserialize)] @@ -291,8 +286,12 @@ 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)?) + let filter: ListFilter = parse(params)?; + json!(store.list(&filter)?) + } + "view" => { + let p: ViewParams = parse(params)?; + json!(store.view(&p.name)?) } "health" => json!(store.health()?), "search" => { diff --git a/crates/hephd/tests/client_mode.rs b/crates/hephd/tests/client_mode.rs index 15707a2..f8fd3d7 100644 --- a/crates/hephd/tests/client_mode.rs +++ b/crates/hephd/tests/client_mode.rs @@ -77,7 +77,7 @@ fn remote_store_proxies_the_store_api() { .unwrap() .expect("task on server"); assert_eq!(fetched.node_id, task.node_id); - let listed = remote.list(None, None, true).unwrap(); + let listed = remote.list(&heph_core::ListFilter::default()).unwrap(); assert!( listed.iter().any(|t| t.node_id == task.node_id), "task missing from list" diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 62cbb27..1c5d122 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -309,3 +309,41 @@ fn multiple_clients_concurrently_create_tasks() { let ranked = c.call("next", json!({ "limit": 100 })).unwrap(); assert_eq!(ranked.as_array().unwrap().len(), N); } + +#[test] +fn list_takes_a_filter_and_view_runs_a_builtin_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + c.call( + "task.create", + json!({ "title": "red task", "attention": "red" }), + ) + .unwrap(); + c.call( + "task.create", + json!({ "title": "blue task", "attention": "blue" }), + ) + .unwrap(); + + // An empty filter is the whole outstanding set (both tasks). + let all = c.call("list", json!({})).unwrap(); + assert_eq!(all.as_array().unwrap().len(), 2); + + // A filter excluding blue drops the on-deck task. + let no_blue = c + .call("list", json!({ "attention_not": ["blue"] })) + .unwrap(); + let arr = no_blue.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["title"], "red task"); + + // The Top of Mind view (red|orange) returns just the red task. + let tom = c.call("view", json!({ "name": "tom" })).unwrap(); + let arr = tom.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["title"], "red task"); + + // An unknown view name is a reported RPC error. + assert!(c.call("view", json!({ "name": "bogus" })).is_err()); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index aca928b..9ccf385 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -22,3 +22,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Dev/installed isolation tooling: a `mise run dev` task runs the working-tree `hephd` on isolated `.dev/` paths, and a how-to ([[install-heph]]) covers installing `heph`/`hephd` from the forge (build-from-source), the lazy.nvim plugin setup, and pointing a dev Neovim at the dev daemon via `$HEPH_SOCKET`/`$HEPH_DB` so it never touches the installed store. - CLI as a complete task surface (§1, §6.2.1): `heph` now implements the entire daemon API and is the task capture/scripting surface. Structured fields are flags with **human dates** (`--do-date tomorrow|+3d|fri|YYYY-MM-DD`, shown back compactly in `next`/`list`) and **recurrence** (`--recur` presets/natural-language like "every 3 days", or a raw `--rrule`). New verbs: `list`, `done`/`drop`/`skip`, `attention`, `edit` (reschedule do-date/late-on/recurrence, re-attention, re-file — backed by the new `task.set_schedule` RPC), `promote`, `show`, `log` (append or tail), `health`, `node update`/`rm`, `resolve`, `links`/`backlinks`, `link add`, `project add [--parent]`, `sync [--status]`, `conflicts [resolve]`. Projects are referenced by name. Date/recurrence parsing is unit-tested; the new verbs have real-socket process tests. - Daemon lifecycle is now an explicit OS service, and all surfaces are connect-only (no more auto-spawn). `heph daemon start/stop/restart/status/uninstall` idempotently manages a launchd agent (macOS) or systemd user service (Linux) that runs `hephd` on your default store; `heph.nvim` no longer spawns or supervises a daemon — it just connects and points you at `heph daemon start` if none is running. Rationale: once the CLI became a first-class surface, a daemon owned by one surface couldn't be shared (see [[run-the-daemon]], [[design]] §4). +- Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view ` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view ` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index db4b19b..e28ddaf 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -72,6 +72,7 @@ edits, else adding the `wiki` link directly). | `:Heph search ` | Full-text search; pick a result to open | | `:Heph next [scope]` | Tactical "what is next?" view (`` opens a task's context) | | `:Heph list [attention]` | Organizational survey of the outstanding set | +| `:Heph view ` | Run a built-in filter view (`tom\|ondeck\|chores\|work\|tasks`, tech-spec §8.2) | | `:Heph capture ` | Capture a committed task (pick attention) | | `:Heph attention [color]` | Set the current task's attention | | `:Heph done` / `:Heph drop` / `:Heph skip` | State change on the current task | diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 855cc7a..1c3d652 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -195,7 +195,8 @@ Methods (request → response; errors are JSON-RPC errors). Signatures are indic - `task.set_attention({id, attention}) → Task` - `task.promote({container_id, item_ref, attention?, project?}) → Task` (mints a committed task from a context-item line and rewrites that line into a link to it, §4.3) - `next({scope?, limit?}) → [RankedTask]` (the Tactical blank-slate "what is next?" ranking, §7) -- `list({scope?, attention?, include_blue?, include_future?, group_by?}) → [Task]` (enumeration for the Organizational view — the whole set incl. backlog) +- `list(ListFilter) → [RankedTask]` (the §8.2 predicate-as-data: `{attention_in?, attention_not?, scope?, exclude_projects?, actionable?}`; an empty filter is the whole outstanding set — the Organizational survey) +- `view({name}) → [RankedTask]` (run a built-in filter view `tom|ondeck|chores|work|tasks`, §8.2 — resolves project names→ids+subtree and lists) - `search({query, filters?}) → [Node]` (FTS) - `links.outgoing(id) → [Link]` / `links.backlinks(id) → [Link]` - `journal.open_or_create(date) → Node` @@ -258,9 +259,11 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Testing** — TDD against a real daemon; headless smoke via `ratatui`'s `TestBackend`. - **Prereqs** (land first): **§8.2 filter views** (the TUI's saved-filter pane is just those views); the CLI-complete task surface and `task.set_schedule` (done). -## 8.2 Filter views (saved agenda slices) — planned, the next slice +## 8.2 Filter views (saved agenda slices) — built -> **Status: planned, the next slice.** [[design]] §6.2 / §6.2.1 establish that the owner navigates work through a fixed set of **saved filters**, not one flat list — so `next` alone is too coarse. This slice makes those filters first-class, shared by the CLI now and the TUI (§8.1) next. (The reference-context noise those filters excluded — `##Culture`, `#Camano Info` — has already been reclassified out of tasks into wiki docs, [[design]] §6.2.1.) +> **Status: built.** [[design]] §6.2 / §6.2.1 establish that the owner navigates work through a fixed set of **saved filters**, not one flat list — so `next` alone is too coarse. This slice made those filters first-class, shared by the CLI now and the TUI (§8.1) next. (The reference-context noise those filters excluded — `##Culture`, `#Camano Info` — has already been reclassified out of tasks into wiki docs, [[design]] §6.2.1.) +> +> Implemented as a `ListFilter` **predicate-as-data** (`heph-core::filter`): `list` takes a `ListFilter` (attention include/exclude sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate); `Store::view(name)` resolves a built-in [`ViewSpec`] — looking project **names** up to ids and **subtree-expanding** them through `parent` links — then runs `list`. Surfaced as `heph view <name>` (no name lists the five), the `view` RPC, and `:Heph view <name>` in nvim. **The five built-in views** (the owner's sixth Todoist filter, **Schedule**, is intentionally dropped — see below), each derived from the verbatim Todoist query ([[design]] §6.2.1) and realized in heph terms (attention: p1→red, p2→orange, p4→white, p3→blue): @@ -274,7 +277,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba **Engine work — extend `list` (§6) so a view is a *predicate expressed as data*** (mirroring §7's "order as data"): -- `attention`: a **set** of states (was a single value) — e.g. `{red,orange}`. +- `attention`: an **include set** (`attention_in`, e.g. `{red,orange}`) *and* an **exclude set** (`attention_not`, e.g. `{blue}` for "≠ blue"). The split matters: a whitelist drops attention-less tasks, but "≠ blue" must keep them — so Work/Tasks use `attention_not`, ToM/On Deck use `attention_in`. - `scope`: a project **including its descendant projects** (subtree, for `##Culture` / Work-tree), and/or **multiple** projects (Chores + Camano Chores). - `exclude_projects`: a list subtracted from the result (the "Tasks" leftover view). - `actionable`: a bool toggle applying the §7 do-date candidacy gate inside `list` (today the gate is `next`-only). @@ -377,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-02 — **117 Rust tests** (`cargo test --all`) + **17 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **154 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** @@ -406,19 +409,19 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - **Interactive task views:** `:Heph next`/`list` buffers gained `a` add / `d` done / `r` refresh (+ `<CR>` open), with a dimmed key-hint **header line** (virt-lines-above the first row render off-screen — use a real header). - **Dev/installed isolation:** installed `heph`/`hephd` own the default paths; `mise run dev` runs the working-tree daemon on `.dev/` paths; `$HEPH_SOCKET`/`$HEPH_DB` point a dev Neovim at it. - **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI. +- ✅ **Filter views (§8.2) — saved agenda slices:** the owner's saved filters are first-class so the agenda isn't one flat list. New **`heph-core::filter`**: a `ListFilter` **predicate-as-data** (`attention_in`/`attention_not` sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate) + the five built-in [`ViewSpec`]s (Top of Mind / On Deck / Chores / Work Tasks / Tasks — **Schedule dropped**, §8.2). `Store::list` now takes a `ListFilter`; new `Store::view(name)` resolves a spec's project **names** → ids and **subtree-expands** them through `parent` links (`links::project_subtree`/`resolve_project_id`), tolerating absent projects (a scoped view whose projects are all missing returns empty, never widens). Surfaces: `heph view <name>` (no name lists the five), the **`view` RPC** + `RemoteStore` forward, and `:Heph view <name>` in nvim (`heph://view/<name>` buffers). The `list` RPC/`RemoteStore`/CLI/`heph.nvim` migrated to the filter wire (legacy `--scope`/`--attention`/`--no-blue` map onto it). Tested: `filter` unit predicate, a `views` integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket `list`/`view` dispatch test, and two nvim e2e specs. **Not yet done (resume order)** -> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), and the live store has been seeded from Todoist with reference contexts reclassified to wiki docs ([[design]] §6.2.1). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (to build), **nvim = context/KB**. Remaining work, in order: +> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist with reference contexts reclassified to wiki docs ([[design]] §6.2.1), and **filter views (§8.2) are built** (`heph view`). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (next big build), **nvim = context/KB**. Remaining work, in order: -1. ⏳ **Filter views (§8.2) — the next slice:** make the owner's saved filters (Top of Mind / Tasks / Work Tasks / Chores / On Deck — **Schedule dropped**, §8.2) first-class so the agenda isn't one flat list. Extend `list` (§6) to a data-expressed predicate — **attention set**, **project-subtree / multi scope**, **exclude-projects**, **actionable toggle** (+ parent-project link resolution) — and surface five built-in views via `heph view <name>` (the TUI reuses them). Seeded from the verbatim Todoist queries ([[design]] §6.2.1). *(Future, noted: chores become a first-class kind with their own do-date/recurrence semantics, retiring the Chores/Camano-Chores projects — §8.2.)* -2. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; the §8.2 filter views as the saved-filter pane; launches into nvim for context and back. **Depends on the filter-views slice.** -3. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). -4. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduces) is a refinement. -5. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -6. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). -7. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -8. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; the §8.2 filter views as the saved-filter pane (the `view` RPC is ready); launches into nvim for context and back. **Filter-views prereq is now done.** +2. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). +3. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduced) is a refinement. +4. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +5. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). +6. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +7. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index af63122..774c58e 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -65,6 +65,14 @@ M.subs = { list = function(args) require("heph.view").list({ attention = args[1] }) end, + view = function(args) + local name = args[1] + if not name then + require("heph.util").notify("usage: :Heph view <tom|ondeck|chores|work|tasks>", vim.log.levels.WARN) + return + end + require("heph.view").view(name) + end, capture = function(args) local title = table.concat(args, " ") if #title == 0 then diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua index 5c1063f..fbc10fa 100644 --- a/heph.nvim/lua/heph/view.lua +++ b/heph.nvim/lua/heph/view.lua @@ -144,17 +144,35 @@ function M.next(opts) end --- Organizational survey — render the outstanding set, return the rows. +--- `list` takes a ListFilter (tech-spec §8.2); an empty table is the whole +--- outstanding set. Legacy opts map onto the filter fields. function M.list(opts) opts = opts or {} - local tasks = rpc.call("list", { - scope = opts.scope, - attention = opts.attention, - include_blue = opts.include_blue ~= false, - }) + local filter = {} + if opts.scope then + filter.scope = { opts.scope } + end + if opts.attention then + filter.attention_in = { opts.attention } + end + if opts.include_blue == false then + filter.attention_not = { "blue" } + end + local tasks = rpc.call("list", filter) render("heph://list", tasks, function() M.list(opts) end) return tasks end +--- A built-in filter view (tech-spec §8.2) — render its rows like `list`. +function M.view(name, opts) + opts = opts or {} + local tasks = rpc.call("view", { name = name }) + render("heph://view/" .. name, tasks, function() + M.view(name, opts) + end) + return tasks +end + return M diff --git a/heph.nvim/tests/e2e/view_spec.lua b/heph.nvim/tests/e2e/view_spec.lua new file mode 100644 index 0000000..fc88358 --- /dev/null +++ b/heph.nvim/tests/e2e/view_spec.lua @@ -0,0 +1,45 @@ +-- Filter views (tech-spec §8.2): `:Heph view <name>` renders a built-in slice. + +local h = require("e2e.helpers") + +describe("filter views", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("renders the Top of Mind view (red|orange, not blue)", function() + ctx.q:call("task.create", { title = "urgent thing", attention = "red" }) + ctx.q:call("task.create", { title = "warm thing", attention = "orange" }) + ctx.q:call("task.create", { title = "cool thing", attention = "blue" }) + + -- The backend view returns just the red + orange tasks. + local rows = ctx.q:call("view", { name = "tom" }) + assert.are.equal(2, #rows) + + -- The plugin renders them into a dedicated view buffer. + require("heph.view").view("tom") + local buf = vim.api.nvim_get_current_buf() + assert.is_truthy( + vim.api.nvim_buf_get_name(buf):find("heph://view/tom", 1, true), + "view buffer not named heph://view/tom" + ) + local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + assert.is_truthy(text:find("urgent thing", 1, true), "red task missing from ToM") + assert.is_truthy(text:find("warm thing", 1, true), "orange task missing from ToM") + assert.is_falsy(text:find("cool thing", 1, true), "blue task should not be in ToM") + end) + + it("scopes the chores view to chore projects via the daemon", function() + local chores = ctx.q:call("node.create", { kind = "project", title = "Chores" }) + ctx.q:call("task.create", { title = "take out trash", attention = "white", project_id = chores.id }) + ctx.q:call("task.create", { title = "unrelated", attention = "white" }) + + local rows = ctx.q:call("view", { name = "chores" }) + assert.are.equal(1, #rows) + assert.are.equal("take out trash", rows[1].title) + end) +end)