generated from eblume/project-template
Phase 1: v1 prototype #1
20 changed files with 772 additions and 98 deletions
feat(views): filter views (§8.2) — saved agenda slices
Some checks failed
Build / validate (pull_request) Failing after 18m44s
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>
commit
a5fc578525
|
|
@ -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}")]
|
||||
|
|
|
|||
278
crates/heph-core/src/filter.rs
Normal file
278
crates/heph-core/src/filter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
161
crates/heph-core/tests/views.rs
Normal file
161
crates/heph-core/tests/views.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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")?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
heph.nvim/tests/e2e/view_spec.lua
Normal file
45
heph.nvim/tests/e2e/view_spec.lua
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue