generated from eblume/project-template
heph-tui + PWA cosmetic polish: humanized recurrence, scrolling/indented/counted project sidebar #10
17 changed files with 837 additions and 37 deletions
feat(heph-tui,heph-pwa): humanized recurrence + indented/counted/scrolling project sidebar
All checks were successful
Build / validate (pull_request) Successful in 6m57s
All checks were successful
Build / validate (pull_request) Successful in 6m57s
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) <noreply@anthropic.com>
commit
9a487cbe3b
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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<Vec<ProjectOverview>> {
|
||||
tasks::project_overview(&self.conn, &self.owner_id)
|
||||
}
|
||||
|
||||
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||
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};
|
||||
|
|
|
|||
|
|
@ -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<Health> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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<Vec<ProjectOverview>> {
|
||||
// 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<String, usize> = HashMap::new();
|
||||
let rows = count_stmt.query_map([owner], |r| {
|
||||
Ok((r.get::<_, Option<String>>(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<Vec<RankedTask>> {
|
||||
|
|
|
|||
|
|
@ -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<Health>;
|
||||
|
||||
/// 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<Vec<ProjectOverview>>;
|
||||
|
||||
/// 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<Vec<Node>>;
|
||||
|
|
|
|||
|
|
@ -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<ProjectOverview>) -> Vec<SidebarEntry> {
|
||||
let order = order_projects(&overview);
|
||||
let mut overview: Vec<Option<ProjectOverview>> = 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<usize>> = HashMap::new();
|
||||
let mut roots: Vec<usize> = 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<B: Backend> App<B> {
|
|||
});
|
||||
}
|
||||
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<B: Backend> App<B> {
|
|||
/// The title of a project node id, resolved from the sidebar.
|
||||
pub fn project_name(&self, id: &str) -> Option<String> {
|
||||
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<B: Backend> App<B> {
|
|||
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<B: Backend> App<B> {
|
|||
/// 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<B: Backend> App<B> {
|
|||
.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<B: Backend> App<B> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,22 @@ pub struct SearchHit {
|
|||
pub trait Backend {
|
||||
/// All project nodes (for the sidebar), title-sorted.
|
||||
fn projects(&mut self) -> Result<Vec<Project>>;
|
||||
/// 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<Vec<heph_core::ProjectOverview>> {
|
||||
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<Vec<RankedTask>>;
|
||||
/// 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<Vec<heph_core::ProjectOverview>> {
|
||||
let v = self.call("project.overview", json!({}))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
||||
let v = self.call("view", json!({ "name": name }))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
|||
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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let focused = app.focus == Focus::Sidebar;
|
||||
let width = area.width.saturating_sub(2) as usize; // inside borders
|
||||
let items: Vec<ListItem> = app
|
||||
.sidebar
|
||||
.iter()
|
||||
|
|
@ -166,17 +183,38 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(
|
|||
}
|
||||
}
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
let mut freq: Option<String> = None;
|
||||
let mut interval: u32 = 1;
|
||||
let mut byday: Option<String> = None;
|
||||
let mut bymonth: Option<u32> = None;
|
||||
let mut bymonthday: Option<i32> = 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 <singular>` for 2, `every N <plural>` 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<Vec<&'static str>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,6 +221,10 @@ impl Store for RemoteStore {
|
|||
self.call_as("health", json!({}))
|
||||
}
|
||||
|
||||
fn project_overview(&self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||
self.call_as("project.overview", json!({}))
|
||||
}
|
||||
|
||||
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||
self.call_as("search", json!({ "query": query }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
let p: NodeListParams = parse(params)?;
|
||||
json!(store.list_nodes(p.kind)?)
|
||||
}
|
||||
"project.overview" => json!(store.project_overview()?),
|
||||
"task.create" => {
|
||||
let p: NewTask = parse(params)?;
|
||||
json!(store.create_task(p)?)
|
||||
|
|
|
|||
1
docs/changelog.d/tui-polish-project-tree.doc.md
Normal file
1
docs/changelog.d/tui-polish-project-tree.doc.md
Normal file
|
|
@ -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.
|
||||
1
docs/changelog.d/tui-polish-project-tree.feature.md
Normal file
1
docs/changelog.d/tui-polish-project-tree.feature.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue