Phase 1: v1 prototype #1

Merged
eblume merged 91 commits from feature/v1-prototype into main 2026-06-03 20:48:23 -07:00
20 changed files with 772 additions and 98 deletions
Showing only changes of commit a5fc578525 - Show all commits

feat(views): filter views (§8.2) — saved agenda slices
Some checks failed
Build / validate (pull_request) Failing after 18m44s

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 <name>` (no name lists them),
the `view` RPC + RemoteStore forward, and `:Heph view <name>` 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) <noreply@anthropic.com>
Erich Blume 2026-06-03 06:39:07 -07:00

View file

@ -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}")]

View file

@ -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<Attention>,
/// Drop these attention states. `[Blue]` expresses "≠ blue" while still
/// keeping attention-less tasks (unlike an `attention_in` whitelist).
pub attention_not: Vec<Attention>,
/// Keep only tasks whose project is one of these ids (subtree-expanded).
/// Empty = any project, including project-less tasks.
pub scope: Vec<String>,
/// Drop tasks whose project is one of these ids (subtree-expanded).
pub exclude_projects: Vec<String>,
/// 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 <name>`).
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);
}
}

View file

@ -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,

View file

@ -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<Option<String>> {
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<Vec<String>> {
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<String> = stmt
.query_map([&parent], |r| r.get(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
for child in children {
if !out.contains(&child) {
out.push(child.clone());
frontier.push(child);
}
}
}
Ok(out)
}

View file

@ -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<Attention>,
include_blue: bool,
) -> Result<Vec<RankedTask>> {
tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue)
fn list(&self, filter: &ListFilter) -> Result<Vec<RankedTask>> {
let now = self.clock.now_ms();
tasks::list(&self.conn, &self.owner_id, now, filter)
}
fn view(&self, name: &str) -> Result<Vec<RankedTask>> {
let now = self.clock.now_ms();
tasks::view(&self.conn, &self.owner_id, now, name)
}
fn health(&self) -> Result<Health> {

View file

@ -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<Attention>,
include_blue: bool,
now: i64,
filter: &ListFilter,
) -> Result<Vec<RankedTask>> {
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<Vec<RankedTask>> {
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<Vec<String>> {
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<Health> {
let mut stmt = conn.prepare(

View file

@ -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<Vec<RankedTask>>;
/// 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<Attention>,
include_blue: bool,
) -> Result<Vec<RankedTask>>;
/// 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<Vec<RankedTask>>;
/// 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<Vec<RankedTask>>;
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
fn health(&self) -> Result<Health>;

View file

@ -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::<Vec<_>>();
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]

View file

@ -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<i64>,
) -> 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<String> {
let mut t: Vec<String> = 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());
}

View file

@ -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<String>,
},
/// 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 <name>):");
for v in heph_core::BUILTIN_VIEWS {
println!(" {:<8} {}", v.name, v.title);
}
}
},
Command::Done { id } => {
set_state(&mut client, &id, "done")?;
}

View file

@ -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<Attention>,
include_blue: bool,
) -> Result<Vec<heph_core::RankedTask>> {
self.call_as(
"list",
json!({ "scope": scope, "attention": attention, "include_blue": include_blue }),
)
fn list(&self, filter: &ListFilter) -> Result<Vec<heph_core::RankedTask>> {
self.call_as("list", json!(filter))
}
fn view(&self, name: &str) -> Result<Vec<heph_core::RankedTask>> {
self.call_as("view", json!({ "name": name }))
}
fn health(&self) -> Result<Health> {

View file

@ -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<usize>,
}
#[derive(Deserialize)]
struct ListParams {
#[serde(default)]
scope: Option<String>,
#[serde(default)]
attention: Option<Attention>,
/// 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<Va
json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?)
}
"list" => {
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" => {

View file

@ -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"

View file

@ -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());
}

View file

@ -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 <name>` 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 <name>` 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).

View file

@ -72,6 +72,7 @@ edits, else adding the `wiki` link directly).
| `:Heph search <query>` | Full-text search; pick a result to open |
| `:Heph next [scope]` | Tactical "what is next?" view (`<CR>` opens a task's context) |
| `:Heph list [attention]` | Organizational survey of the outstanding set |
| `:Heph view <name>` | Run a built-in filter view (`tom\|ondeck\|chores\|work\|tasks`, tech-spec §8.2) |
| `:Heph capture <title>` | 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 |

View file

@ -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 11a11c **+ 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 11a11c **+ 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

View file

@ -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

View file

@ -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

View file

@ -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)