feat: Inbox view — outstanding tasks with no project (§8.2)

A sixth built-in filter view (listed below On Deck) showing every
outstanding task filed under no project — the capture inbox to triage. New
ListFilter.unfiled predicate kept purely in matches(); the inbox ViewSpec is
un-gated (no attention/do-date filter) so nothing hides from triage. Surfaces
automatically in the heph-tui sidebar and `heph view`. Tests cover the
predicate and the view spec; navigation tests updated for the 6th view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 18:12:33 -07:00
commit 01ae561a74
4 changed files with 58 additions and 9 deletions

View file

@ -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<String>,
/// 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);
}
}

View file

@ -416,6 +416,7 @@ pub(super) fn view(
scope,
exclude_projects,
actionable: spec.actionable,
unfiled: spec.unfiled,
};
list(conn, owner, now, &filter)
}

View file

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

View file

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