diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 5673ae0..9b1af11 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -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) -> 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) -> (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, + 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, + 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 { 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, /// When `Some`, a delete is awaiting y/N confirmation. @@ -170,6 +244,7 @@ impl App { 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 App { 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 App { self.reload_preview(); } + /// Project id → title, from the sidebar (for project-aware sorting + the + /// project-mode separators). + fn project_names(&self) -> HashMap { + 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> { match self.current_target() { Some(Target::View(name)) => self.backend.view(&name), @@ -687,3 +797,94 @@ fn parse_optional_date(s: &str) -> Result> { 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, + project: Option<&str>, + do_date: Option, + 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 { + [ + ("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"]); + } +} diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 651d805..3fc7373 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -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, 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 diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index c48a42c..89ee6e6 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -163,7 +163,8 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option 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(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 0aa2182..acf5f35 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -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(frame: &mut Frame, app: &App, 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(frame: &mut Frame, app: &App, 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)); } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index db85c8d..b056f90 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -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| -> Vec { 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; diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 68523d2..6394d78 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -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 (p1–p4) + 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 --project ` 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`**.) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index e7dbca6..d70930f 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -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('')"` (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.