generated from eblume/project-template
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>
161 lines
4.5 KiB
Rust
161 lines
4.5 KiB
Rust
//! 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());
|
|
}
|