diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs index 0f16f9d..a04ed49 100644 --- a/crates/heph-core/src/filter.rs +++ b/crates/heph-core/src/filter.rs @@ -3,10 +3,11 @@ //! 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 / Tasks / +//! [`RankedTask`] rows. The built-in [`ViewSpec`]s (Top of Mind / Tasks / //! Work Tasks / Chores / On Deck) 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). +//! (attention: p1→red, p2→orange, p4→white, p3→blue), plus an **Inbox** of +//! project-less tasks to triage. use serde::{Deserialize, Serialize}; @@ -36,6 +37,9 @@ pub struct ListFilter { pub exclude_projects: Vec, /// Apply the §7 do-date candidacy gate: `do_date` is `None` or `<= now`. pub actionable: bool, + /// Keep only tasks with **no project** — the capture "Inbox" to triage and + /// file. (Pairs naturally with an empty `scope`; a scoped Inbox is empty.) + pub unfiled: bool, } impl ListFilter { @@ -70,6 +74,9 @@ impl ListFilter { if self.actionable && task.do_date.is_some_and(|d| d > now) { return false; } + if self.unfiled && task.project_id.is_some() { + return false; + } true } } @@ -94,12 +101,14 @@ pub struct ViewSpec { pub exclude_names: &'static [&'static str], /// Whether the §7 do-date candidacy gate applies. pub actionable: bool, + /// Whether to keep only project-less tasks (see [`ListFilter::unfiled`]). + pub unfiled: bool, } -/// The five built-in views (tech-spec §8.2), each realized from the verbatim -/// Todoist query in design §6.2.1. +/// The built-in views (tech-spec §8.2): five realized from the verbatim +/// Todoist query in design §6.2.1, plus an Inbox of project-less tasks. // Sidebar / `heph view` order (owner's preference): Top of Mind, Tasks, -// Work Tasks, Chores, On Deck. +// Work Tasks, Chores, On Deck, Inbox. pub const BUILTIN_VIEWS: &[ViewSpec] = &[ // (p1|p2) & (no date|today|overdue) ViewSpec { @@ -110,6 +119,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &[], exclude_names: &[], actionable: true, + unfiled: false, }, // !p3 & (…) & !(#Daily Routine|#Work Routine|#Chores|#Camano Chores|#Work|…) & !subtask ViewSpec { @@ -126,6 +136,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ "Daily Routine", ], actionable: true, + unfiled: false, }, // #Work & !p3 & (…) & !subtask ViewSpec { @@ -136,6 +147,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &["Work"], exclude_names: &[], actionable: true, + unfiled: false, }, // (today|overdue|no date) & (#Chores|#Camano Chores) ViewSpec { @@ -146,6 +158,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &["Chores", "Camano Chores"], exclude_names: &[], actionable: true, + unfiled: false, }, // p3 & (no date|overdue|today) ViewSpec { @@ -156,6 +169,19 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &[], exclude_names: &[], actionable: true, + unfiled: false, + }, + // Tasks filed under no project — the capture inbox to triage and file. Shows + // everything unfiled (no attention/do-date gate) so nothing hides from triage. + ViewSpec { + name: "inbox", + title: "Inbox", + attention_in: &[], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: false, + unfiled: true, }, ]; @@ -241,6 +267,26 @@ mod tests { assert!(!f.matches(&none, NOW)); } + #[test] + fn unfiled_keeps_only_projectless_tasks() { + let f = ListFilter { + unfiled: true, + ..Default::default() + }; + let none = task("none"); + let mut filed = task("filed"); + filed.project_id = Some("work".into()); + assert!(f.matches(&none, NOW)); + assert!(!f.matches(&filed, NOW)); + } + + #[test] + fn inbox_view_is_unfiled_and_ungated() { + let spec = builtin("inbox").expect("inbox view exists"); + assert!(spec.unfiled); + assert!(!spec.actionable); + } + #[test] fn exclude_drops_listed_projects_but_keeps_projectless() { let f = ListFilter { @@ -276,6 +322,7 @@ mod tests { 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); + assert_eq!(builtin("inbox").unwrap().title, "Inbox"); + assert_eq!(BUILTIN_VIEWS.len(), 6); } } diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 197fc45..22bfdaa 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -416,6 +416,7 @@ pub(super) fn view( scope, exclude_projects, actionable: spec.actionable, + unfiled: spec.unfiled, }; list(conn, owner, now, &filter) } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 2355a0c..c79570f 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -179,8 +179,8 @@ fn moving_the_sidebar_switches_the_task_list() { #[test] fn sidebar_skips_headers_into_the_projects_section() { let mut app = App::new(fixture()).unwrap(); - // 5 built-in views: step down 5 times crosses the "Projects" header to p1. - for _ in 0..5 { + // 6 built-in views: step down 6 times crosses the "Projects" header to p1. + for _ in 0..6 { app.move_sidebar(1); } assert_eq!(app.task_pane_title(), "Camano"); @@ -231,7 +231,7 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { let mut app = App::new(fake).unwrap(); // Select the project so the new task is filed there. - for _ in 0..5 { + for _ in 0..6 { app.move_sidebar(1); } assert_eq!(app.task_pane_title(), "Camano"); diff --git a/docs/changelog.d/v1-inbox-view.feature.md b/docs/changelog.d/v1-inbox-view.feature.md new file mode 100644 index 0000000..3683314 --- /dev/null +++ b/docs/changelog.d/v1-inbox-view.feature.md @@ -0,0 +1 @@ +- **Inbox view** (§8.2): a sixth built-in filter view, listed below On Deck, showing every outstanding task with **no project** — the capture inbox to triage and file. A new `ListFilter.unfiled` predicate (kept purely in `matches()`) drives it; the `inbox` `ViewSpec` is deliberately un-gated (no attention or do-date filter) so nothing hides from triage. Appears automatically in the `heph-tui` sidebar and `heph view`; `heph view inbox` from the CLI.