From 9a487cbe3bafa5defc8471456d9e79b3fa3f0f96 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 5 Jun 2026 17:44:43 -0700 Subject: [PATCH] feat(heph-tui,heph-pwa): humanized recurrence + indented/counted/scrolling project sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side; no schema or sync change (see hub-spoke-data-evolution). - humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa previews instead of the raw FREQ= string. - project.overview RPC + Store::project_overview: each project's parent (via the existing 'parent' links) and direct outstanding-task count, a read-only query. - TUI sidebar: subprojects indented by depth, per-project counts, wider pane, and ListState + scrollbar so it scrolls instead of clipping on overflow. Tests: humanize parity (Rust + JS), round-trip through parse_recurrence, raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle safety. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/lib.rs | 2 +- crates/heph-core/src/model.rs | 18 ++ crates/heph-core/src/sqlite/mod.rs | 69 +++++- crates/heph-core/src/sqlite/tasks.rs | 55 ++++- crates/heph-core/src/store.rs | 10 +- crates/heph-tui/src/app.rs | 161 ++++++++++-- crates/heph-tui/src/backend.rs | 21 ++ crates/heph-tui/src/ui.rs | 84 ++++++- crates/heph-tui/tests/agenda.rs | 7 +- crates/hephd/src/datespec.rs | 233 ++++++++++++++++++ crates/hephd/src/remote.rs | 4 + crates/hephd/src/rpc.rs | 1 + .../tui-polish-project-tree.doc.md | 1 + .../tui-polish-project-tree.feature.md | 1 + heph-pwa/src/app.js | 6 +- heph-pwa/src/datespec.js | 140 +++++++++++ heph-pwa/test/parsers.test.mjs | 61 ++++- 17 files changed, 837 insertions(+), 37 deletions(-) create mode 100644 docs/changelog.d/tui-polish-project-tree.doc.md create mode 100644 docs/changelog.d/tui-polish-project-tree.feature.md diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index e5ff637..6554d48 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -38,7 +38,7 @@ pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, - NodeKind, SchedulePatch, SyncCursors, Task, TaskState, + NodeKind, ProjectOverview, SchedulePatch, SyncCursors, Task, TaskState, }; pub use oplog::Op; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 423c96b..783f4cf 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -314,6 +314,24 @@ pub struct Health { pub sync_status: String, } +/// A project plus the two facts a sidebar needs to render it as a counted, +/// indented tree (§8.1): its parent project (via a `parent` link, if any) and +/// the number of outstanding tasks filed **directly** under it. Pure read-side — +/// both derive from existing data, so this carries no schema or sync change (see +/// [[hub-spoke-data-evolution]]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectOverview { + /// The project node id. + pub id: String, + /// The project's title. + pub title: String, + /// The parent project's node id, or `None` for a top-level project. + pub parent_id: Option, + /// Outstanding tasks filed directly in this project (children counted under + /// their own row, not summed here). + pub outstanding: usize, +} + /// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec /// §12). The winning value is already in the store; this records what was /// dropped so `heph conflicts` can show and settle it. diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 28d73b5..af07a8f 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -32,8 +32,8 @@ use crate::error::{Error, Result}; use crate::filter::ListFilter; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, - SyncCursors, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview, + SchedulePatch, SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -297,6 +297,10 @@ impl Store for LocalStore { tasks::health(&self.conn, &self.owner_id) } + fn project_overview(&self) -> Result> { + tasks::project_overview(&self.conn, &self.owner_id) + } + fn search(&self, query: &str) -> Result> { nodes::search(&self.conn, &self.owner_id, query) } @@ -498,6 +502,67 @@ mod tests { assert!(store.project_scope("Nope").is_err()); } + #[test] + fn project_overview_carries_parent_and_direct_outstanding_count() { + use crate::model::{LinkType, NewNode, NewTask, NodeKind, TaskState}; + let mut store = store_at(1); + let mk_proj = |store: &mut LocalStore, title: &str| { + store + .create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id + }; + let mk_task = |store: &mut LocalStore, title: &str, project: Option<&str>| { + store + .create_task(NewTask { + title: title.into(), + attention: None, + do_date: None, + late_on: None, + recurrence: None, + project_id: project.map(String::from), + }) + .unwrap() + .node_id + }; + + let work = mk_proj(&mut store, "Work"); + let sub = mk_proj(&mut store, "Work Sub"); + mk_proj(&mut store, "Garden"); + // Work Sub is a child of Work (child holds the `parent` link → parent). + store.add_link(&sub, &work, LinkType::Parent).unwrap(); + + // Two outstanding + one done in Work; one outstanding in the subproject. + mk_task(&mut store, "ship", Some(&work)); + mk_task(&mut store, "review", Some(&work)); + let done = mk_task(&mut store, "archived", Some(&work)); + store.set_task_state(&done, TaskState::Done).unwrap(); + mk_task(&mut store, "nested", Some(&sub)); + // An unfiled task counts toward no project. + mk_task(&mut store, "loose", None); + + let overview = store.project_overview().unwrap(); + // Title-sorted. + let titles: Vec<_> = overview.iter().map(|p| p.title.as_str()).collect(); + assert_eq!(titles, ["Garden", "Work", "Work Sub"]); + + let by_title = |t: &str| overview.iter().find(|p| p.title == t).unwrap(); + assert_eq!(by_title("Work").outstanding, 2, "done task excluded"); + assert_eq!(by_title("Work").parent_id, None); + assert_eq!( + by_title("Work Sub").outstanding, + 1, + "direct only, not summed" + ); + assert_eq!(by_title("Work Sub").parent_id, Some(work.clone())); + assert_eq!(by_title("Garden").outstanding, 0); + assert_eq!(by_title("Garden").parent_id, None); + } + #[test] fn resolve_project_is_fuzzy_only_when_unambiguous() { use crate::model::{NewNode, NodeKind}; diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 3df2a53..29ac341 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -3,6 +3,8 @@ //! A committed task is a `task` node plus a `tasks` row. On creation it also //! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6). +use std::collections::HashMap; + use rusqlite::{Connection, OptionalExtension, Row}; use serde_json::json; @@ -12,7 +14,7 @@ use crate::error::{Error, Result}; use crate::extract; use crate::filter::ListFilter; use crate::model::{ - Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, + Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState, }; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; @@ -482,6 +484,57 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result { }) } +/// Every project (owner-scoped, non-tombstoned) with its parent project (via a +/// `parent` link) and its direct outstanding-task count — the shape a sidebar +/// renders as a counted, indented tree (§8.1). Pure read-side: counts come from +/// a single `GROUP BY` over the `in-project` links, parents from the `parent` +/// links, both already in the store. Title-sorted for a stable sibling order. +pub(super) fn project_overview(conn: &Connection, owner: &str) -> Result> { + // Direct outstanding count per project: each task's project is its first + // `in-project` link target (mirrors `list`/`load_candidates`). + let mut count_stmt = conn.prepare( + "SELECT (SELECT dst_id FROM links + WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS project_id, + COUNT(*) + FROM nodes n JOIN tasks t ON t.node_id = n.id + WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding' + GROUP BY project_id", + )?; + let mut counts: HashMap = HashMap::new(); + let rows = count_stmt.query_map([owner], |r| { + Ok((r.get::<_, Option>(0)?, r.get::<_, i64>(1)?)) + })?; + for row in rows { + let (project_id, count) = row?; + if let Some(pid) = project_id { + counts.insert(pid, count as usize); + } + } + + // Parent of each project: the dst of its (first) `parent` link. + let mut parent_stmt = conn.prepare( + "SELECT dst_id FROM links + WHERE src_id = ?1 AND type = 'parent' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + )?; + + let mut out = Vec::new(); + for node in nodes::list(conn, owner, Some(NodeKind::Project))? { + let parent_id = parent_stmt + .query_row([&node.id], |r| r.get::<_, String>(0)) + .optional()?; + out.push(ProjectOverview { + outstanding: counts.get(&node.id).copied().unwrap_or(0), + id: node.id, + title: node.title, + parent_id, + }); + } + out.sort_by(|a, b| a.title.cmp(&b.title)); + Ok(out) +} + /// Load every non-tombstoned committed task for `owner` as a ranking candidate, /// joining in its project and canonical-context link targets. fn load_candidates(conn: &Connection, owner: &str) -> Result> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 72e13d3..473b3ee 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -7,8 +7,8 @@ use crate::error::Result; use crate::filter::ListFilter; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, - SyncCursors, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview, + SchedulePatch, SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -142,6 +142,12 @@ pub trait Store { /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; + /// Every project with its parent (via a `parent` link) and its direct + /// outstanding-task count — the shape a sidebar renders as a counted, + /// indented tree (§8.1). Read-only over existing data; no schema or sync + /// change (see [[hub-spoke-data-evolution]]). + fn project_overview(&self) -> Result>; + /// Full-text search over title + body (FTS5), owner-scoped, best-match /// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax. fn search(&self, query: &str) -> Result>; diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 2a89cdd..3ced067 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use anyhow::Result; use chrono::NaiveDate; -use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; +use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; use crate::backend::{Backend, Project, SearchHit}; use crate::fmt::{days_overdue, today_local}; @@ -313,8 +313,18 @@ pub enum Focus { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SidebarEntry { Header(String), - View { name: String, title: String }, - Project { id: String, title: String }, + View { + name: String, + title: String, + }, + /// A project row. `depth` is its nesting level (0 = top-level) for indent; + /// `count` is its direct outstanding-task count, shown as a trailing chip. + Project { + id: String, + title: String, + depth: u16, + count: usize, + }, } impl SidebarEntry { @@ -323,6 +333,70 @@ impl SidebarEntry { } } +/// Turn the daemon's flat (title-sorted) project overview into sidebar rows in +/// tree order — each project followed by its descendants, carrying the nesting +/// `depth` and outstanding `count` the renderer needs. +fn project_entries(overview: Vec) -> Vec { + let order = order_projects(&overview); + let mut overview: Vec> = overview.into_iter().map(Some).collect(); + order + .into_iter() + .map(|(i, depth)| { + let p = overview[i].take().expect("each index visited once"); + SidebarEntry::Project { + id: p.id, + title: p.title, + depth, + count: p.outstanding, + } + }) + .collect() +} + +/// Depth-first display order over the project forest: returns `(index, depth)` +/// pairs, each project ahead of its children, siblings in the input's title +/// order. A project whose parent is missing (tombstoned, or not in the set) +/// renders at the top level; cycles can't loop (each node is emitted once). +fn order_projects(overview: &[ProjectOverview]) -> Vec<(usize, u16)> { + use std::collections::HashSet; + let ids: HashSet<&str> = overview.iter().map(|p| p.id.as_str()).collect(); + let mut children: HashMap<&str, Vec> = HashMap::new(); + let mut roots: Vec = Vec::new(); + for (i, p) in overview.iter().enumerate() { + match &p.parent_id { + Some(pid) if ids.contains(pid.as_str()) => { + children.entry(pid.as_str()).or_default().push(i); + } + _ => roots.push(i), + } + } + let mut out = Vec::with_capacity(overview.len()); + let mut visited = vec![false; overview.len()]; + // Stack of (index, depth); push siblings reversed so we pop in title order. + let mut stack: Vec<(usize, u16)> = roots.iter().rev().map(|&i| (i, 0)).collect(); + while let Some((i, depth)) = stack.pop() { + if visited[i] { + continue; + } + visited[i] = true; + out.push((i, depth)); + if let Some(kids) = children.get(overview[i].id.as_str()) { + for &k in kids.iter().rev() { + if !visited[k] { + stack.push((k, depth + 1)); + } + } + } + } + // Defensive: any node trapped in a parent-cycle still gets one top-level row. + for (i, seen) in visited.iter().enumerate() { + if !seen { + out.push((i, 0)); + } + } + out +} + /// The selected sidebar source, as owned values (so reloads don't hold a borrow /// on `self.sidebar` while calling the backend). enum Target { @@ -376,9 +450,7 @@ impl App { }); } sidebar.push(SidebarEntry::Header("Projects".into())); - for Project { id, title } in backend.projects()? { - sidebar.push(SidebarEntry::Project { id, title }); - } + sidebar.extend(project_entries(backend.project_overview()?)); let sidebar_cursor = sidebar .iter() @@ -423,7 +495,7 @@ impl App { /// The title of a project node id, resolved from the sidebar. pub fn project_name(&self, id: &str) -> Option { self.sidebar.iter().find_map(|e| match e { - SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()), + SidebarEntry::Project { id: pid, title, .. } if pid == id => Some(title.clone()), _ => None, }) } @@ -469,7 +541,7 @@ impl App { self.sidebar .iter() .filter_map(|e| match e { - SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())), + SidebarEntry::Project { id, title, .. } => Some((id.clone(), title.clone())), _ => None, }) .collect() @@ -742,7 +814,7 @@ impl App { /// become unfiled (they move to the Inbox), not deleted. pub fn begin_delete_project(&mut self) { match self.sidebar.get(self.sidebar_cursor) { - Some(SidebarEntry::Project { id, title }) => { + Some(SidebarEntry::Project { id, title, .. }) => { self.pending_delete = Some(PendingDelete::Project { project_id: id.clone(), title: title.clone(), @@ -881,10 +953,8 @@ impl App { .filter(|e| !matches!(e, SidebarEntry::Project { .. })) .cloned() .collect(); - if let Ok(projects) = self.backend.projects() { - for Project { id, title } in projects { - rebuilt.push(SidebarEntry::Project { id, title }); - } + if let Ok(overview) = self.backend.project_overview() { + rebuilt.extend(project_entries(overview)); } self.sidebar = rebuilt; // Restore the cursor: same entry if present, else the nearest selectable @@ -923,7 +993,7 @@ impl App { self.sidebar .iter() .filter_map(|e| match e { - SidebarEntry::Project { id, title } => Some(Project { + SidebarEntry::Project { id, title, .. } => Some(Project { id: id.clone(), title: title.clone(), }), @@ -1213,4 +1283,67 @@ mod sort_tests { // 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"]); } + + fn po(id: &str, title: &str, parent: Option<&str>, outstanding: usize) -> ProjectOverview { + ProjectOverview { + id: id.into(), + title: title.into(), + parent_id: parent.map(str::to_string), + outstanding, + } + } + + #[test] + fn project_entries_nest_children_under_parents_with_depth_and_count() { + // Input arrives title-sorted from the daemon. + let overview = vec![ + po("g", "Garden", None, 0), + po("w", "Work", None, 2), + po("ws", "Work Sub", Some("w"), 1), + po("wsx", "Work Sub Sub", Some("ws"), 5), + ]; + let rows: Vec<(String, u16, usize)> = project_entries(overview) + .into_iter() + .map(|e| match e { + SidebarEntry::Project { + title, + depth, + count, + .. + } => (title, depth, count), + _ => unreachable!("project_entries yields only Project rows"), + }) + .collect(); + assert_eq!( + rows, + vec![ + ("Garden".into(), 0, 0), + ("Work".into(), 0, 2), + ("Work Sub".into(), 1, 1), + ("Work Sub Sub".into(), 2, 5), + ] + ); + } + + #[test] + fn project_entries_treat_a_missing_parent_as_top_level() { + // A child whose parent isn't in the set (e.g. tombstoned) still shows. + let overview = vec![po("orphan", "Orphan", Some("gone"), 3)]; + let rows = project_entries(overview); + assert!(matches!( + rows.as_slice(), + [SidebarEntry::Project { + depth: 0, + count: 3, + .. + }] + )); + } + + #[test] + fn order_projects_does_not_loop_on_a_parent_cycle() { + // a→b→a is pathological but must still terminate, each row once. + let overview = vec![po("a", "A", Some("b"), 0), po("b", "B", Some("a"), 0)]; + assert_eq!(order_projects(&overview).len(), 2); + } } diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 784c520..88beaaa 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -26,6 +26,22 @@ pub struct SearchHit { pub trait Backend { /// All project nodes (for the sidebar), title-sorted. fn projects(&mut self) -> Result>; + /// Projects enriched with parent + direct outstanding-task count, for the + /// indented, counted sidebar tree (§8.1). The default derives a flat list + /// from [`projects`](Self::projects); the real backend forwards the + /// dedicated `project.overview` RPC. + fn project_overview(&mut self) -> Result> { + Ok(self + .projects()? + .into_iter() + .map(|p| heph_core::ProjectOverview { + id: p.id, + title: p.title, + parent_id: None, + outstanding: 0, + }) + .collect()) + } /// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2). fn view(&mut self, name: &str) -> Result>; /// Run a raw [`ListFilter`] (used for per-project scope). @@ -103,6 +119,11 @@ impl Backend for ClientBackend { Ok(projects) } + fn project_overview(&mut self) -> Result> { + let v = self.call("project.overview", json!({}))?; + Ok(serde_json::from_value(v)?) + } + fn view(&mut self, name: &str) -> Result> { let v = self.call("view", json!({ "name": name }))?; Ok(serde_json::from_value(v)?) diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 23305c6..e6043b8 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -37,7 +37,7 @@ pub fn render(frame: &mut Frame, app: &App) { let panes = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(22), + Constraint::Length(28), Constraint::Min(28), Constraint::Length(38), ]) @@ -151,8 +151,25 @@ fn pane_border(focused: bool) -> Style { } } +/// The (label, trailing-count) styles for a sidebar row given its selection +/// state: a full-width cyan bar when focus-selected, reversed when selected in +/// the unfocused pane, otherwise plain with a dimmed count. +fn sidebar_row_styles(selected: bool, focused: bool) -> (Style, Style) { + if selected { + let s = if focused { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().add_modifier(Modifier::REVERSED) + }; + (s, s) + } else { + (Style::default(), Style::default().fg(Color::DarkGray)) + } +} + fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { let focused = app.focus == Focus::Sidebar; + let width = area.width.saturating_sub(2) as usize; // inside borders let items: Vec = app .sidebar .iter() @@ -166,17 +183,38 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ))), - SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => { - let mut style = Style::default(); - if selected { - style = if focused { - style.fg(Color::Black).bg(Color::Cyan) - } else { - style.add_modifier(Modifier::REVERSED) - }; - } + SidebarEntry::View { title, .. } => { + let (style, _) = sidebar_row_styles(selected, focused); ListItem::new(Line::from(Span::styled(format!(" {title}"), style))) } + SidebarEntry::Project { + title, + depth, + count, + .. + } => { + // Indent two columns per nesting level (one base level so a + // top-level project still clears the pane border). + let indent = " ".repeat(1 + *depth as usize); + // A right-aligned outstanding-task count (blank when zero). + let count_str = if *count > 0 { + format!(" {count}") + } else { + String::new() + }; + let label_w = width.saturating_sub(count_str.chars().count()); + let title_room = label_w.saturating_sub(indent.chars().count()); + let title_trunc: String = title.chars().take(title_room).collect(); + let mut label = format!("{indent}{title_trunc}"); + let pad = label_w.saturating_sub(label.chars().count()); + label.push_str(&" ".repeat(pad)); + + let (label_style, count_style) = sidebar_row_styles(selected, focused); + ListItem::new(Line::from(vec![ + Span::styled(label, label_style), + Span::styled(count_str, count_style), + ])) + } } }) .collect(); @@ -187,7 +225,29 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { .border_style(pane_border(focused)) .title(" Views "), ); - frame.render_widget(list, area); + // Drive scroll-to-visible off the cursor so projects below the fold stay + // reachable; the row's own highlight remains the selection cue. + let mut state = ListState::default(); + state.select(Some(app.sidebar_cursor)); + frame.render_stateful_widget(list, area, &mut state); + + // A scrollbar once the entries can't all fit at once (position tracks the + // cursor — an honest "where am I in the list" signal). + let inner_h = area.height.saturating_sub(2) as usize; + if app.sidebar.len() > inner_h { + let mut sb = ScrollbarState::new(app.sidebar.len()).position(app.sidebar_cursor); + let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None); + frame.render_stateful_widget( + bar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut sb, + ); + } } /// A dimmed `──── Project ────` group header for the project sort mode, padded @@ -239,7 +299,7 @@ fn task_detail_lines( } } if let Some(rrule) = &t.recurrence { - field("recurs:", rrule.clone()); + field("recurs:", hephd::datespec::humanize_rrule(rrule)); } if let Some(d) = t.do_date { field("do:", fmt_date(d, today)); diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 89399e4..917f602 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -206,7 +206,12 @@ fn recurring_task_shows_glyph_and_selected_detail_block() { assert!(s.contains('↻'), "recurrence glyph missing:\n{s}"); // ...and the selected task's inline detail block (cursor starts on row 0). assert!(s.contains("recurs:"), "no recurrence detail:\n{s}"); - assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}"); + // The RRULE is humanized for display (§8.1), not shown raw. + assert!(s.contains("daily"), "recurrence not humanized:\n{s}"); + assert!( + !s.contains("FREQ=DAILY"), + "raw rrule leaked into detail:\n{s}" + ); assert!(s.contains("project:"), "no project detail:\n{s}"); assert!(s.contains("Routines"), "project name missing:\n{s}"); } diff --git a/crates/hephd/src/datespec.rs b/crates/hephd/src/datespec.rs index 71d23e6..fa91611 100644 --- a/crates/hephd/src/datespec.rs +++ b/crates/hephd/src/datespec.rs @@ -288,6 +288,172 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> { None } +// --------------------------------------------------------------------------- +// Reverse datespec: humanize an RRULE for display (§8.1). +// --------------------------------------------------------------------------- + +/// Render an RFC-5545 RRULE back into the compact human phrasing the owner would +/// have typed — the inverse of [`parse_recurrence`] for the forms it produces: +/// `daily`, `every 3 days`, `every other day`, `weekly`, `every other week`, +/// `weekdays`, `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, +/// `monthly on the 5th`, `yearly on Apr 15`. Any rule that uses parts we don't +/// model (`COUNT`, `UNTIL`, `BYSETPOS`, ordinal `BYDAY` like `2MO`, …) is +/// returned **verbatim** so nothing is silently hidden from the reader. +pub fn humanize_rrule(rrule: &str) -> String { + humanize_known(rrule).unwrap_or_else(|| rrule.trim().to_string()) +} + +/// The fallible core: `None` whenever the rule contains anything we don't model, +/// so [`humanize_rrule`] can fall back to the raw text. +fn humanize_known(rrule: &str) -> Option { + let mut freq: Option = None; + let mut interval: u32 = 1; + let mut byday: Option = None; + let mut bymonth: Option = None; + let mut bymonthday: Option = None; + for part in rrule.trim().split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (k, v) = part.split_once('=')?; + match k.trim().to_uppercase().as_str() { + "FREQ" => freq = Some(v.trim().to_uppercase()), + "INTERVAL" => interval = v.trim().parse().ok()?, + "BYDAY" => byday = Some(v.trim().to_uppercase()), + "BYMONTH" => bymonth = Some(v.trim().parse().ok()?), + "BYMONTHDAY" => bymonthday = Some(v.trim().parse().ok()?), + // A part we don't render → don't risk a misleading summary. + _ => return None, + } + } + + match freq?.as_str() { + "DAILY" => { + if byday.is_some() || bymonth.is_some() || bymonthday.is_some() { + return None; + } + Some(every_unit(interval, "day", "days", "daily")) + } + "WEEKLY" => { + if bymonth.is_some() || bymonthday.is_some() { + return None; + } + match byday { + None => Some(every_unit(interval, "week", "weeks", "weekly")), + Some(days) => { + if interval == 1 && is_weekday_set(&days) { + return Some("weekdays".into()); + } + let names = weekday_names(&days)?; + if names.len() == 1 { + let day = names[0]; + Some(match interval { + 1 => format!("every {day}"), + 2 => format!("every other {day}"), + n => format!("every {n} weeks on {day}"), + }) + } else { + let joined = names.join(", "); + Some(match interval { + 1 => format!("weekly on {joined}"), + 2 => format!("every other week on {joined}"), + n => format!("every {n} weeks on {joined}"), + }) + } + } + } + } + "MONTHLY" => { + if byday.is_some() || bymonth.is_some() { + return None; + } + match bymonthday { + None => Some(every_unit(interval, "month", "months", "monthly")), + Some(d @ 1..=31) => { + let day = ordinal(d as u32); + Some(match interval { + 1 => format!("monthly on the {day}"), + 2 => format!("every other month on the {day}"), + n => format!("every {n} months on the {day}"), + }) + } + Some(_) => None, // negative / out-of-range day-of-month → raw + } + } + "YEARLY" => { + if byday.is_some() { + return None; + } + match (bymonth, bymonthday) { + (None, None) => Some(every_unit(interval, "year", "years", "yearly")), + (Some(m @ 1..=12), Some(d @ 1..=31)) => { + let mon = MONTH_ABBR[(m - 1) as usize]; + Some(match interval { + 1 => format!("yearly on {mon} {d}"), + 2 => format!("every other year on {mon} {d}"), + n => format!("every {n} years on {mon} {d}"), + }) + } + _ => None, + } + } + _ => None, + } +} + +const MONTH_ABBR: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// `preset` for `n == 1`, `every other ` for 2, `every N ` otherwise. +fn every_unit(n: u32, singular: &str, plural: &str, preset: &str) -> String { + match n { + 1 => preset.to_string(), + 2 => format!("every other {singular}"), + n => format!("every {n} {plural}"), + } +} + +/// `1st`, `2nd`, `3rd`, `4th`, … `11th`, `21st`, `22nd`. +fn ordinal(n: u32) -> String { + let suffix = match (n % 10, n % 100) { + (_, 11..=13) => "th", + (1, _) => "st", + (2, _) => "nd", + (3, _) => "rd", + _ => "th", + }; + format!("{n}{suffix}") +} + +/// `MO,TU,WE,TH,FR` in any order (and only those), the inverse of the `weekdays` +/// preset. +fn is_weekday_set(byday: &str) -> bool { + let mut days: Vec<&str> = byday.split(',').map(str::trim).collect(); + days.sort_unstable(); + days == ["FR", "MO", "TH", "TU", "WE"] +} + +/// `BYDAY` tokens → capitalized weekday abbreviations, order preserved. `None` if +/// any token isn't a bare weekday (e.g. an ordinal `2MO`), so the caller falls +/// back to the raw rule. +fn weekday_names(byday: &str) -> Option> { + byday + .split(',') + .map(|t| match t.trim() { + "MO" => Some("Mon"), + "TU" => Some("Tue"), + "WE" => Some("Wed"), + "TH" => Some("Thu"), + "FR" => Some("Fri"), + "SA" => Some("Sat"), + "SU" => Some("Sun"), + _ => None, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -404,4 +570,71 @@ mod tests { ); assert!(parse_recurrence("every blue moon").is_err()); } + + #[test] + fn humanize_inverts_the_natural_language_forms() { + let cases = [ + ("FREQ=DAILY", "daily"), + ("FREQ=DAILY;INTERVAL=2", "every other day"), + ("FREQ=DAILY;INTERVAL=3", "every 3 days"), + ("FREQ=WEEKLY", "weekly"), + ("FREQ=WEEKLY;INTERVAL=2", "every other week"), + ("FREQ=MONTHLY", "monthly"), + ("FREQ=MONTHLY;INTERVAL=6", "every 6 months"), + ("FREQ=YEARLY", "yearly"), + ("FREQ=WEEKLY;BYDAY=FR", "every Fri"), + ("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"), + ("FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"), + ("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"), + ("FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"), + ("FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"), + ("FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"), + ("FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"), + ("FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"), + ("FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"), + ]; + for (rrule, want) in cases { + assert_eq!(humanize_rrule(rrule), want, "humanizing {rrule}"); + } + } + + #[test] + fn humanize_round_trips_through_parse_recurrence() { + // For the interval/weekday forms the display text is itself a valid input: + // owner types text → we store an RRULE → we show it back → it re-parses to + // the same rule. (The `yearly on Apr 15` / `monthly on the 5th` forms are + // tuned for reading, not re-typing — the stored RRULE, never this string, + // is what gets parsed — so they're covered by the exact-output test above.) + for input in [ + "every 3 days", + "every other day", + "every other wed", + "weekdays", + "every fri", + "every 6 months", + "every 2 weeks", + ] { + let rrule = parse_recurrence(input).unwrap(); + let shown = humanize_rrule(&rrule); + assert_eq!( + parse_recurrence(&shown).unwrap(), + rrule, + "{input:?} → {rrule:?} → shown {shown:?} must re-parse to the same rule" + ); + } + } + + #[test] + fn humanize_falls_back_to_raw_for_unmodeled_rules() { + // COUNT/UNTIL/BYSETPOS and ordinal BYDAY would be misleading if dropped. + for raw in [ + "FREQ=DAILY;COUNT=5", + "FREQ=WEEKLY;UNTIL=20261231T000000Z", + "FREQ=MONTHLY;BYDAY=2MO", + "FREQ=MONTHLY;BYMONTHDAY=-1", + "not an rrule at all", + ] { + assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through"); + } + } } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 43f8ad4..45a581d 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -221,6 +221,10 @@ impl Store for RemoteStore { self.call_as("health", json!({})) } + fn project_overview(&self) -> Result> { + self.call_as("project.overview", json!({})) + } + fn search(&self, query: &str) -> Result> { self.call_as("search", json!({ "query": query })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index bc29eca..e46f7a6 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -352,6 +352,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!(store.project_overview()?), "task.create" => { let p: NewTask = parse(params)?; json!(store.create_task(p)?) diff --git a/docs/changelog.d/tui-polish-project-tree.doc.md b/docs/changelog.d/tui-polish-project-tree.doc.md new file mode 100644 index 0000000..3d607c2 --- /dev/null +++ b/docs/changelog.d/tui-polish-project-tree.doc.md @@ -0,0 +1 @@ +New explanation card [[hub-spoke-data-evolution]] covering why heph's op-based sync lets most new features ship without a coordinated migration, and the narrow case (a new required SQLite column) that does need a hub-first rollout. diff --git a/docs/changelog.d/tui-polish-project-tree.feature.md b/docs/changelog.d/tui-polish-project-tree.feature.md new file mode 100644 index 0000000..bd76951 --- /dev/null +++ b/docs/changelog.d/tui-polish-project-tree.feature.md @@ -0,0 +1 @@ +Recurring tasks now show their schedule in plain language (`every other week`, `weekdays`, `yearly on Apr 15`) instead of a raw RRULE — in both the TUI detail pane and the mobile PWA. The TUI's project sidebar gained subproject indentation, per-project outstanding-task counts, a wider pane, and scrolling when the list overflows. diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js index 32820e0..4452c89 100644 --- a/heph-pwa/src/app.js +++ b/heph-pwa/src/app.js @@ -8,7 +8,7 @@ import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js"; import * as oauth from "./oauth.js"; import { parse as quickParse } from "./quickadd.js"; -import { today, parseDate, toEpochMs } from "./datespec.js"; +import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js"; import { ATTENTION_COLORS, fmtRelative, @@ -212,7 +212,7 @@ function taskRow(t) { function taskDetail(t) { const meta = []; if (t.project_id) meta.push(["project", projectTitle(t.project_id)]); - if (t.recurrence) meta.push(["recurs", t.recurrence]); + if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]); if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]); if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]); @@ -373,7 +373,7 @@ function openQuickAdd() { } if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate))); if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId))); - if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + parsed.recurrence)); + if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence))); }; input.addEventListener("input", updatePreview); input.addEventListener("keydown", (e) => { diff --git a/heph-pwa/src/datespec.js b/heph-pwa/src/datespec.js index 7c2986a..afe798a 100644 --- a/heph-pwa/src/datespec.js +++ b/heph-pwa/src/datespec.js @@ -219,3 +219,143 @@ export function parseRecurrenceOrNull(spec) { return null; } } + +// --------------------------------------------------------------------------- +// Reverse: humanize an RRULE for display (§8.1) — a faithful port of hephd's +// `datespec::humanize_rrule`. +// --------------------------------------------------------------------------- + +const MONTH_ABBR = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; +const DAY_ABBR = { MO: "Mon", TU: "Tue", WE: "Wed", TH: "Thu", FR: "Fri", SA: "Sat", SU: "Sun" }; + +function everyUnit(n, singular, plural, preset) { + if (n === 1) return preset; + if (n === 2) return `every other ${singular}`; + return `every ${n} ${plural}`; +} + +function ordinal(n) { + const tens = n % 100; + if (tens >= 11 && tens <= 13) return `${n}th`; + switch (n % 10) { + case 1: return `${n}st`; + case 2: return `${n}nd`; + case 3: return `${n}rd`; + default: return `${n}th`; + } +} + +function isWeekdaySet(byday) { + const days = byday.split(",").map((s) => s.trim()).sort(); + return days.join(",") === "FR,MO,TH,TU,WE"; +} + +/** BYDAY tokens → capitalized weekday abbreviations, order preserved, or null + * if any token isn't a bare weekday (e.g. an ordinal `2MO`). */ +function weekdayNames(byday) { + const out = []; + for (const tok of byday.split(",")) { + const name = DAY_ABBR[tok.trim()]; + if (!name) return null; + out.push(name); + } + return out; +} + +/** + * Render an RFC-5545 RRULE back into the compact phrasing `parseRecurrence` + * accepts — `daily`, `every 3 days`, `every other week`, `weekdays`, + * `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, `monthly on the + * 5th`, `yearly on Apr 15`. Any rule using parts we don't model (COUNT, UNTIL, + * ordinal BYDAY, …) is returned **verbatim** so nothing is silently hidden. + */ +export function humanizeRecurrence(rrule) { + const known = humanizeKnown(rrule); + return known === null ? rrule.trim() : known; +} + +function humanizeKnown(rrule) { + let freq = null; + let interval = 1; + let byday = null; + let bymonth = null; + let bymonthday = null; + for (const rawPart of rrule.trim().split(";")) { + const part = rawPart.trim(); + if (part === "") continue; + const eq = part.indexOf("="); + if (eq === -1) return null; + const k = part.slice(0, eq).trim().toUpperCase(); + const v = part.slice(eq + 1).trim(); + switch (k) { + case "FREQ": freq = v.toUpperCase(); break; + case "INTERVAL": { + const n = Number(v); + if (!Number.isInteger(n) || n < 1) return null; + interval = n; + break; + } + case "BYDAY": byday = v.toUpperCase(); break; + case "BYMONTH": { + const n = Number(v); + if (!Number.isInteger(n)) return null; + bymonth = n; + break; + } + case "BYMONTHDAY": { + const n = Number(v); + if (!Number.isInteger(n)) return null; + bymonthday = n; + break; + } + default: return null; // a part we don't render → don't risk a wrong summary + } + } + + switch (freq) { + case "DAILY": + if (byday !== null || bymonth !== null || bymonthday !== null) return null; + return everyUnit(interval, "day", "days", "daily"); + case "WEEKLY": { + if (bymonth !== null || bymonthday !== null) return null; + if (byday === null) return everyUnit(interval, "week", "weeks", "weekly"); + if (interval === 1 && isWeekdaySet(byday)) return "weekdays"; + const names = weekdayNames(byday); + if (names === null) return null; + if (names.length === 1) { + const day = names[0]; + if (interval === 1) return `every ${day}`; + if (interval === 2) return `every other ${day}`; + return `every ${interval} weeks on ${day}`; + } + const joined = names.join(", "); + if (interval === 1) return `weekly on ${joined}`; + if (interval === 2) return `every other week on ${joined}`; + return `every ${interval} weeks on ${joined}`; + } + case "MONTHLY": { + if (byday !== null || bymonth !== null) return null; + if (bymonthday === null) return everyUnit(interval, "month", "months", "monthly"); + if (bymonthday < 1 || bymonthday > 31) return null; + const day = ordinal(bymonthday); + if (interval === 1) return `monthly on the ${day}`; + if (interval === 2) return `every other month on the ${day}`; + return `every ${interval} months on the ${day}`; + } + case "YEARLY": { + if (byday !== null) return null; + if (bymonth === null && bymonthday === null) { + return everyUnit(interval, "year", "years", "yearly"); + } + if (bymonth < 1 || bymonth > 12 || bymonthday < 1 || bymonthday > 31) return null; + const mon = MONTH_ABBR[bymonth - 1]; + if (interval === 1) return `yearly on ${mon} ${bymonthday}`; + if (interval === 2) return `every other year on ${mon} ${bymonthday}`; + return `every ${interval} years on ${mon} ${bymonthday}`; + } + default: return null; + } +} diff --git a/heph-pwa/test/parsers.test.mjs b/heph-pwa/test/parsers.test.mjs index 7c006d0..cd984fc 100644 --- a/heph-pwa/test/parsers.test.mjs +++ b/heph-pwa/test/parsers.test.mjs @@ -3,7 +3,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js"; +import { + parseDate, + parseRecurrence, + humanizeRecurrence, + toEpochMs, +} from "../src/datespec.js"; import { parse } from "../src/quickadd.js"; const d = (y, m, day) => new Date(y, m - 1, day); @@ -58,6 +63,60 @@ test("recurrence natural language", () => { assert.throws(() => parseRecurrence("every blue moon")); }); +// Parity with hephd's `humanize_rrule` unit tests (datespec.rs). +test("humanize inverts the natural-language forms", () => { + const cases = [ + ["FREQ=DAILY", "daily"], + ["FREQ=DAILY;INTERVAL=2", "every other day"], + ["FREQ=DAILY;INTERVAL=3", "every 3 days"], + ["FREQ=WEEKLY", "weekly"], + ["FREQ=WEEKLY;INTERVAL=2", "every other week"], + ["FREQ=MONTHLY", "monthly"], + ["FREQ=MONTHLY;INTERVAL=6", "every 6 months"], + ["FREQ=YEARLY", "yearly"], + ["FREQ=WEEKLY;BYDAY=FR", "every Fri"], + ["FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"], + ["FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"], + ["FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"], + ["FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"], + ["FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"], + ["FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"], + ["FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"], + ["FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"], + ["FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"], + ]; + for (const [rrule, want] of cases) { + assert.equal(humanizeRecurrence(rrule), want, `humanizing ${rrule}`); + } +}); + +test("humanize round-trips the re-typeable forms through parseRecurrence", () => { + for (const input of [ + "every 3 days", + "every other day", + "every other wed", + "weekdays", + "every fri", + "every 6 months", + "every 2 weeks", + ]) { + const rrule = parseRecurrence(input); + assert.equal(parseRecurrence(humanizeRecurrence(rrule)), rrule, `round-trip ${input}`); + } +}); + +test("humanize falls back to raw for unmodeled rules", () => { + for (const raw of [ + "FREQ=DAILY;COUNT=5", + "FREQ=WEEKLY;UNTIL=20261231T000000Z", + "FREQ=MONTHLY;BYDAY=2MO", + "FREQ=MONTHLY;BYMONTHDAY=-1", + "not an rrule at all", + ]) { + assert.equal(humanizeRecurrence(raw), raw, `passthrough ${raw}`); + } +}); + // quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores. const QTODAY = d(2026, 6, 3); const PROJECTS = [