diff --git a/CHANGELOG.md b/CHANGELOG.md index 9799e3f..ad6ed44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,35 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [v1.2.3] - 2026-06-06 - -### Features - -- heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log. - -### Documentation - -- [[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it. - - -## [v1.2.2] - 2026-06-06 - -### Features - -- 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. - -### Documentation - -- 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. - - -## [v1.2.1] - 2026-06-05 - -### Features - -- heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider. - - ## [v1.2.0] - 2026-06-04 ### Features diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 6554d48..e5ff637 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, ProjectOverview, SchedulePatch, SyncCursors, Task, TaskState, + NodeKind, 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 783f4cf..423c96b 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -314,24 +314,6 @@ 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 af07a8f..28d73b5 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, ProjectOverview, - SchedulePatch, SyncCursors, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -297,10 +297,6 @@ 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) } @@ -502,67 +498,6 @@ 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 29ac341..3df2a53 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -3,8 +3,6 @@ //! 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; @@ -14,7 +12,7 @@ use crate::error::{Error, Result}; use crate::extract; use crate::filter::ListFilter; use crate::model::{ - Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState, + Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, }; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; @@ -484,57 +482,6 @@ 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 473b3ee..72e13d3 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, ProjectOverview, - SchedulePatch, SyncCursors, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -142,12 +142,6 @@ 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 e60a969..2a89cdd 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use anyhow::Result; use chrono::NaiveDate; -use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; -use crate::backend::{Backend, Project, SearchHit, SyncStatus}; +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). @@ -313,18 +313,8 @@ pub enum Focus { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SidebarEntry { Header(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, - }, + View { name: String, title: String }, + Project { id: String, title: String }, } impl SidebarEntry { @@ -333,70 +323,6 @@ 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 { @@ -433,8 +359,6 @@ pub struct App { undo_stack: Vec, redo_stack: Vec, pub status: String, - /// Latest sync health for the status-line indicator (refreshed on a tick). - pub sync: SyncStatus, pub should_quit: bool, } @@ -452,7 +376,9 @@ impl App { }); } sidebar.push(SidebarEntry::Header("Projects".into())); - sidebar.extend(project_entries(backend.project_overview()?)); + for Project { id, title } in backend.projects()? { + sidebar.push(SidebarEntry::Project { id, title }); + } let sidebar_cursor = sidebar .iter() @@ -474,23 +400,12 @@ impl App { undo_stack: Vec::new(), redo_stack: Vec::new(), status: String::new(), - sync: SyncStatus::default(), should_quit: false, }; app.reload(); - app.refresh_sync(); Ok(app) } - /// Refresh the sync-health snapshot for the status line. Best-effort: a - /// failed read leaves the previous snapshot in place (a stale indicator - /// beats a flicker), so this never disrupts navigation. - pub fn refresh_sync(&mut self) { - if let Ok(status) = self.backend.sync_status() { - self.sync = status; - } - } - /// The title shown above the task list (the selected source). pub fn task_pane_title(&self) -> String { match self.sidebar.get(self.sidebar_cursor) { @@ -508,7 +423,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, }) } @@ -554,7 +469,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() @@ -827,7 +742,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(), @@ -966,8 +881,10 @@ impl App { .filter(|e| !matches!(e, SidebarEntry::Project { .. })) .cloned() .collect(); - if let Ok(overview) = self.backend.project_overview() { - rebuilt.extend(project_entries(overview)); + if let Ok(projects) = self.backend.projects() { + for Project { id, title } in projects { + rebuilt.push(SidebarEntry::Project { id, title }); + } } self.sidebar = rebuilt; // Restore the cursor: same entry if present, else the nearest selectable @@ -1006,7 +923,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(), }), @@ -1296,67 +1213,4 @@ 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 a52fd90..784c520 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -22,55 +22,10 @@ pub struct SearchHit { pub kind: String, } -/// Sync health for the status line (the `sync.status` RPC). On a standalone -/// instance `hub_url` is `None` and `health` is absent; the conflict count is -/// always present. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] -pub struct SyncStatus { - /// The hub this device syncs with, or `None` if standalone (no indicator). - pub hub_url: Option, - /// Pending merge conflicts awaiting resolution. - #[serde(default)] - pub conflicts: usize, - /// Observed health of the background sync loop (spoke only). - #[serde(default)] - pub health: Option, -} - -/// The spoke's observed sync health (mirrors `hephd`'s `SyncHealth`). -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] -pub struct SyncHealth { - /// Epoch ms of the last successful exchange ("last synced"), if any. - pub last_success_ms: Option, - /// Epoch ms of the last attempt (success or failure), if any. - pub last_attempt_ms: Option, - /// The last error message, cleared on the next success. - pub last_error: Option, - /// Whether the most recent attempt failed authentication (needs re-login). - #[serde(default)] - pub auth_failure: bool, -} - /// Everything the agenda surface asks of the daemon. 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). @@ -85,11 +40,6 @@ pub trait Backend { /// A task's canonical-context doc id (where its description/checklist live), /// for opening a task search-hit at the useful node. `None` if it has none. fn context_of(&mut self, task_id: &str) -> Result>; - /// Sync health for the status line. The default is a standalone instance - /// (no hub, no conflicts); the real backend forwards `sync.status`. - fn sync_status(&mut self) -> Result { - Ok(SyncStatus::default()) - } // --- triage mutations (T2) --- @@ -153,11 +103,6 @@ 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)?) @@ -204,11 +149,6 @@ impl Backend for ClientBackend { .map(|l| l.dst_id)) } - fn sync_status(&mut self) -> Result { - let v = self.call("sync.status", json!({}))?; - Ok(serde_json::from_value(v)?) - } - fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { self.call("task.set_state", json!({ "id": task_id, "state": state }))?; Ok(()) diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 8c49ac9..3fc7373 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -25,29 +25,6 @@ pub fn today_local() -> NaiveDate { Local::now().date_naive() } -/// Now, in epoch milliseconds (the reference for [`fmt_age`]). -pub fn now_ms() -> i64 { - Local::now().timestamp_millis() -} - -/// A compact "how long ago" for the sync indicator: `Ns` under a minute, then -/// `Nm` / `Nh` / `Nd`. Second-granularity under a minute makes the chip a visible -/// heartbeat (the sync loop runs every 30s) and surfaces a missed beat as the age -/// climbing, rather than hiding under a flat "just now". Clamped at zero so a -/// little clock skew never shows a negative age. -pub fn fmt_age(now_ms: i64, then_ms: i64) -> String { - let secs = (now_ms - then_ms).max(0) / 1000; - if secs < 60 { - format!("{secs}s") - } else if secs < 3_600 { - format!("{}m", secs / 60) - } else if secs < 86_400 { - format!("{}h", secs / 3_600) - } else { - format!("{}d", secs / 86_400) - } -} - /// 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 { @@ -125,19 +102,6 @@ mod tests { assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); } - #[test] - fn age_is_compact_and_clamped() { - let now = 1_000_000_000_000; - assert_eq!(fmt_age(now, now), "0s"); - assert_eq!(fmt_age(now, now - 30_000), "30s"); - assert_eq!(fmt_age(now, now - 59_000), "59s"); - assert_eq!(fmt_age(now, now - 5 * 60_000), "5m"); - assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h"); - assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d"); - // Clock skew (then in the future) never shows a negative age. - assert_eq!(fmt_age(now, now + 10_000), "0s"); - } - #[test] fn project_color_is_stable_distinct_and_neutral_when_absent() { assert_eq!(project_color(None), Color::DarkGray); diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b672d7b..27be96e 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -61,22 +61,14 @@ fn run( mut app: App, socket: &std::path::Path, ) -> Result<()> { - // Poll with a timeout so the sync indicator's age advances and a sync - // failure surfaces within a couple of seconds even while the user is idle. - let tick = std::time::Duration::from_secs(2); loop { terminal.draw(|f| ui::render(f, &app))?; - if event::poll(tick)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - if let Some(action) = handle_key(&mut app, key) { - perform(terminal, &mut app, socket, action)?; - } + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if let Some(action) = handle_key(&mut app, key) { + perform(terminal, &mut app, socket, action)?; } } - } else { - // Idle tick: refresh the sync-health snapshot for the status line. - app.refresh_sync(); } if app.should_quit { return Ok(()); diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6e15453..23305c6 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -3,7 +3,7 @@ use heph_core::Attention; use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, + layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ @@ -14,8 +14,8 @@ use ratatui::{ }; use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode}; -use crate::backend::{Backend, SyncStatus}; -use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local}; +use crate::backend::Backend; +use crate::fmt::{fmt_date, project_color, today_local}; // Task-pane gestures (the focused pane shows its own hints, §8.1). const HINTS: &str = @@ -37,7 +37,7 @@ pub fn render(frame: &mut Frame, app: &App) { let panes = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(28), + Constraint::Length(22), Constraint::Min(28), Constraint::Length(38), ]) @@ -151,25 +151,8 @@ 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() @@ -183,38 +166,17 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ))), - SidebarEntry::View { title, .. } => { - let (style, _) = sidebar_row_styles(selected, focused); + 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) + }; + } 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(); @@ -225,29 +187,7 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { .border_style(pane_border(focused)) .title(" Views "), ); - // 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, - ); - } + frame.render_widget(list, area); } /// A dimmed `──── Project ────` group header for the project sort mode, padded @@ -299,7 +239,7 @@ fn task_detail_lines( } } if let Some(rrule) = &t.recurrence { - field("recurs:", hephd::datespec::humanize_rrule(rrule)); + field("recurs:", rrule.clone()); } if let Some(d) = t.do_date { field("do:", fmt_date(d, today)); @@ -538,130 +478,5 @@ fn render_status(frame: &mut Frame, app: &App, area: Rect) { } else { Style::default().fg(Color::DarkGray) }; - let left = Paragraph::new(Line::from(Span::styled(text, style))); - - // A right-aligned sync indicator (spoke only); the hints take the rest. - let indicator = sync_indicator(&app.sync, now_ms()); - if indicator.is_empty() { - frame.render_widget(left, area); - return; - } - let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum(); - let cols = - Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area); - frame.render_widget(left, cols[0]); - frame.render_widget( - Paragraph::new(Line::from(indicator)).alignment(Alignment::Right), - cols[1], - ); -} - -/// The status-line sync indicator (empty on a standalone instance): a sync-state -/// chip — `⚠ auth` when re-login is needed, `⟳ ` since the last successful -/// sync, `⚠ offline` when erroring, `⟳ …` before the first sync — plus a -/// conflict chip when any merge conflicts are pending. -fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec> { - if sync.hub_url.is_none() { - return Vec::new(); - } - let dim = Style::default().fg(Color::DarkGray); - let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - let yellow = Style::default().fg(Color::Yellow); - - let health = sync.health.clone().unwrap_or_default(); - let mut spans = vec![if health.auth_failure { - Span::styled("⚠ auth", red) - } else if let Some(ts) = health.last_success_ms { - Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim) - } else if health.last_error.is_some() { - Span::styled("⚠ offline", yellow) - } else { - Span::styled("⟳ …", dim) - }]; - - if sync.conflicts > 0 { - let label = if sync.conflicts == 1 { - "1 conflict".to_string() - } else { - format!("{} conflicts", sync.conflicts) - }; - spans.push(Span::raw(" ")); - spans.push(Span::styled(format!("⚠ {label}"), red)); - } - spans -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::SyncHealth; - - fn render(sync: &SyncStatus, now: i64) -> String { - sync_indicator(sync, now) - .iter() - .map(|s| s.content.to_string()) - .collect() - } - - const NOW: i64 = 1_000_000_000_000; - - fn spoke(health: SyncHealth, conflicts: usize) -> SyncStatus { - SyncStatus { - hub_url: Some("http://hub:8787".into()), - conflicts, - health: Some(health), - } - } - - #[test] - fn standalone_shows_no_indicator() { - assert!(sync_indicator(&SyncStatus::default(), NOW).is_empty()); - } - - #[test] - fn indicator_reflects_each_sync_state() { - // Recently synced → a dim age chip. - let ok = spoke( - SyncHealth { - last_success_ms: Some(NOW - 5 * 60_000), - ..Default::default() - }, - 0, - ); - assert_eq!(render(&ok, NOW), "⟳ 5m"); - - // Auth failure wins over age (it's the actionable state). - let auth = spoke( - SyncHealth { - last_success_ms: Some(NOW - 60_000), - auth_failure: true, - ..Default::default() - }, - 0, - ); - assert_eq!(render(&auth, NOW), "⚠ auth"); - - // Errored with no prior success → offline. - let offline = spoke( - SyncHealth { - last_error: Some("error sending request".into()), - ..Default::default() - }, - 0, - ); - assert_eq!(render(&offline, NOW), "⚠ offline"); - - // Before the first sync. - assert_eq!(render(&spoke(SyncHealth::default(), 0), NOW), "⟳ …"); - } - - #[test] - fn conflicts_chip_appends_and_pluralizes() { - let h = SyncHealth { - last_success_ms: Some(NOW), - ..Default::default() - }; - assert_eq!(render(&spoke(h.clone(), 1), NOW), "⟳ 0s ⚠ 1 conflict"); - assert_eq!(render(&spoke(h, 3), NOW), "⟳ 0s ⚠ 3 conflicts"); - } + frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); } diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 84ca739..89399e4 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -98,12 +98,6 @@ fn agenda_renders_views_projects_and_tasks() { // The red/orange tasks carry a flag glyph in the leading column (§8.1). assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}"); assert!(s.contains("Preview"), "preview pane missing:\n{s}"); - // A standalone daemon (no hub) shows no sync indicator — the `sync.status` - // RPC round-trips and reports `hub_url: null`. - assert!( - !s.contains('⟳'), - "sync indicator should be hidden without a hub:\n{s}" - ); } #[test] @@ -212,12 +206,7 @@ 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}"); - // 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("FREQ=DAILY"), "no rrule in 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 fa91611..71d23e6 100644 --- a/crates/hephd/src/datespec.rs +++ b/crates/hephd/src/datespec.rs @@ -288,172 +288,6 @@ 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::*; @@ -570,71 +404,4 @@ 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 45a581d..43f8ad4 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -221,10 +221,6 @@ 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 e46f7a6..bc29eca 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -352,7 +352,6 @@ 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/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 30c5d5a..59826ac 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -10,10 +10,9 @@ //! ops with the configured hub (tech-spec §6.1, §12). use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use anyhow::Result; -use serde::Serialize; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -33,23 +32,6 @@ struct SpokeAuth { client_id: String, } -/// A spoke's observed sync health, updated after every exchange (background loop -/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether -/// sync is actually working instead of trusting silence (tech-spec §3.1 / the -/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet". -#[derive(Clone, Default, Serialize)] -struct SyncHealth { - /// When we last attempted an exchange. - last_attempt_ms: Option, - /// When we last completed one without error (the "last synced" time). - last_success_ms: Option, - /// The last error message, cleared on the next success. - last_error: Option, - /// Whether the most recent attempt failed authentication (a 401) — the - /// "re-auth needed" signal, distinct from a transient network blip. - auth_failure: bool, -} - /// The shared, cheaply-cloneable context each connection serves from. #[derive(Clone)] struct Ctx { @@ -61,41 +43,6 @@ struct Ctx { auth: Option, /// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card). self_update: Option, - /// Live sync health, shared between the background loop and `sync.status`. - sync_health: Arc>, -} - -/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure). -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -/// True if `e` carries an HTTP 401 — i.e. the hub rejected our bearer token. -fn is_auth_error(e: &anyhow::Error) -> bool { - e.downcast_ref::() - .and_then(|re| re.status()) - .is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED) -} - -/// Fold one exchange outcome into the shared [`SyncHealth`]. -fn record_sync_outcome(health: &Arc>, result: &Result) { - let now = now_ms(); - let mut h = health.lock().expect("sync_health mutex poisoned"); - h.last_attempt_ms = Some(now); - match result { - Ok(_) => { - h.last_success_ms = Some(now); - h.last_error = None; - h.auth_failure = false; - } - Err(e) => { - h.auth_failure = is_auth_error(e); - h.last_error = Some(e.to_string()); - } - } } impl Ctx { @@ -140,7 +87,6 @@ impl Daemon { .expect("building the daemon HTTP client"), auth: None, self_update: None, - sync_health: Arc::new(Mutex::new(SyncHealth::default())), }, } } @@ -224,10 +170,7 @@ impl Daemon { loop { tick.tick().await; let bearer = ctx.bearer().await; - let result = - sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx.sync_health, &result); - match result { + match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await { Ok(report) => tracing::debug!(?report, "background sync"), Err(e) => tracing::warn!("background sync failed: {e}"), } @@ -322,9 +265,7 @@ async fn sync_now(ctx: &Ctx) -> Result { }); }; let bearer = ctx.bearer().await; - let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx.sync_health, &result); - match result { + match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await { Ok(report) => Ok(json!(report)), Err(e) => Err(RpcError { code: INTERNAL_ERROR, @@ -333,28 +274,11 @@ async fn sync_now(ctx: &Ctx) -> Result { } } -/// `sync.status` — the hub url, the current per-hub cursors, the observed sync -/// health (last-success time / last error / auth-failure flag), and the pending -/// merge-conflict count. A spoke that is silently failing is visible here (and, -/// via it, in the TUI status line). +/// `sync.status` — the hub url and the current per-hub cursors. async fn sync_status(ctx: &Ctx) -> Result { - // Conflict count is meaningful even on a hub / standalone instance. - let store = ctx.store.clone(); - let conflicts = tokio::task::spawn_blocking(move || { - let guard = store.lock().expect("store mutex poisoned"); - guard.conflicts_list().map(|c| c.len()) - }) - .await - .map_err(|e| RpcError { - code: INTERNAL_ERROR, - message: format!("sync.status task failed: {e}"), - })? - .map_err(RpcError::from)?; - let Some(hub_url) = ctx.hub_url.clone() else { - return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts })); + return Ok(json!({ "hub_url": Value::Null })); }; - let store = ctx.store.clone(); let hub = hub_url.clone(); let cursors = tokio::task::spawn_blocking(move || { @@ -367,17 +291,5 @@ async fn sync_status(ctx: &Ctx) -> Result { message: format!("sync.status task failed: {e}"), })? .map_err(RpcError::from)?; - - let health = ctx - .sync_health - .lock() - .expect("sync_health mutex poisoned") - .clone(); - - Ok(json!({ - "hub_url": hub_url, - "cursors": cursors, - "conflicts": conflicts, - "health": health, - })) + Ok(json!({ "hub_url": hub_url, "cursors": cursors })) } diff --git a/docs/changelog.d/+sync-age-seconds.feature.md b/docs/changelog.d/+sync-age-seconds.feature.md deleted file mode 100644 index cf453c2..0000000 --- a/docs/changelog.d/+sync-age-seconds.feature.md +++ /dev/null @@ -1 +0,0 @@ -heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing. diff --git a/docs/changelog.d/heph-pwa-oidc-login.feature.md b/docs/changelog.d/heph-pwa-oidc-login.feature.md new file mode 100644 index 0000000..aae9d26 --- /dev/null +++ b/docs/changelog.d/heph-pwa-oidc-login.feature.md @@ -0,0 +1 @@ +heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider. diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md index bbc81dc..c4763ab 100644 --- a/docs/explanation/explanation.md +++ b/docs/explanation/explanation.md @@ -12,4 +12,3 @@ Background context and design decisions. - [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap - [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up -- [[hub-spoke-data-evolution]] — why op-based sync lets most new features skip migrations, and when a coordinated SQLite migration is actually required diff --git a/docs/explanation/hub-spoke-data-evolution.md b/docs/explanation/hub-spoke-data-evolution.md deleted file mode 100644 index adf3453..0000000 --- a/docs/explanation/hub-spoke-data-evolution.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Hub + Spoke Data Evolution -modified: 2026-06-05 -tags: - - explanation - - sync ---- - -# Hub + Spoke Data Evolution - -How the data model evolves safely when nodes run different versions across the -hub/spoke deployment (indri is the hub; see [[set-up-sync-hub]] and -[[host-heph-pwa]]). The short version: **sync is op-based, not schema-based**, so -most new features need no coordinated migration — but adding a SQLite *column* -does. - -## Two independent layers - -heph keeps two layers that evolve on different clocks: - -1. **The op-log (synced).** Every change is an operation — `node.create`, - `node.set`, `task.set`, `link.add`, `link.remove`, … — carrying an HLC, an - origin device, and a JSON payload. Spokes push/pull ops to/from the hub; both - sides run the **same** merge logic from `heph-core` (`sqlite/apply.rs`). This - is the only thing that crosses the wire. -2. **The SQLite schema (local, per node).** Each node materializes ops into local - tables. The schema version is tracked by SQLite's `PRAGMA user_version` and - advanced by the ordered, append-only migration list in - `heph-core/src/sqlite/migrations.rs`. **No schema or migration state is ever - synced.** A spoke can sit on an older schema than the hub indefinitely. - -Because the wire format is ops — not rows — a node only has to understand the -*ops* its peers emit, not their table layout. - -## What forward/backward compatibility already buys you - -The merge engine is deliberately lenient: - -- **Unknown op types are stored but not applied** (`apply.rs`) — a spoke that - receives a newer op type keeps it in the log (so a later upgrade can replay it) - but doesn't choke on it. -- **Unknown payload fields are ignored.** Field extraction is by name - (`str_field` / `i64_field`), so a payload with extra keys an older node doesn't - recognize just drops the extras. -- **Links are schema-free.** A link's `type` is a string column. A brand-new link - kind (a new `LinkType`) needs no migration — every version reads it as text and - applies OR-set add/remove identically. - -## The rule of thumb - -| Change | Needs coordinated migration? | -|--------|------------------------------| -| New `LinkType` (e.g. a new relationship between nodes) | **No** — just emit `link.add` with the new `type` string | -| New optional/nullable scalar carried in an op payload | **No, if** every node's `apply` reads it defensively and tolerates its absence | -| New *read-side* feature over existing data (counts, hierarchy from existing `parent` links) | **No** — pure local queries, no op or schema change | -| New **required** SQLite column that `apply` must write on every relevant op | **Yes** — old spokes lack the column and the `UPDATE` fails | -| Renaming/removing a column other nodes' `apply` paths reference | **Yes** | - -## When a migration *is* required, do it hub-first - -If a change genuinely needs a new column that the apply path writes: - -1. Ship the migration to **every** node (hub and all spokes) **before** any node - emits an op that depends on the new column. The migration list is - append-only and ordered, so rolling the new `hephd` out everywhere is the - gate. -2. Keep new columns **nullable / defaulted** so an op that predates the column - still applies, and so a node that hasn't yet upgraded degrades to "field - absent" rather than erroring. -3. Prefer encoding the new fact as a **link or an op-payload field** over a new - column whenever you can — that keeps the change in the no-migration column of - the table above. - -## Worked example: indented, counted projects - -The sidebar's subproject indentation and per-project task counts (see -[[install-heph]] and the agenda surface in [[design]] §8.1) are a pure read-side -feature: - -- **Nesting** is read from `parent` links that already exist — created by - `heph project add --parent ` — via the existing - `project_subtree` traversal. -- **Counts** are a read-only `SELECT … GROUP BY` over the `tasks`/`links` tables. - -No new column, no new op type, no migration — it works against a hub and a spoke -on any schema version that already understands `parent` links. That is the case -the rule of thumb is meant to make obvious. diff --git a/docs/how-to/host-heph-pwa.md b/docs/how-to/host-heph-pwa.md index dc22359..63b3d76 100644 --- a/docs/how-to/host-heph-pwa.md +++ b/docs/how-to/host-heph-pwa.md @@ -102,11 +102,6 @@ app defaults its hub URL to its own origin. (A manual **Bearer token** field remains as a fallback for hubs without OIDC, or for pasting a one-off token.) - > Re-prompted for login too often? The fix is the Authentik provider's - > **refresh token validity**, not the app — see the token-lifetime note in - > [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's - > storage after ~7 idle days; Add to Home Screen mitigates it.) - **Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md index a5b56ea..a0f7706 100644 --- a/docs/how-to/set-up-sync-hub.md +++ b/docs/how-to/set-up-sync-hub.md @@ -51,26 +51,6 @@ need: - **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/` - **Client id** — the device-code client id (this is also the token *audience*). -### Token lifetime (avoid frequent re-logins) - -Token lifetimes are set on the Authentik **provider**, not in heph — heph honors -whatever `expires_in` Authentik returns and silently refreshes using the -`offline_access` refresh token (both the CLI/daemon and the PWA do this). To -avoid re-authenticating often, set generous validities on the heph provider: - -- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps - no revocation list, so this is the window in which a leaked token stays usable; - on a Tailscale-only hub, 24–48h is a reasonable trade. -- **Refresh token validity** — e.g. `days=30`+. This is the setting that stops - the re-logins: while the refresh token is valid, the spoke **and** the PWA - renew silently with no browser round-trip. A short refresh window is the usual - cause of "I have to log in constantly". - -> **iOS PWA caveat:** Safari can purge an *un-installed* PWA's `localStorage` -> (where its tokens live) after ~7 idle days regardless of these settings. -> Installing the app to the home screen mitigates it, but expect the occasional -> re-login on iOS. - ## 2. Bring up the hub on `indri` **Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`), @@ -118,16 +98,10 @@ and background-syncs on its interval. ## 4. Verify ```bash -heph sync --status # hub url, last push/pull cursors, sync health +heph sync --status # last push/pull cursors, hub url heph sync # force a cycle now ``` -`heph sync --status` also reports **sync health** — the time of the last -successful exchange, any last error, and whether the spoke is currently failing -to authenticate. The same signal is surfaced live in `heph-tui`'s status line -(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken -spoke is visible at a glance rather than buried in the daemon log. - Make a change on `gilbert`, force a sync, and confirm it appears via the hub. ## Current gaps (finalized by the blumeops deployment) diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js index 4452c89..32820e0 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, humanizeRecurrence } from "./datespec.js"; +import { today, parseDate, toEpochMs } 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", humanizeRecurrence(t.recurrence)]); + if (t.recurrence) meta.push(["recurs", 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" }, "↻ " + humanizeRecurrence(parsed.recurrence))); + if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + 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 afe798a..7c2986a 100644 --- a/heph-pwa/src/datespec.js +++ b/heph-pwa/src/datespec.js @@ -219,143 +219,3 @@ 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 cd984fc..7c006d0 100644 --- a/heph-pwa/test/parsers.test.mjs +++ b/heph-pwa/test/parsers.test.mjs @@ -3,12 +3,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { - parseDate, - parseRecurrence, - humanizeRecurrence, - toEpochMs, -} from "../src/datespec.js"; +import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js"; import { parse } from "../src/quickadd.js"; const d = (y, m, day) => new Date(y, m - 1, day); @@ -63,60 +58,6 @@ 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 = [