feat(tui): s sort toggle — default vs project-grouped (§8.1)

`s` flips the task list between two orders:
- default: attention (red→orange→white→blue) → most-overdue (desc) →
  project name → created_at (FIFO)
- project: project first, with dimmed ──── Name ──── separators riding
  atop each group's first task (the cursor only lands on real tasks)

The view filter still runs before the sort. Pure comparator (`cmp_tasks`/
`sort_tasks`, today injected) with unit tests for both modes + a
navigation test for the toggle. `skip` moved from `s` to `S` to free `s`.

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

View file

@ -2,10 +2,82 @@
//! [`Backend`] so it is testable without a terminal or a daemon. Rendering
//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`.
use std::cmp::{Ordering, Reverse};
use std::collections::HashMap;
use anyhow::Result;
use chrono::NaiveDate;
use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS};
use crate::backend::{Backend, Project, SearchHit};
use crate::fmt::{days_overdue, today_local};
/// How the task list is ordered (toggled in the UI, §8.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortMode {
/// Attention band → most-overdue → project → creation (FIFO).
Default,
/// Project first (grouped, with separators), then the default sub-order.
Project,
}
/// Attention sort rank: red → orange → white → blue → (none last).
fn attention_rank(a: Option<Attention>) -> u8 {
match a {
Some(Attention::Red) => 0,
Some(Attention::Orange) => 1,
Some(Attention::White) => 2,
Some(Attention::Blue) => 3,
None => 4,
}
}
/// The project sort component: tasks with a project sort first (by lowercased
/// name), project-less tasks last.
fn project_key(t: &RankedTask, names: &HashMap<String, String>) -> (bool, String) {
match &t.project_id {
Some(id) => (
false,
names
.get(id)
.cloned()
.unwrap_or_else(|| id.clone())
.to_lowercase(),
),
None => (true, String::new()),
}
}
/// Total order for the agenda (§8.1). **Default**: attention → most-overdue
/// (descending) → project → FIFO. **Project**: project first, then the same
/// sub-order. `today` resolves "how overdue"; `names` maps project id → title.
pub fn cmp_tasks(
a: &RankedTask,
b: &RankedTask,
names: &HashMap<String, String>,
today: NaiveDate,
mode: SortMode,
) -> Ordering {
let att = || attention_rank(a.attention).cmp(&attention_rank(b.attention));
let over =
|| Reverse(days_overdue(a.do_date, today)).cmp(&Reverse(days_overdue(b.do_date, today)));
let proj = || project_key(a, names).cmp(&project_key(b, names));
let fifo = || a.created_at.cmp(&b.created_at);
match mode {
SortMode::Default => att().then_with(over).then_with(proj).then_with(fifo),
SortMode::Project => proj().then_with(att).then_with(over).then_with(fifo),
}
}
/// Sort `tasks` in place by [`cmp_tasks`].
pub fn sort_tasks(
tasks: &mut [RankedTask],
names: &HashMap<String, String>,
today: NaiveDate,
mode: SortMode,
) {
tasks.sort_by(|a, b| cmp_tasks(a, b, names, today, mode));
}
/// The interaction mode: normal navigation, or collecting a line of text.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -130,6 +202,8 @@ pub struct App<B: Backend> {
pub preview: Preview,
pub focus: Focus,
pub mode: Mode,
/// How the task list is ordered (`s` toggles it).
pub sort_mode: SortMode,
/// When `Some`, a full-text search overlays the task list.
pub search: Option<SearchView>,
/// When `Some`, a delete is awaiting y/N confirmation.
@ -170,6 +244,7 @@ impl<B: Backend> App<B> {
preview: Preview::default(),
focus: Focus::Sidebar,
mode: Mode::Normal,
sort_mode: SortMode::Default,
search: None,
pending_delete: None,
status: String::new(),
@ -226,6 +301,7 @@ impl<B: Backend> App<B> {
match self.load_tasks() {
Ok(tasks) => {
self.tasks = tasks;
self.apply_sort();
if self.task_cursor >= self.tasks.len() {
self.task_cursor = self.tasks.len().saturating_sub(1);
}
@ -235,6 +311,40 @@ impl<B: Backend> App<B> {
self.reload_preview();
}
/// Project id → title, from the sidebar (for project-aware sorting + the
/// project-mode separators).
fn project_names(&self) -> HashMap<String, String> {
self.sidebar
.iter()
.filter_map(|e| match e {
SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())),
_ => None,
})
.collect()
}
/// Re-order the loaded tasks for the current [`SortMode`].
fn apply_sort(&mut self) {
let names = self.project_names();
sort_tasks(&mut self.tasks, &names, today_local(), self.sort_mode);
}
/// Flip between the default and project sort orders (the `s` gesture),
/// re-sorting in place and resetting the cursor to the top.
pub fn toggle_sort(&mut self) {
self.sort_mode = match self.sort_mode {
SortMode::Default => SortMode::Project,
SortMode::Project => SortMode::Default,
};
self.apply_sort();
self.task_cursor = 0;
self.reload_preview();
self.status = match self.sort_mode {
SortMode::Default => "sort: default".into(),
SortMode::Project => "sort: by project".into(),
};
}
fn load_tasks(&mut self) -> Result<Vec<RankedTask>> {
match self.current_target() {
Some(Target::View(name)) => self.backend.view(&name),
@ -687,3 +797,94 @@ fn parse_optional_date(s: &str) -> Result<Option<i64>> {
Ok(Some(hephd::datespec::parse_date_ms(s)?))
}
}
#[cfg(test)]
mod sort_tests {
use super::*;
use heph_core::TaskState;
fn t(
id: &str,
att: Option<Attention>,
project: Option<&str>,
do_date: Option<i64>,
created: i64,
) -> RankedTask {
RankedTask {
node_id: id.into(),
title: id.into(),
attention: att,
do_date,
late_on: None,
state: TaskState::Outstanding,
recurrence: None,
tombstoned: false,
project_id: project.map(str::to_string),
canonical_context_id: None,
created_at: created,
}
}
fn names() -> HashMap<String, String> {
[
("pA".to_string(), "Alpha".to_string()),
("pB".to_string(), "Beta".to_string()),
]
.into_iter()
.collect()
}
fn ids(v: &[RankedTask]) -> Vec<&str> {
v.iter().map(|t| t.node_id.as_str()).collect()
}
fn day(y: i32, m: u32, d: u32) -> i64 {
NaiveDate::from_ymd_opt(y, m, d)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_local_timezone(chrono::Local)
.unwrap()
.timestamp_millis()
}
#[test]
fn default_sort_is_attention_then_overdue_then_project_then_fifo() {
let today = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
let mut tasks = vec![
t("blue", Some(Attention::Blue), Some("pA"), None, 0),
t(
"red_2d",
Some(Attention::Red),
Some("pB"),
Some(day(2026, 6, 1)),
5,
),
t(
"red_4d",
Some(Attention::Red),
Some("pA"),
Some(day(2026, 5, 30)),
9,
),
t("white", Some(Attention::White), None, None, 1),
];
sort_tasks(&mut tasks, &names(), today, SortMode::Default);
// reds first (most-overdue first), then white, then blue.
assert_eq!(ids(&tasks), vec!["red_4d", "red_2d", "white", "blue"]);
}
#[test]
fn project_sort_groups_by_name_then_default_suborder_with_none_last() {
let today = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
let mut tasks = vec![
t("b_white", Some(Attention::White), Some("pB"), None, 0),
t("a_blue", Some(Attention::Blue), Some("pA"), None, 1),
t("a_red", Some(Attention::Red), Some("pA"), None, 2),
t("none_red", Some(Attention::Red), None, None, 3),
];
sort_tasks(&mut tasks, &names(), today, SortMode::Project);
// Alpha group (red before blue), then Beta, then project-less tasks last.
assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]);
}
}

View file

@ -25,6 +25,17 @@ pub fn today_local() -> NaiveDate {
Local::now().date_naive()
}
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
match do_date.and_then(DateTime::from_timestamp_millis) {
Some(dt) => (today - dt.with_timezone(&Local).date_naive())
.num_days()
.max(0),
None => 0,
}
}
/// A stable display color for a project, derived from its node id (§8.1) so the
/// task list's bullets read as project identity. Hashing the id (rather than a
/// position-based palette) keeps each project's color **stable as others are

View file

@ -163,7 +163,8 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
// triage mutations (act on the highlighted task)
KeyCode::Char('x') => app.complete_selected(),
KeyCode::Char('d') => app.drop_selected(),
KeyCode::Char('s') => app.skip_selected(),
KeyCode::Char('S') => app.skip_selected(),
KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('A') => app.cycle_attention_selected(),
KeyCode::Char('b') => app.push_to_blue_selected(),
KeyCode::Char('m') => app.begin_move(),

View file

@ -13,12 +13,12 @@ use ratatui::{
Frame,
};
use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry};
use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry, SortMode};
use crate::backend::Backend;
use crate::fmt::{fmt_date, project_color, today_local};
const HINTS: &str =
" j/k move a add x done e date A attn b→blue D del o edit / search q quit";
" j/k move a add x done S skip e date A attn b→blue m move D del s sort o edit / search q quit";
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
@ -160,6 +160,22 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
frame.render_widget(list, area);
}
/// A dimmed `──── Project ────` group header for the project sort mode, padded
/// to `width` columns with the name centered.
fn project_separator(name: &str, width: usize) -> Line<'static> {
let label = format!(" {name} ");
let dashes = width.saturating_sub(label.chars().count());
let left = dashes / 2;
let right = dashes - left;
let text = format!("{}{}{}", "".repeat(left), label, "".repeat(right));
Line::from(Span::styled(
text,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
))
}
/// The leading flag glyph + color for an attention band: a colored `⚑` for the
/// bands that demand attention (red/orange/blue), blank for white/none. The
/// bullet beside it carries project identity instead (§8.1).
@ -270,7 +286,20 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
}
header.push(Span::styled(chip, chip_style));
let mut lines = vec![Line::from(header)];
let mut lines = Vec::new();
// In project sort, head each project group with a separator row
// (non-selectable — it rides atop the group's first task).
if app.sort_mode == SortMode::Project
&& (i == 0 || app.tasks[i - 1].project_id != t.project_id)
{
let name = t
.project_id
.as_deref()
.and_then(|id| app.project_name(id))
.unwrap_or_else(|| "(No project)".into());
lines.push(project_separator(&name, width));
}
lines.push(Line::from(header));
if selected {
lines.extend(task_detail_lines(app, t, today));
}

View file

@ -366,6 +366,48 @@ fn move_to_project_picker_refiles_the_selected_task() {
assert_eq!(refiled[0], ("t1".into(), Some("p1".into())));
}
#[test]
fn toggle_sort_switches_mode_and_regroups_by_project() {
use heph_tui::app::SortMode;
let mut beta = task("b1", "beta one", Attention::Red, None);
beta.project_id = Some("pB".into());
let mut alpha = task("a1", "alpha one", Attention::White, None);
alpha.project_id = Some("pA".into());
let mut fake = Fake {
projects: vec![
Project {
id: "pB".into(),
title: "Beta".into(),
},
Project {
id: "pA".into(),
title: "Alpha".into(),
},
],
..Default::default()
};
fake.views.insert("tom".into(), vec![beta, alpha]);
let mut app = App::new(fake).unwrap();
let order =
|a: &App<Fake>| -> Vec<String> { a.tasks.iter().map(|t| t.node_id.clone()).collect() };
// Default sort: attention first → red (b1) before white (a1).
assert_eq!(app.sort_mode, SortMode::Default);
assert_eq!(order(&app), vec!["b1", "a1"]);
// Project sort: Alpha group before Beta → a1 before b1.
app.toggle_sort();
assert_eq!(app.sort_mode, SortMode::Project);
assert_eq!(order(&app), vec!["a1", "b1"]);
assert!(app.status.contains("project"), "status: {}", app.status);
// Toggling back restores the default order.
app.toggle_sort();
assert_eq!(app.sort_mode, SortMode::Default);
assert_eq!(order(&app), vec!["b1", "a1"]);
}
#[test]
fn move_to_project_cancel_refiles_nothing() {
use heph_tui::app::Mode;

View file

@ -26,3 +26,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim.
- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap.
- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit.
- `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.)

View file

@ -254,15 +254,14 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba
- **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure.
- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`).
- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)*
- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `S` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `s` **sort toggle** (default ↔ project-grouped) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)*
- **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)*
- **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend.
- **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`.
- **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap.
- **flag column + project-colored bullets** ✅ — a leading **flag glyph** (`⚑`) colored by attention (red/orange/blue; **blank for white/none**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb` truecolor; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement (derived client-side for now).
- **scrollbar** ✅ — a `ratatui` `Scrollbar` on the task list once it overflows (a `ListState` selection drives scroll-to-visible so a task below the fold stays reachable).
- **Planned UX wave** (§14 roadmap, 2026-06-03) — remaining, client-side over the existing `RankedTask` rows:
- **sort toggle `s`****default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort.
- **sort toggle `s`** ✅ — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with dimmed **`──── Name ────` group separators** that ride atop each group's first task (so the cursor only ever lands on real tasks). The view filter always runs before the sort. (`skip` moved to `S`.)
## 8.2 Filter views (saved agenda slices) — built
@ -450,8 +449,8 @@ See [[design]] §5§7 for the constraints later phases impose on present choi
2. **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):**
- ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change).
- ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view.
- **(b) sort toggle `s`****default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort.
- ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit).
- **(b) sort toggle `s` DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.)
- ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). *(The only piece of item 2 left.)*
3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph.
4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3.
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.