Compare commits

..

1 commit

Author SHA1 Message Date
Forgejo Actions
51470e9937 Release v1.2.0 [skip ci] 2026-06-04 17:53:31 -07:00
33 changed files with 86 additions and 1747 deletions

View file

@ -12,35 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
<!-- towncrier release notes start --> <!-- towncrier release notes start -->
## [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 ## [v1.2.0] - 2026-06-04
### Features ### Features

10
Cargo.lock generated
View file

@ -2196,7 +2196,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "heph" name = "heph"
version = "0.0.0" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2210,7 +2210,7 @@ dependencies = [
[[package]] [[package]]
name = "heph-core" name = "heph-core"
version = "0.0.0" version = "1.2.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"proptest", "proptest",
@ -2227,7 +2227,7 @@ dependencies = [
[[package]] [[package]]
name = "heph-quickadd" name = "heph-quickadd"
version = "0.0.0" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2243,7 +2243,7 @@ dependencies = [
[[package]] [[package]]
name = "heph-tui" name = "heph-tui"
version = "0.0.0" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2259,7 +2259,7 @@ dependencies = [
[[package]] [[package]]
name = "hephd" name = "hephd"
version = "0.0.0" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"apple-native-keyring-store", "apple-native-keyring-store",

View file

@ -10,7 +10,7 @@ members = [
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "0.0.0" version = "1.2.0"
license = "LicenseRef-Proprietary" license = "LicenseRef-Proprietary"
publish = false publish = false
authors = ["Erich Blume <blume.erich@gmail.com>"] authors = ["Erich Blume <blume.erich@gmail.com>"]

View file

@ -38,7 +38,7 @@ pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS};
pub use hlc::{Hlc, HlcClock}; pub use hlc::{Hlc, HlcClock};
pub use model::{ pub use model::{
deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, 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 oplog::Op;
pub use ranking::{rank, Dimension, RankedTask, RANKING}; pub use ranking::{rank, Dimension, RankedTask, RANKING};

View file

@ -314,24 +314,6 @@ pub struct Health {
pub sync_status: String, 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 /// 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 /// §12). The winning value is already in the store; this records what was
/// dropped so `heph conflicts` can show and settle it. /// dropped so `heph conflicts` can show and settle it.

View file

@ -32,8 +32,8 @@ use crate::error::{Error, Result};
use crate::filter::ListFilter; use crate::filter::ListFilter;
use crate::hlc::Hlc; use crate::hlc::Hlc;
use crate::model::{ use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SchedulePatch, SyncCursors, Task, TaskState, SyncCursors, Task, TaskState,
}; };
use crate::oplog::Op; use crate::oplog::Op;
use crate::ranking::RankedTask; use crate::ranking::RankedTask;
@ -297,10 +297,6 @@ impl Store for LocalStore {
tasks::health(&self.conn, &self.owner_id) 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>> { fn search(&self, query: &str) -> Result<Vec<Node>> {
nodes::search(&self.conn, &self.owner_id, query) nodes::search(&self.conn, &self.owner_id, query)
} }
@ -502,67 +498,6 @@ mod tests {
assert!(store.project_scope("Nope").is_err()); 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] #[test]
fn resolve_project_is_fuzzy_only_when_unambiguous() { fn resolve_project_is_fuzzy_only_when_unambiguous() {
use crate::model::{NewNode, NodeKind}; use crate::model::{NewNode, NodeKind};

View file

@ -3,8 +3,6 @@
//! A committed task is a `task` node plus a `tasks` row. On creation it also //! 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). //! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, Row}; use rusqlite::{Connection, OptionalExtension, Row};
use serde_json::json; use serde_json::json;
@ -14,7 +12,7 @@ use crate::error::{Error, Result};
use crate::extract; use crate::extract;
use crate::filter::ListFilter; use crate::filter::ListFilter;
use crate::model::{ 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::oplog::op_type;
use crate::ranking::{self, RankedTask}; use crate::ranking::{self, RankedTask};
@ -484,57 +482,6 @@ 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, /// Load every non-tombstoned committed task for `owner` as a ranking candidate,
/// joining in its project and canonical-context link targets. /// joining in its project and canonical-context link targets.
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> { fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {

View file

@ -7,8 +7,8 @@
use crate::error::Result; use crate::error::Result;
use crate::filter::ListFilter; use crate::filter::ListFilter;
use crate::model::{ use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SchedulePatch, SyncCursors, Task, TaskState, SyncCursors, Task, TaskState,
}; };
use crate::oplog::Op; use crate::oplog::Op;
use crate::ranking::RankedTask; use crate::ranking::RankedTask;
@ -142,12 +142,6 @@ pub trait Store {
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
fn health(&self) -> Result<Health>; 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 /// Full-text search over title + body (FTS5), owner-scoped, best-match
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax. /// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
fn search(&self, query: &str) -> Result<Vec<Node>>; fn search(&self, query: &str) -> Result<Vec<Node>>;

View file

@ -7,9 +7,9 @@ use std::collections::HashMap;
use anyhow::Result; use anyhow::Result;
use chrono::NaiveDate; 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}; use crate::fmt::{days_overdue, today_local};
/// How the task list is ordered (toggled in the UI, §8.1). /// How the task list is ordered (toggled in the UI, §8.1).
@ -313,18 +313,8 @@ pub enum Focus {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SidebarEntry { pub enum SidebarEntry {
Header(String), Header(String),
View { View { name: String, title: String },
name: String, Project { id: String, title: 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 { 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<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 /// The selected sidebar source, as owned values (so reloads don't hold a borrow
/// on `self.sidebar` while calling the backend). /// on `self.sidebar` while calling the backend).
enum Target { enum Target {
@ -433,8 +359,6 @@ pub struct App<B: Backend> {
undo_stack: Vec<UndoEntry>, undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>, redo_stack: Vec<UndoEntry>,
pub status: String, pub status: String,
/// Latest sync health for the status-line indicator (refreshed on a tick).
pub sync: SyncStatus,
pub should_quit: bool, pub should_quit: bool,
} }
@ -452,7 +376,9 @@ impl<B: Backend> App<B> {
}); });
} }
sidebar.push(SidebarEntry::Header("Projects".into())); 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 let sidebar_cursor = sidebar
.iter() .iter()
@ -474,23 +400,12 @@ impl<B: Backend> App<B> {
undo_stack: Vec::new(), undo_stack: Vec::new(),
redo_stack: Vec::new(), redo_stack: Vec::new(),
status: String::new(), status: String::new(),
sync: SyncStatus::default(),
should_quit: false, should_quit: false,
}; };
app.reload(); app.reload();
app.refresh_sync();
Ok(app) 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). /// The title shown above the task list (the selected source).
pub fn task_pane_title(&self) -> String { pub fn task_pane_title(&self) -> String {
match self.sidebar.get(self.sidebar_cursor) { match self.sidebar.get(self.sidebar_cursor) {
@ -508,7 +423,7 @@ impl<B: Backend> App<B> {
/// The title of a project node id, resolved from the sidebar. /// The title of a project node id, resolved from the sidebar.
pub fn project_name(&self, id: &str) -> Option<String> { pub fn project_name(&self, id: &str) -> Option<String> {
self.sidebar.iter().find_map(|e| match e { 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, _ => None,
}) })
} }
@ -554,7 +469,7 @@ impl<B: Backend> App<B> {
self.sidebar self.sidebar
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
SidebarEntry::Project { id, title, .. } => Some((id.clone(), title.clone())), SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())),
_ => None, _ => None,
}) })
.collect() .collect()
@ -827,7 +742,7 @@ impl<B: Backend> App<B> {
/// become unfiled (they move to the Inbox), not deleted. /// become unfiled (they move to the Inbox), not deleted.
pub fn begin_delete_project(&mut self) { pub fn begin_delete_project(&mut self) {
match self.sidebar.get(self.sidebar_cursor) { match self.sidebar.get(self.sidebar_cursor) {
Some(SidebarEntry::Project { id, title, .. }) => { Some(SidebarEntry::Project { id, title }) => {
self.pending_delete = Some(PendingDelete::Project { self.pending_delete = Some(PendingDelete::Project {
project_id: id.clone(), project_id: id.clone(),
title: title.clone(), title: title.clone(),
@ -966,8 +881,10 @@ impl<B: Backend> App<B> {
.filter(|e| !matches!(e, SidebarEntry::Project { .. })) .filter(|e| !matches!(e, SidebarEntry::Project { .. }))
.cloned() .cloned()
.collect(); .collect();
if let Ok(overview) = self.backend.project_overview() { if let Ok(projects) = self.backend.projects() {
rebuilt.extend(project_entries(overview)); for Project { id, title } in projects {
rebuilt.push(SidebarEntry::Project { id, title });
}
} }
self.sidebar = rebuilt; self.sidebar = rebuilt;
// Restore the cursor: same entry if present, else the nearest selectable // Restore the cursor: same entry if present, else the nearest selectable
@ -1006,7 +923,7 @@ impl<B: Backend> App<B> {
self.sidebar self.sidebar
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
SidebarEntry::Project { id, title, .. } => Some(Project { SidebarEntry::Project { id, title } => Some(Project {
id: id.clone(), id: id.clone(),
title: title.clone(), title: title.clone(),
}), }),
@ -1296,67 +1213,4 @@ mod sort_tests {
// Alpha group (red before blue), then Beta, then project-less tasks last. // 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"]); 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);
}
} }

View file

@ -22,55 +22,10 @@ pub struct SearchHit {
pub kind: String, 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<String>,
/// Pending merge conflicts awaiting resolution.
#[serde(default)]
pub conflicts: usize,
/// Observed health of the background sync loop (spoke only).
#[serde(default)]
pub health: Option<SyncHealth>,
}
/// 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<i64>,
/// Epoch ms of the last attempt (success or failure), if any.
pub last_attempt_ms: Option<i64>,
/// The last error message, cleared on the next success.
pub last_error: Option<String>,
/// Whether the most recent attempt failed authentication (needs re-login).
#[serde(default)]
pub auth_failure: bool,
}
/// Everything the agenda surface asks of the daemon. /// Everything the agenda surface asks of the daemon.
pub trait Backend { pub trait Backend {
/// All project nodes (for the sidebar), title-sorted. /// All project nodes (for the sidebar), title-sorted.
fn projects(&mut self) -> Result<Vec<Project>>; 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). /// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>; fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
/// Run a raw [`ListFilter`] (used for per-project scope). /// 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), /// 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. /// for opening a task search-hit at the useful node. `None` if it has none.
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>; fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
/// 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<SyncStatus> {
Ok(SyncStatus::default())
}
// --- triage mutations (T2) --- // --- triage mutations (T2) ---
@ -153,11 +103,6 @@ impl Backend for ClientBackend {
Ok(projects) 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>> { fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
let v = self.call("view", json!({ "name": name }))?; let v = self.call("view", json!({ "name": name }))?;
Ok(serde_json::from_value(v)?) Ok(serde_json::from_value(v)?)
@ -204,11 +149,6 @@ impl Backend for ClientBackend {
.map(|l| l.dst_id)) .map(|l| l.dst_id))
} }
fn sync_status(&mut self) -> Result<SyncStatus> {
let v = self.call("sync.status", json!({}))?;
Ok(serde_json::from_value(v)?)
}
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
self.call("task.set_state", json!({ "id": task_id, "state": state }))?; self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
Ok(()) Ok(())

View file

@ -25,29 +25,6 @@ pub fn today_local() -> NaiveDate {
Local::now().date_naive() 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 /// 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). /// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 { pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
@ -125,19 +102,6 @@ mod tests {
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); 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] #[test]
fn project_color_is_stable_distinct_and_neutral_when_absent() { fn project_color_is_stable_distinct_and_neutral_when_absent() {
assert_eq!(project_color(None), Color::DarkGray); assert_eq!(project_color(None), Color::DarkGray);

View file

@ -61,22 +61,14 @@ fn run<B: heph_tui::Backend>(
mut app: App<B>, mut app: App<B>,
socket: &std::path::Path, socket: &std::path::Path,
) -> Result<()> { ) -> 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 { loop {
terminal.draw(|f| ui::render(f, &app))?; terminal.draw(|f| ui::render(f, &app))?;
if event::poll(tick)? { if let Event::Key(key) = event::read()? {
if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press {
if key.kind == KeyEventKind::Press { if let Some(action) = handle_key(&mut app, key) {
if let Some(action) = handle_key(&mut app, key) { perform(terminal, &mut app, socket, action)?;
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 { if app.should_quit {
return Ok(()); return Ok(());

View file

@ -3,7 +3,7 @@
use heph_core::Attention; use heph_core::Attention;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, layout::{Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{ widgets::{
@ -14,8 +14,8 @@ use ratatui::{
}; };
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode}; use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
use crate::backend::{Backend, SyncStatus}; use crate::backend::Backend;
use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local}; use crate::fmt::{fmt_date, project_color, today_local};
// Task-pane gestures (the focused pane shows its own hints, §8.1). // Task-pane gestures (the focused pane shows its own hints, §8.1).
const HINTS: &str = const HINTS: &str =
@ -37,7 +37,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
let panes = Layout::default() let panes = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Length(28), Constraint::Length(22),
Constraint::Min(28), Constraint::Min(28),
Constraint::Length(38), 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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let focused = app.focus == Focus::Sidebar; let focused = app.focus == Focus::Sidebar;
let width = area.width.saturating_sub(2) as usize; // inside borders
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.sidebar .sidebar
.iter() .iter()
@ -183,38 +166,17 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
.fg(Color::DarkGray) .fg(Color::DarkGray)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
))), ))),
SidebarEntry::View { title, .. } => { SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => {
let (style, _) = sidebar_row_styles(selected, focused); 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))) 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(); .collect();
@ -225,29 +187,7 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
.border_style(pane_border(focused)) .border_style(pane_border(focused))
.title(" Views "), .title(" Views "),
); );
// Drive scroll-to-visible off the cursor so projects below the fold stay frame.render_widget(list, area);
// 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 /// A dimmed `──── Project ────` group header for the project sort mode, padded
@ -299,7 +239,7 @@ fn task_detail_lines<B: Backend>(
} }
} }
if let Some(rrule) = &t.recurrence { if let Some(rrule) = &t.recurrence {
field("recurs:", hephd::datespec::humanize_rrule(rrule)); field("recurs:", rrule.clone());
} }
if let Some(d) = t.do_date { if let Some(d) = t.do_date {
field("do:", fmt_date(d, today)); field("do:", fmt_date(d, today));
@ -538,130 +478,5 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}; };
let left = Paragraph::new(Line::from(Span::styled(text, style))); frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
// 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, `⟳ <age>` 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<Span<'static>> {
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");
}
} }

View file

@ -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). // 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('⚑'), "attention flag glyph missing:\n{s}");
assert!(s.contains("Preview"), "preview pane 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] #[test]
@ -212,12 +206,7 @@ fn recurring_task_shows_glyph_and_selected_detail_block() {
assert!(s.contains('↻'), "recurrence glyph missing:\n{s}"); assert!(s.contains('↻'), "recurrence glyph missing:\n{s}");
// ...and the selected task's inline detail block (cursor starts on row 0). // ...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("recurs:"), "no recurrence detail:\n{s}");
// The RRULE is humanized for display (§8.1), not shown raw. assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}");
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("project:"), "no project detail:\n{s}");
assert!(s.contains("Routines"), "project name missing:\n{s}"); assert!(s.contains("Routines"), "project name missing:\n{s}");
} }

View file

@ -49,13 +49,6 @@ pub trait TokenVerifier: Send + Sync {
/// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its /// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its
/// claims, or an [`AuthError`]. /// claims, or an [`AuthError`].
fn verify(&self, bearer: &str) -> Result<Claims, AuthError>; fn verify(&self, bearer: &str) -> Result<Claims, AuthError>;
/// The public OIDC parameters a browser client (the `heph-pwa`) needs to
/// start a login: `(issuer, client_id)`. Neither is a secret. `None` for
/// non-OIDC verifiers (e.g. test stubs).
fn oidc_config(&self) -> Option<(&str, &str)> {
None
}
} }
/// What an OIDC provider's discovery document tells us (we only need the JWKS). /// What an OIDC provider's discovery document tells us (we only need the JWKS).
@ -163,9 +156,4 @@ impl TokenVerifier for OidcVerifier {
.map_err(|e| AuthError::Invalid(e.to_string()))?; .map_err(|e| AuthError::Invalid(e.to_string()))?;
Ok(data.claims) Ok(data.claims)
} }
fn oidc_config(&self) -> Option<(&str, &str)> {
// The audience is the OIDC client id (Authentik sets `aud` to it).
Some((&self.issuer, &self.audience))
}
} }

View file

@ -288,172 +288,6 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> {
None 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -570,71 +404,4 @@ mod tests {
); );
assert!(parse_recurrence("every blue moon").is_err()); 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");
}
}
} }

View file

@ -221,10 +221,6 @@ impl Store for RemoteStore {
self.call_as("health", json!({})) 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>> { fn search(&self, query: &str) -> Result<Vec<Node>> {
self.call_as("search", json!({ "query": query })) self.call_as("search", json!({ "query": query }))
} }

View file

@ -352,7 +352,6 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: NodeListParams = parse(params)?; let p: NodeListParams = parse(params)?;
json!(store.list_nodes(p.kind)?) json!(store.list_nodes(p.kind)?)
} }
"project.overview" => json!(store.project_overview()?),
"task.create" => { "task.create" => {
let p: NewTask = parse(params)?; let p: NewTask = parse(params)?;
json!(store.create_task(p)?) json!(store.create_task(p)?)

View file

@ -10,10 +10,9 @@
//! ops with the configured hub (tech-spec §6.1, §12). //! ops with the configured hub (tech-spec §6.1, §12).
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
@ -33,23 +32,6 @@ struct SpokeAuth {
client_id: String, 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<i64>,
/// When we last completed one without error (the "last synced" time).
last_success_ms: Option<i64>,
/// The last error message, cleared on the next success.
last_error: Option<String>,
/// 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. /// The shared, cheaply-cloneable context each connection serves from.
#[derive(Clone)] #[derive(Clone)]
struct Ctx { struct Ctx {
@ -61,41 +43,6 @@ struct Ctx {
auth: Option<SpokeAuth>, auth: Option<SpokeAuth>,
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card). /// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
self_update: Option<SelfUpdateConfig>, self_update: Option<SelfUpdateConfig>,
/// Live sync health, shared between the background loop and `sync.status`.
sync_health: Arc<Mutex<SyncHealth>>,
}
/// 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::<reqwest::Error>()
.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<Mutex<SyncHealth>>, result: &Result<sync::SyncReport>) {
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 { impl Ctx {
@ -140,7 +87,6 @@ impl Daemon {
.expect("building the daemon HTTP client"), .expect("building the daemon HTTP client"),
auth: None, auth: None,
self_update: None, self_update: None,
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
}, },
} }
} }
@ -224,10 +170,7 @@ impl Daemon {
loop { loop {
tick.tick().await; tick.tick().await;
let bearer = ctx.bearer().await; let bearer = ctx.bearer().await;
let result = match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await {
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx.sync_health, &result);
match result {
Ok(report) => tracing::debug!(?report, "background sync"), Ok(report) => tracing::debug!(?report, "background sync"),
Err(e) => tracing::warn!("background sync failed: {e}"), Err(e) => tracing::warn!("background sync failed: {e}"),
} }
@ -322,9 +265,7 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
}); });
}; };
let bearer = ctx.bearer().await; let bearer = ctx.bearer().await;
let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await; match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await {
record_sync_outcome(&ctx.sync_health, &result);
match result {
Ok(report) => Ok(json!(report)), Ok(report) => Ok(json!(report)),
Err(e) => Err(RpcError { Err(e) => Err(RpcError {
code: INTERNAL_ERROR, code: INTERNAL_ERROR,
@ -333,28 +274,11 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
} }
} }
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync /// `sync.status` — the hub url and the current per-hub cursors.
/// 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).
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> { async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
// 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 { 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 store = ctx.store.clone();
let hub = hub_url.clone(); let hub = hub_url.clone();
let cursors = tokio::task::spawn_blocking(move || { let cursors = tokio::task::spawn_blocking(move || {
@ -367,17 +291,5 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
message: format!("sync.status task failed: {e}"), message: format!("sync.status task failed: {e}"),
})? })?
.map_err(RpcError::from)?; .map_err(RpcError::from)?;
Ok(json!({ "hub_url": hub_url, "cursors": cursors }))
let health = ctx
.sync_health
.lock()
.expect("sync_health mutex poisoned")
.clone();
Ok(json!({
"hub_url": hub_url,
"cursors": cursors,
"conflicts": conflicts,
"health": health,
}))
} }

View file

@ -135,10 +135,6 @@ pub fn router_with_web(
.route("/sync/push", post(push)) .route("/sync/push", post(push))
.route("/rpc", post(rpc_call)) .route("/rpc", post(rpc_call))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
// Unauthenticated: the public OIDC params (issuer + client id) a browser
// client reads to start a PKCE login. Added after the auth `route_layer`
// so it is NOT gated — the app needs it *before* it has a token.
.route("/config", get(config))
// The static shell is unauthenticated and lives behind the API routes. // The static shell is unauthenticated and lives behind the API routes.
.fallback(serve_static) .fallback(serve_static)
// Outermost: stamp CORS headers on every response and short-circuit the // Outermost: stamp CORS headers on every response and short-circuit the
@ -178,20 +174,6 @@ async fn cors(request: Request, next: Next) -> AxumResponse {
response response
} }
/// Public OIDC parameters for a browser client (the `heph-pwa`) to start a PKCE
/// login: `{ "issuer", "client_id" }`. Unauthenticated — neither value is a
/// secret. Returns an empty object `{}` when the hub runs without OIDC, so the
/// app can detect that and fall back to a manually pasted token.
async fn config(State(state): State<HubState>) -> Json<Value> {
let body = state
.verifier
.as_ref()
.and_then(|v| v.oidc_config())
.map(|(issuer, client_id)| serde_json::json!({ "issuer": issuer, "client_id": client_id }))
.unwrap_or_else(|| serde_json::json!({}));
Json(body)
}
/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no /// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no
/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA /// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA
/// can own its own routing. Path traversal (`..`) is rejected. /// can own its own routing. Path traversal (`..`) is rejected.

View file

@ -12,23 +12,10 @@ use std::thread;
use std::time::Duration; use std::time::Duration;
use heph_core::{FixedClock, LocalStore}; use heph_core::{FixedClock, LocalStore};
use hephd::auth::{AuthError, Claims, TokenVerifier};
use hephd::sync::{self, SharedStore}; use hephd::sync::{self, SharedStore};
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
/// A verifier that never admits a token but advertises OIDC params, so we can
/// drive the unauthenticated `/config` route without a live IdP.
struct StubOidc;
impl TokenVerifier for StubOidc {
fn verify(&self, _bearer: &str) -> Result<Claims, AuthError> {
Err(AuthError::Missing)
}
fn oidc_config(&self) -> Option<(&str, &str)> {
Some(("https://idp.example/application/o/heph/", "heph"))
}
}
/// One parsed HTTP response: status line code, lowercased headers, and body. /// One parsed HTTP response: status line code, lowercased headers, and body.
struct Resp { struct Resp {
status: u16, status: u16,
@ -77,15 +64,6 @@ fn request(addr: &str, method: &str, path: &str) -> Resp {
/// an ephemeral port; return its `host:port`. The server thread + temp dirs live /// an ephemeral port; return its `host:port`. The server thread + temp dirs live
/// for the test's duration. /// for the test's duration.
fn start(web_root: Option<std::path::PathBuf>) -> String { fn start(web_root: Option<std::path::PathBuf>) -> String {
start_with(None, web_root)
}
/// As [`start`], but with an explicit token verifier (to exercise the `/config`
/// route, which reports the verifier's OIDC params).
fn start_with(
verifier: Option<Arc<dyn TokenVerifier>>,
web_root: Option<std::path::PathBuf>,
) -> String {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
thread::spawn(move || { thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
@ -100,7 +78,7 @@ fn start_with(
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
tx.send(listener.local_addr().unwrap()).unwrap(); tx.send(listener.local_addr().unwrap()).unwrap();
let _keep = dir; let _keep = dir;
let app = sync::router_with_web(shared, verifier, web_root); let app = sync::router_with_web(shared, None, web_root);
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); });
}); });
@ -183,31 +161,3 @@ fn no_web_root_yields_404_for_static_paths() {
// Even the 404 carries CORS headers (it passed through the layer). // Even the 404 carries CORS headers (it passed through the layer).
assert_eq!(resp.header("access-control-allow-origin"), Some("*")); assert_eq!(resp.header("access-control-allow-origin"), Some("*"));
} }
#[test]
fn config_is_empty_without_oidc() {
let addr = start(None);
let resp = request(&addr, "GET", "/config");
assert_eq!(resp.status, 200);
assert_eq!(resp.body.trim(), "{}");
}
#[test]
fn config_reports_oidc_params_unauthenticated() {
// Even on an authed hub, /config is reachable without a token (it is added
// after the auth layer) and reports the issuer + public client id.
let addr = start_with(Some(Arc::new(StubOidc)), None);
let resp = request(&addr, "GET", "/config");
assert_eq!(resp.status, 200);
assert!(
resp.body
.contains("\"issuer\":\"https://idp.example/application/o/heph/\""),
"body was: {}",
resp.body
);
assert!(
resp.body.contains("\"client_id\":\"heph\""),
"body was: {}",
resp.body
);
}

View file

@ -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.

View file

@ -12,4 +12,3 @@ Background context and design decisions.
- [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap - [[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 - [[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

View file

@ -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 <name> --parent <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.

View file

@ -95,24 +95,16 @@ app defaults its hub URL to its own origin.
1. Ensure the phone is on the tailnet (or can reach the proxy). 1. Ensure the phone is on the tailnet (or can reach the proxy).
2. Open the hub URL (`https://indri.<tailnet>.ts.net/`) and **Add to Home Screen**. 2. Open the hub URL (`https://indri.<tailnet>.ts.net/`) and **Add to Home Screen**.
3. The app defaults its **Hub URL** to the origin it loaded from — no typing. 3. The app defaults its **Hub URL** to the origin it loaded from — no typing.
4. **Sign in:** open **Settings → Login with Authentik**. The app reads the 4. **Token:** the hub requires an OIDC bearer token, and the PWA does **not yet
hub's `GET /config` for the issuer + client id (zero-config) and runs an implement the in-app device-code login** — paste a token into Settings →
Authorization-Code + PKCE redirect to Authentik; after you approve it lands Token for now. Obtain one via the device-code flow against the Authentik
back on the app, signed in, and silently refreshes the token from then on. client (the same flow the CLI uses; e.g. reuse the access token a logged-in
(A manual **Bearer token** field remains as a fallback for hubs without spoke cached, or run a one-off device-code grant). Tap **Test** to confirm.
OIDC, or for pasting a one-off token.)
> Re-prompted for login too often? The fix is the Authentik provider's > **Known gap / next step:** wire the RFC 8628 device-code flow into the PWA's
> **refresh token validity**, not the app — see the token-lifetime note in > Settings so login is in-app (open the verification URL, poll for the token,
> [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's > store it, and refresh it) — removing the manual paste. Tracked as follow-up
> storage after ~7 idle days; Add to Home Screen mitigates it.) > work for `heph-pwa`.
**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
trailing slash, e.g. `https://heph.ops.eblu.me/` (and `http://localhost:8787/`
for local dev). In blumeops this is the `redirect_uris` list on the heph
provider blueprint.
## Upgrades ## Upgrades

View file

@ -51,26 +51,6 @@ need:
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/` - **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
- **Client id** — the device-code client id (this is also the token *audience*). - **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, 2448h 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` ## 2. Bring up the hub on `indri`
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`), **Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
@ -118,16 +98,10 @@ and background-syncs on its interval.
## 4. Verify ## 4. Verify
```bash ```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 # 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. Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
## Current gaps (finalized by the blumeops deployment) ## Current gaps (finalized by the blumeops deployment)

View file

@ -6,9 +6,8 @@
// rpc.js). Context/KB is read-only here (no nvim editing surface). // rpc.js). Context/KB is read-only here (no nvim editing surface).
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js"; import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
import * as oauth from "./oauth.js";
import { parse as quickParse } from "./quickadd.js"; import { parse as quickParse } from "./quickadd.js";
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js"; import { today, parseDate, toEpochMs } from "./datespec.js";
import { import {
ATTENTION_COLORS, ATTENTION_COLORS,
fmtRelative, fmtRelative,
@ -41,29 +40,7 @@ const state = {
lastUndo: null, // { label, run } lastUndo: null, // { label, run }
}; };
// Build the RPC client from the current settings, wiring an OIDC silent-refresh state.client = new Client(state.settings);
// hook: on a 401 the client calls this to renew the token (oauth.js) and retry
// once before surfacing the error.
function makeClient() {
return new Client({
baseUrl: state.settings.baseUrl,
token: state.settings.token,
refresh: async () => {
const tok = await oauth.ensureFreshToken(true);
applyToken(tok || "");
return tok;
},
});
}
// Adopt `token` as the active bearer: persist it and rebuild the client.
function applyToken(token) {
state.settings.token = token || "";
saveSettings(state.settings);
state.client = makeClient();
}
state.client = makeClient();
// --- tiny DOM helper -------------------------------------------------------- // --- tiny DOM helper --------------------------------------------------------
@ -212,7 +189,7 @@ function taskRow(t) {
function taskDetail(t) { function taskDetail(t) {
const meta = []; const meta = [];
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]); 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.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]); if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
@ -373,7 +350,7 @@ function openQuickAdd() {
} }
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate))); 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.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("input", updatePreview);
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
@ -715,62 +692,27 @@ function openSettings() {
const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" }); const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" });
const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" }); const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" });
const test = h("div", { class: "settings-test" }); const test = h("div", { class: "settings-test" });
const setTest = (msg, ok) => {
test.textContent = msg;
test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : "");
};
const save = async () => { const save = async () => {
state.settings.baseUrl = url.value.trim(); state.settings.baseUrl = url.value.trim();
state.settings.token = tok.value.trim(); state.settings.token = tok.value.trim();
saveSettings(state.settings); saveSettings(state.settings);
state.client = makeClient(); state.client = new Client(state.settings);
closeModal(); closeModal();
reload(); reload();
}; };
const check = async () => { const check = async () => {
setTest("Checking…", null); test.textContent = "Checking…";
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() }); const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
try { try {
const v = await probe.call("version", {}); const v = await probe.call("version", {});
setTest(`✓ Connected (hephd ${v.version})`, true); test.textContent = `✓ Connected (hephd ${v.version})`;
test.className = "settings-test ok";
} catch (e) { } catch (e) {
setTest(`${e.message}`, false); test.textContent = `${e.message}`;
test.className = "settings-test bad";
} }
}; };
// Login with Authentik: read the hub's /config for the issuer + client id,
// then start the PKCE redirect (this navigates away and returns to init()).
const login = async () => {
const hub = url.value.trim() || state.settings.baseUrl;
if (!hub) return setTest("✗ Set the hub URL first.", false);
setTest("Contacting hub…", null);
const cfg = await oauth.fetchHubConfig(hub);
if (!cfg) {
return setTest("✗ Hub has no OIDC (/config) — paste a token, or enable OIDC on the hub.", false);
}
state.settings.baseUrl = hub;
saveSettings(state.settings); // persist before we navigate away
try {
await oauth.beginLogin(cfg);
} catch (e) {
setTest(`${e.message}`, false);
}
};
const logout = () => {
oauth.clearAuth();
applyToken("");
closeModal();
reload();
};
const authRow = oauth.loggedIn()
? h(
"div",
{ class: "settings-auth" },
h("span", { class: "settings-test ok" }, "✓ Signed in with Authentik"),
h("button", { class: "act", onclick: logout }, "Log out"),
)
: h("button", { class: "qa-add settings-login", onclick: login }, "Login with Authentik");
openModal( openModal(
h( h(
@ -779,14 +721,8 @@ function openSettings() {
h("div", { class: "modal-title" }, "Settings"), h("div", { class: "modal-title" }, "Settings"),
h("label", { class: "settings-label" }, "Hub URL"), h("label", { class: "settings-label" }, "Hub URL"),
url, url,
h("label", { class: "settings-label" }, "Sign-in"), h("label", { class: "settings-label" }, "Token"),
authRow, tok,
h(
"details",
{ class: "settings-manual" },
h("summary", {}, "Or paste a bearer token"),
tok,
),
test, test,
h( h(
"div", "div",
@ -794,7 +730,7 @@ function openSettings() {
h("button", { class: "act", onclick: check }, "Test"), h("button", { class: "act", onclick: check }, "Test"),
h("button", { class: "qa-add", onclick: save }, "Save"), h("button", { class: "qa-add", onclick: save }, "Save"),
), ),
h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."), h("div", { class: "settings-hint" }, "The hub is your server-mode hephd. Leave the token blank if the hub runs without OIDC."),
), ),
); );
} }
@ -866,20 +802,6 @@ async function init() {
} }
}); });
// OIDC: finish a redirect callback (back from Authentik), or refresh an
// existing session, so the first reload() already carries a valid bearer.
if (oauth.isCallback()) {
try {
applyToken(await oauth.completeLogin());
toast("Signed in.");
} catch (e) {
toast(`Sign-in failed: ${e.message}`);
}
} else if (oauth.loggedIn()) {
const tok = await oauth.ensureFreshToken();
if (tok) applyToken(tok);
}
render(); render();
reload(); reload();

View file

@ -219,143 +219,3 @@ export function parseRecurrenceOrNull(spec) {
return null; 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;
}
}

View file

@ -1,204 +0,0 @@
// Browser OIDC sign-in for the PWA: Authorization Code + PKCE (RFC 7636) against
// the hub's IdP (Authentik). Unlike the CLI's device-code flow, a browser SPA
// uses a redirect + PKCE — no client secret, no polling. The resulting access
// token is the same bearer the hub's OidcVerifier checks (iss / aud=client_id /
// RS256 / exp), so once signed in the app talks to /rpc exactly as a pasted
// token would. We also keep a refresh token (offline_access) to renew silently.
//
// Zero-config: the hub serves GET /config -> { issuer, client_id }, so the app
// learns the IdP without the user typing anything when served from the hub.
const AUTH_KEY = "heph-pwa:auth"; // localStorage: { issuer, clientId, access, refresh, expiresAt }
const PKCE_KEY = "heph-pwa:pkce"; // sessionStorage: in-flight { verifier, state, ... }
// --- persistence ------------------------------------------------------------
export function loadAuth() {
try {
return JSON.parse(localStorage.getItem(AUTH_KEY) || "null");
} catch {
return null;
}
}
function saveAuth(a) {
localStorage.setItem(AUTH_KEY, JSON.stringify(a));
}
export function clearAuth() {
localStorage.removeItem(AUTH_KEY);
}
export function loggedIn() {
return !!loadAuth();
}
// --- PKCE helpers -----------------------------------------------------------
function b64url(bytes) {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function randomString(nbytes = 32) {
const a = new Uint8Array(nbytes);
crypto.getRandomValues(a);
return b64url(a);
}
async function challengeOf(verifier) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return b64url(digest);
}
// The redirect URI is the app's own base directory (query/hash stripped), so it
// is stable across the login start and the callback. Register this exact value
// (with trailing slash) on the Authentik provider, e.g. https://heph.ops.eblu.me/.
function redirectUri() {
return new URL(".", location.href).href;
}
// --- discovery --------------------------------------------------------------
async function discover(issuer) {
const url = issuer.replace(/\/+$/, "") + "/.well-known/openid-configuration";
const r = await fetch(url);
if (!r.ok) throw new Error(`OIDC discovery failed (HTTP ${r.status}).`);
const d = await r.json();
if (!d.authorization_endpoint || !d.token_endpoint) {
throw new Error("OIDC discovery is missing authorization/token endpoints.");
}
return { authorize: d.authorization_endpoint, token: d.token_endpoint };
}
/** Read the hub's public OIDC params. Returns { issuer, clientId } or null. */
export async function fetchHubConfig(baseUrl) {
try {
const r = await fetch(baseUrl.replace(/\/+$/, "") + "/config");
if (!r.ok) return null;
const d = await r.json();
if (!d.issuer || !d.client_id) return null;
return { issuer: d.issuer, clientId: d.client_id };
} catch {
return null;
}
}
// --- login (redirect away) --------------------------------------------------
/** Begin a PKCE login: stash the verifier+state and redirect to the IdP. */
export async function beginLogin({ issuer, clientId }) {
const { authorize } = await discover(issuer);
const verifier = randomString(48);
const state = randomString(16);
const redirect_uri = redirectUri();
sessionStorage.setItem(
PKCE_KEY,
JSON.stringify({ verifier, state, issuer, clientId, redirect_uri }),
);
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri,
scope: "openid offline_access",
state,
code_challenge: await challengeOf(verifier),
code_challenge_method: "S256",
});
location.assign(`${authorize}?${params}`);
}
// --- callback (back from the IdP) -------------------------------------------
/** True when the current URL is an OAuth redirect callback. */
export function isCallback() {
const p = new URLSearchParams(location.search);
return (p.has("code") && p.has("state")) || p.has("error");
}
/** Exchange the callback code for tokens. Always cleans the URL. Returns the
* access token on success; throws on failure. */
export async function completeLogin() {
const p = new URLSearchParams(location.search);
const cleanUrl = () => history.replaceState(null, "", redirectUri());
let pkce = null;
try {
pkce = JSON.parse(sessionStorage.getItem(PKCE_KEY) || "null");
} catch {
pkce = null;
}
sessionStorage.removeItem(PKCE_KEY);
if (p.get("error")) {
cleanUrl();
throw new Error(p.get("error_description") || p.get("error"));
}
if (!pkce || pkce.state !== p.get("state")) {
cleanUrl();
throw new Error("Login state mismatch — please try again.");
}
const { token } = await discover(pkce.issuer);
const body = new URLSearchParams({
grant_type: "authorization_code",
code: p.get("code"),
client_id: pkce.clientId,
redirect_uri: pkce.redirect_uri,
code_verifier: pkce.verifier,
});
const r = await fetch(token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
cleanUrl();
if (!r.ok) throw new Error(`Token exchange failed (HTTP ${r.status}).`);
const t = await r.json();
saveAuth({
issuer: pkce.issuer,
clientId: pkce.clientId,
access: t.access_token,
refresh: t.refresh_token || null,
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
});
return t.access_token;
}
// --- token lifecycle --------------------------------------------------------
/** Return a usable access token, refreshing if it is near expiry (or `force`).
* Returns null when not logged in or when a refresh fails (caller re-prompts). */
export async function ensureFreshToken(force = false) {
const a = loadAuth();
if (!a) return null;
const stillFresh = a.expiresAt - Date.now() > 60_000;
if (!force && stillFresh) return a.access;
if (!a.refresh) return force ? null : a.access;
try {
const { token } = await discover(a.issuer);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: a.refresh,
client_id: a.clientId,
});
const r = await fetch(token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!r.ok) {
// Refresh token rejected (expired/revoked) — drop the session so the UI
// shows "signed out" and the user can log in again.
clearAuth();
return null;
}
const t = await r.json();
saveAuth({
...a,
access: t.access_token,
refresh: t.refresh_token || a.refresh,
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
});
return t.access_token;
} catch {
// Network blip — keep the (possibly stale) token; the RPC layer will retry.
return a.access;
}
}

View file

@ -49,10 +49,8 @@ export class Client {
return !!this.settings.baseUrl; return !!this.settings.baseUrl;
} }
/** Low-level call: returns the `result` value, or throws RpcError. On a 401, /** Low-level call: returns the `result` value, or throws RpcError. */
* tries the optional `settings.refresh()` hook once (OIDC silent refresh) and async call(method, params = {}) {
* retries before surfacing the error. */
async call(method, params = {}, _retried = false) {
if (!this.configured) { if (!this.configured) {
throw new RpcError("No hub configured — open Settings and set the hub URL.", 0); throw new RpcError("No hub configured — open Settings and set the hub URL.", 0);
} }
@ -70,16 +68,7 @@ export class Client {
} catch (e) { } catch (e) {
throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0); throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0);
} }
if (resp.status === 401) { if (resp.status === 401) throw new RpcError("Unauthorized — set or refresh your token.", 401);
if (!_retried && typeof this.settings.refresh === "function") {
const fresh = await this.settings.refresh();
if (fresh) {
this.settings.token = fresh;
return this.call(method, params, true);
}
}
throw new RpcError("Unauthorized — sign in again (Settings).", 401);
}
if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403); if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403);
if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status); if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status);

View file

@ -435,26 +435,6 @@ body {
font-size: 12px; font-size: 12px;
color: var(--fg-dim); color: var(--fg-dim);
} }
.settings-login {
align-self: stretch;
}
.settings-auth {
display: flex;
align-items: center;
gap: 10px;
}
.settings-auth .settings-test {
flex: 1;
}
.settings-manual > summary {
font-size: 13px;
color: var(--fg-dim);
cursor: pointer;
margin-bottom: 6px;
}
.settings-manual[open] > summary {
margin-bottom: 10px;
}
/* --- Search --- */ /* --- Search --- */
.search-pane { .search-pane {

View file

@ -1,7 +1,7 @@
// Service worker: cache the app shell so heph launches offline. Data is never // Service worker: cache the app shell so heph launches offline. Data is never
// cached — every /rpc call must hit the live hub (and POSTs aren't cacheable // cached — every /rpc call must hit the live hub (and POSTs aren't cacheable
// anyway). Bump CACHE when shell assets change to evict the old set. // anyway). Bump CACHE when shell assets change to evict the old set.
const CACHE = "heph-pwa-v4"; const CACHE = "heph-pwa-v3";
const SHELL = [ const SHELL = [
"./", "./",
"./index.html", "./index.html",
@ -9,7 +9,6 @@ const SHELL = [
"./manifest.webmanifest", "./manifest.webmanifest",
"./src/app.js", "./src/app.js",
"./src/rpc.js", "./src/rpc.js",
"./src/oauth.js",
"./src/quickadd.js", "./src/quickadd.js",
"./src/datespec.js", "./src/datespec.js",
"./src/fmt.js", "./src/fmt.js",
@ -32,10 +31,8 @@ self.addEventListener("activate", (e) => {
self.addEventListener("fetch", (e) => { self.addEventListener("fetch", (e) => {
const req = e.request; const req = e.request;
// Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin) // Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin)
// goes straight to the network. Skip URLs with a query string too, so the OAuth // goes straight to the network.
// redirect callback (`/?code=…&state=…`) is never cached or served from cache. if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) {
const u = new URL(req.url);
if (req.method !== "GET" || u.origin !== self.location.origin || u.search) {
return; return;
} }
e.respondWith( e.respondWith(

View file

@ -3,12 +3,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js";
parseDate,
parseRecurrence,
humanizeRecurrence,
toEpochMs,
} from "../src/datespec.js";
import { parse } from "../src/quickadd.js"; import { parse } from "../src/quickadd.js";
const d = (y, m, day) => new Date(y, m - 1, day); 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")); 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. // quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
const QTODAY = d(2026, 6, 3); const QTODAY = d(2026, 6, 3);
const PROJECTS = [ const PROJECTS = [