Compare commits

...

12 commits

Author SHA1 Message Date
c9bb2cbe64 feat(heph-tui): show sync age in seconds under a minute
All checks were successful
Build / validate (push) Successful in 6m28s
The background sync loop runs every 30s, so the last-sync age never crossed
the 60s 'just now' threshold — the chip always read 'just now', which also
masked the first missed sync (age 30-60s looked identical to a fresh one).
Show seconds under a minute ('⟳ 26s') so the chip is a visible heartbeat and a
stalled sync surfaces ~30s sooner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:24:09 -07:00
Forgejo Actions
1a8752f124 Update changelog for v1.2.3 [skip ci] 2026-06-06 11:03:45 -07:00
02a8dd5180 Merge pull request 'heph-tui sync health: last-sync age, pending conflicts, auth-failure indicator' (#11) from feature/tui-sync-health into main
All checks were successful
Build / validate (push) Successful in 8m0s
2026-06-06 11:03:00 -07:00
11aa25c9f4 feat(heph-tui,hephd): surface sync health (last-sync age, conflicts, auth failure)
All checks were successful
Build / validate (pull_request) Successful in 6m11s
A spoke could be silently failing to sync (expired token → 401, or hub
unreachable) with the only signal buried in the daemon log. Now:

- hephd tracks SyncHealth (last attempt/success time, last error, auth-failure
  flag) from the background sync loop and sync.now, classifying a 401 as an auth
  failure. sync.status returns it plus the pending merge-conflict count.
- heph-tui shows a live status-line indicator (spoke only): '⟳ <age>' since the
  last good sync, red '⚠ auth' when re-login is needed, '⚠ offline' when the hub
  is unreachable, and '⚠ N conflicts' when conflicts are pending. The event loop
  polls on a 2s tick so the age advances and failures appear while idle.
- docs: recommended Authentik access/refresh token validity to stop frequent
  re-logins (with the iOS PWA localStorage-eviction caveat).

Closes the 'Add hub connection status to heph-tui' and 'Spoke sync health:
surface unhealthy state instead of silent 401 spam' backlog items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:19:11 -07:00
Forgejo Actions
4bf255b211 Update changelog for v1.2.2 [skip ci] 2026-06-06 09:30:28 -07:00
b2ddb41a46 Merge pull request 'heph-tui + PWA cosmetic polish: humanized recurrence, scrolling/indented/counted project sidebar' (#10) from feature/tui-polish-project-tree into main
All checks were successful
Build / validate (push) Successful in 4m38s
2026-06-06 09:29:10 -07:00
9a487cbe3b feat(heph-tui,heph-pwa): humanized recurrence + indented/counted/scrolling project sidebar
All checks were successful
Build / validate (pull_request) Successful in 6m57s
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side;
no schema or sync change (see hub-spoke-data-evolution).

- humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an
  RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back
  to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in
  the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa
  previews instead of the raw FREQ= string.
- project.overview RPC + Store::project_overview: each project's parent (via the
  existing 'parent' links) and direct outstanding-task count, a read-only query.
- TUI sidebar: subprojects indented by depth, per-project counts, wider pane,
  and ListState + scrollbar so it scrolls instead of clipping on overflow.

Tests: humanize parity (Rust + JS), round-trip through parse_recurrence,
raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle
safety.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:44:43 -07:00
00da36c637 doc(explanation): hub+spoke data-evolution / migration rules
All checks were successful
Build / validate (pull_request) Successful in 6m18s
Document why heph's op-based sync lets most new features (new link types,
read-side queries, optional payload fields) ship without a coordinated
migration across the hub and spokes, and the narrow case — a new required
SQLite column the apply path writes — that does need a hub-first rollout.

Groundwork for the indented/counted project sidebar, which is pure read-side
(existing parent links + a GROUP BY) and needs no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:31:11 -07:00
Forgejo Actions
c8512b2b50 Update changelog for v1.2.1 [skip ci] 2026-06-05 07:36:46 -07:00
36bd27226f Merge pull request 'heph-pwa: Login with Authentik (Authorization Code + PKCE)' (#9) from heph-pwa-oidc-login into main
All checks were successful
Build / validate (push) Successful in 7m46s
Reviewed-on: #9
2026-06-05 07:32:26 -07:00
1f81a2e6d9 feat(heph-pwa): Login with Authentik (Authorization Code + PKCE)
All checks were successful
Build / validate (pull_request) Successful in 6m31s
Replace the manual bearer-token paste with a proper browser OIDC sign-in.

- Hub: unauthenticated GET /config -> {issuer, client_id} (added after the auth
  layer), sourced from the verifier's new TokenVerifier::oidc_config(). Lets the
  PWA self-configure when served from the hub. Tests in web_serve.rs.
- PWA: src/oauth.js implements PKCE (S256), the authorize redirect, the callback
  token exchange, and silent refresh (offline_access). Settings gains a "Login
  with Authentik" button (manual token kept under a fallback disclosure); rpc.js
  retries once on 401 via a refresh hook; app.js completes the callback / refreshes
  on load; sw.js skips caching the callback URL and ships oauth.js in the shell.

Requires the PWA origin registered as a redirect URI on the Authentik provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:17:05 -07:00
a0be0f1085 doc(heph-pwa): in-app Authentik login replaces manual token paste
Document the PKCE 'Login with Authentik' flow, the hub /config zero-config
discovery, and the redirect-URI prerequisite on the Authentik heph provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:09:42 -07:00
31 changed files with 1741 additions and 80 deletions

View file

@ -12,6 +12,35 @@ 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

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, SchedulePatch, SyncCursors, Task, TaskState, NodeKind, ProjectOverview, 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,6 +314,24 @@ 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, SchedulePatch, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
SyncCursors, Task, TaskState, SchedulePatch, SyncCursors, Task, TaskState,
}; };
use crate::oplog::Op; use crate::oplog::Op;
use crate::ranking::RankedTask; use crate::ranking::RankedTask;
@ -297,6 +297,10 @@ 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)
} }
@ -498,6 +502,67 @@ 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,6 +3,8 @@
//! 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;
@ -12,7 +14,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, SchedulePatch, Task, TaskState, Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState,
}; };
use crate::oplog::op_type; use crate::oplog::op_type;
use crate::ranking::{self, RankedTask}; use crate::ranking::{self, RankedTask};
@ -482,6 +484,57 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> {
}) })
} }
/// Every project (owner-scoped, non-tombstoned) with its parent project (via a
/// `parent` link) and its direct outstanding-task count — the shape a sidebar
/// renders as a counted, indented tree (§8.1). Pure read-side: counts come from
/// a single `GROUP BY` over the `in-project` links, parents from the `parent`
/// links, both already in the store. Title-sorted for a stable sibling order.
pub(super) fn project_overview(conn: &Connection, owner: &str) -> Result<Vec<ProjectOverview>> {
// Direct outstanding count per project: each task's project is its first
// `in-project` link target (mirrors `list`/`load_candidates`).
let mut count_stmt = conn.prepare(
"SELECT (SELECT dst_id FROM links
WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS project_id,
COUNT(*)
FROM nodes n JOIN tasks t ON t.node_id = n.id
WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'
GROUP BY project_id",
)?;
let mut counts: HashMap<String, usize> = HashMap::new();
let rows = count_stmt.query_map([owner], |r| {
Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
})?;
for row in rows {
let (project_id, count) = row?;
if let Some(pid) = project_id {
counts.insert(pid, count as usize);
}
}
// Parent of each project: the dst of its (first) `parent` link.
let mut parent_stmt = conn.prepare(
"SELECT dst_id FROM links
WHERE src_id = ?1 AND type = 'parent' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1",
)?;
let mut out = Vec::new();
for node in nodes::list(conn, owner, Some(NodeKind::Project))? {
let parent_id = parent_stmt
.query_row([&node.id], |r| r.get::<_, String>(0))
.optional()?;
out.push(ProjectOverview {
outstanding: counts.get(&node.id).copied().unwrap_or(0),
id: node.id,
title: node.title,
parent_id,
});
}
out.sort_by(|a, b| a.title.cmp(&b.title));
Ok(out)
}
/// Load every non-tombstoned committed task for `owner` as a ranking candidate, /// 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, SchedulePatch, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
SyncCursors, Task, TaskState, SchedulePatch, SyncCursors, Task, TaskState,
}; };
use crate::oplog::Op; use crate::oplog::Op;
use crate::ranking::RankedTask; use crate::ranking::RankedTask;
@ -142,6 +142,12 @@ 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, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
use crate::backend::{Backend, Project, SearchHit}; use crate::backend::{Backend, Project, SearchHit, SyncStatus};
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,8 +313,18 @@ pub enum Focus {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SidebarEntry { pub enum SidebarEntry {
Header(String), Header(String),
View { name: String, title: String }, View {
Project { id: String, title: String }, name: String,
title: String,
},
/// A project row. `depth` is its nesting level (0 = top-level) for indent;
/// `count` is its direct outstanding-task count, shown as a trailing chip.
Project {
id: String,
title: String,
depth: u16,
count: usize,
},
} }
impl SidebarEntry { impl SidebarEntry {
@ -323,6 +333,70 @@ impl SidebarEntry {
} }
} }
/// Turn the daemon's flat (title-sorted) project overview into sidebar rows in
/// tree order — each project followed by its descendants, carrying the nesting
/// `depth` and outstanding `count` the renderer needs.
fn project_entries(overview: Vec<ProjectOverview>) -> Vec<SidebarEntry> {
let order = order_projects(&overview);
let mut overview: Vec<Option<ProjectOverview>> = overview.into_iter().map(Some).collect();
order
.into_iter()
.map(|(i, depth)| {
let p = overview[i].take().expect("each index visited once");
SidebarEntry::Project {
id: p.id,
title: p.title,
depth,
count: p.outstanding,
}
})
.collect()
}
/// Depth-first display order over the project forest: returns `(index, depth)`
/// pairs, each project ahead of its children, siblings in the input's title
/// order. A project whose parent is missing (tombstoned, or not in the set)
/// renders at the top level; cycles can't loop (each node is emitted once).
fn order_projects(overview: &[ProjectOverview]) -> Vec<(usize, u16)> {
use std::collections::HashSet;
let ids: HashSet<&str> = overview.iter().map(|p| p.id.as_str()).collect();
let mut children: HashMap<&str, Vec<usize>> = HashMap::new();
let mut roots: Vec<usize> = Vec::new();
for (i, p) in overview.iter().enumerate() {
match &p.parent_id {
Some(pid) if ids.contains(pid.as_str()) => {
children.entry(pid.as_str()).or_default().push(i);
}
_ => roots.push(i),
}
}
let mut out = Vec::with_capacity(overview.len());
let mut visited = vec![false; overview.len()];
// Stack of (index, depth); push siblings reversed so we pop in title order.
let mut stack: Vec<(usize, u16)> = roots.iter().rev().map(|&i| (i, 0)).collect();
while let Some((i, depth)) = stack.pop() {
if visited[i] {
continue;
}
visited[i] = true;
out.push((i, depth));
if let Some(kids) = children.get(overview[i].id.as_str()) {
for &k in kids.iter().rev() {
if !visited[k] {
stack.push((k, depth + 1));
}
}
}
}
// Defensive: any node trapped in a parent-cycle still gets one top-level row.
for (i, seen) in visited.iter().enumerate() {
if !seen {
out.push((i, 0));
}
}
out
}
/// The selected sidebar source, as owned values (so reloads don't hold a borrow /// 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 {
@ -359,6 +433,8 @@ 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,
} }
@ -376,9 +452,7 @@ impl<B: Backend> App<B> {
}); });
} }
sidebar.push(SidebarEntry::Header("Projects".into())); sidebar.push(SidebarEntry::Header("Projects".into()));
for Project { id, title } in backend.projects()? { sidebar.extend(project_entries(backend.project_overview()?));
sidebar.push(SidebarEntry::Project { id, title });
}
let sidebar_cursor = sidebar let sidebar_cursor = sidebar
.iter() .iter()
@ -400,12 +474,23 @@ 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) {
@ -423,7 +508,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,
}) })
} }
@ -469,7 +554,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()
@ -742,7 +827,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(),
@ -881,10 +966,8 @@ impl<B: Backend> App<B> {
.filter(|e| !matches!(e, SidebarEntry::Project { .. })) .filter(|e| !matches!(e, SidebarEntry::Project { .. }))
.cloned() .cloned()
.collect(); .collect();
if let Ok(projects) = self.backend.projects() { if let Ok(overview) = self.backend.project_overview() {
for Project { id, title } in projects { rebuilt.extend(project_entries(overview));
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
@ -923,7 +1006,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(),
}), }),
@ -1213,4 +1296,67 @@ 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,10 +22,55 @@ 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).
@ -40,6 +85,11 @@ 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) ---
@ -103,6 +153,11 @@ 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)?)
@ -149,6 +204,11 @@ 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,6 +25,29 @@ 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 {
@ -102,6 +125,19 @@ 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,14 +61,22 @@ 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 let Event::Key(key) = event::read()? { if event::poll(tick)? {
if key.kind == KeyEventKind::Press { if let Event::Key(key) = event::read()? {
if let Some(action) = handle_key(&mut app, key) { if key.kind == KeyEventKind::Press {
perform(terminal, &mut app, socket, action)?; if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
}
} }
} }
} else {
// Idle tick: refresh the sync-health snapshot for the status line.
app.refresh_sync();
} }
if app.should_quit { 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::{Constraint, Direction, Layout, Margin, Rect}, layout::{Alignment, 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; use crate::backend::{Backend, SyncStatus};
use crate::fmt::{fmt_date, project_color, today_local}; use crate::fmt::{fmt_age, fmt_date, now_ms, 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(22), Constraint::Length(28),
Constraint::Min(28), Constraint::Min(28),
Constraint::Length(38), Constraint::Length(38),
]) ])
@ -151,8 +151,25 @@ fn pane_border(focused: bool) -> Style {
} }
} }
/// The (label, trailing-count) styles for a sidebar row given its selection
/// state: a full-width cyan bar when focus-selected, reversed when selected in
/// the unfocused pane, otherwise plain with a dimmed count.
fn sidebar_row_styles(selected: bool, focused: bool) -> (Style, Style) {
if selected {
let s = if focused {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().add_modifier(Modifier::REVERSED)
};
(s, s)
} else {
(Style::default(), Style::default().fg(Color::DarkGray))
}
}
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { 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()
@ -166,17 +183,38 @@ 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::Project { title, .. } => { SidebarEntry::View { title, .. } => {
let mut style = Style::default(); let (style, _) = sidebar_row_styles(selected, focused);
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();
@ -187,7 +225,29 @@ 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 "),
); );
frame.render_widget(list, area); // Drive scroll-to-visible off the cursor so projects below the fold stay
// reachable; the row's own highlight remains the selection cue.
let mut state = ListState::default();
state.select(Some(app.sidebar_cursor));
frame.render_stateful_widget(list, area, &mut state);
// A scrollbar once the entries can't all fit at once (position tracks the
// cursor — an honest "where am I in the list" signal).
let inner_h = area.height.saturating_sub(2) as usize;
if app.sidebar.len() > inner_h {
let mut sb = ScrollbarState::new(app.sidebar.len()).position(app.sidebar_cursor);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(
bar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut sb,
);
}
} }
/// A dimmed `──── Project ────` group header for the project sort mode, padded /// A dimmed `──── Project ────` group header for the project sort mode, padded
@ -239,7 +299,7 @@ fn task_detail_lines<B: Backend>(
} }
} }
if let Some(rrule) = &t.recurrence { if let Some(rrule) = &t.recurrence {
field("recurs:", rrule.clone()); field("recurs:", hephd::datespec::humanize_rrule(rrule));
} }
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));
@ -478,5 +538,130 @@ 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)
}; };
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); let left = Paragraph::new(Line::from(Span::styled(text, style)));
// A right-aligned sync indicator (spoke only); the hints take the rest.
let indicator = sync_indicator(&app.sync, now_ms());
if indicator.is_empty() {
frame.render_widget(left, area);
return;
}
let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum();
let cols =
Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area);
frame.render_widget(left, cols[0]);
frame.render_widget(
Paragraph::new(Line::from(indicator)).alignment(Alignment::Right),
cols[1],
);
}
/// The status-line sync indicator (empty on a standalone instance): a sync-state
/// chip — `⚠ auth` when re-login is needed, `⟳ <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,6 +98,12 @@ 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]
@ -206,7 +212,12 @@ 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}");
assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}"); // The RRULE is humanized for display (§8.1), not shown raw.
assert!(s.contains("daily"), "recurrence not humanized:\n{s}");
assert!(
!s.contains("FREQ=DAILY"),
"raw rrule leaked into detail:\n{s}"
);
assert!(s.contains("project:"), "no project detail:\n{s}"); assert!(s.contains("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,6 +49,13 @@ 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).
@ -156,4 +163,9 @@ 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,6 +288,172 @@ 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::*;
@ -404,4 +570,71 @@ 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,6 +221,10 @@ 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,6 +352,7 @@ 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,9 +10,10 @@
//! 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; use std::time::{Duration, SystemTime, UNIX_EPOCH};
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};
@ -32,6 +33,23 @@ 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 {
@ -43,6 +61,41 @@ 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 {
@ -87,6 +140,7 @@ 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())),
}, },
} }
} }
@ -170,7 +224,10 @@ impl Daemon {
loop { loop {
tick.tick().await; tick.tick().await;
let bearer = ctx.bearer().await; let bearer = ctx.bearer().await;
match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await { let result =
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx.sync_health, &result);
match result {
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}"),
} }
@ -265,7 +322,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
}); });
}; };
let bearer = ctx.bearer().await; let bearer = ctx.bearer().await;
match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await { let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx.sync_health, &result);
match result {
Ok(report) => Ok(json!(report)), Ok(report) => Ok(json!(report)),
Err(e) => Err(RpcError { Err(e) => Err(RpcError {
code: INTERNAL_ERROR, code: INTERNAL_ERROR,
@ -274,11 +333,28 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
} }
} }
/// `sync.status` — the hub url and the current per-hub cursors. /// `sync.status` — the hub url, the current per-hub cursors, the observed sync
/// health (last-success time / last error / auth-failure flag), and the pending
/// merge-conflict count. A spoke that is silently failing is visible here (and,
/// via it, in the TUI status line).
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 })); return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
}; };
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 || {
@ -291,5 +367,17 @@ 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,6 +135,10 @@ 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
@ -174,6 +178,20 @@ 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,10 +12,23 @@ 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,
@ -64,6 +77,15 @@ 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()
@ -78,7 +100,7 @@ fn start(web_root: Option<std::path::PathBuf>) -> String {
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, None, web_root); let app = sync::router_with_web(shared, verifier, web_root);
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); });
}); });
@ -161,3 +183,31 @@ 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

@ -0,0 +1 @@
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,3 +12,4 @@ 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

@ -0,0 +1,87 @@
---
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,16 +95,24 @@ 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. **Token:** the hub requires an OIDC bearer token, and the PWA does **not yet 4. **Sign in:** open **Settings → Login with Authentik**. The app reads the
implement the in-app device-code login** — paste a token into Settings → hub's `GET /config` for the issuer + client id (zero-config) and runs an
Token for now. Obtain one via the device-code flow against the Authentik Authorization-Code + PKCE redirect to Authentik; after you approve it lands
client (the same flow the CLI uses; e.g. reuse the access token a logged-in back on the app, signed in, and silently refreshes the token from then on.
spoke cached, or run a one-off device-code grant). Tap **Test** to confirm. (A manual **Bearer token** field remains as a fallback for hubs without
OIDC, or for pasting a one-off token.)
> **Known gap / next step:** wire the RFC 8628 device-code flow into the PWA's > Re-prompted for login too often? The fix is the Authentik provider's
> Settings so login is in-app (open the verification URL, poll for the token, > **refresh token validity**, not the app — see the token-lifetime note in
> store it, and refresh it) — removing the manual paste. Tracked as follow-up > [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's
> work for `heph-pwa`. > storage after ~7 idle days; Add to Home Screen mitigates it.)
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a
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,6 +51,26 @@ 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`),
@ -98,10 +118,16 @@ and background-syncs on its interval.
## 4. Verify ## 4. Verify
```bash ```bash
heph sync --status # last push/pull cursors, hub url heph sync --status # hub url, last push/pull cursors, sync health
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,8 +6,9 @@
// 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 } from "./datespec.js"; import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
import { import {
ATTENTION_COLORS, ATTENTION_COLORS,
fmtRelative, fmtRelative,
@ -40,7 +41,29 @@ const state = {
lastUndo: null, // { label, run } lastUndo: null, // { label, run }
}; };
state.client = new Client(state.settings); // Build the RPC client from the current settings, wiring an OIDC silent-refresh
// 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 --------------------------------------------------------
@ -189,7 +212,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", t.recurrence]); if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]);
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]); if (t.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)]);
@ -350,7 +373,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" }, "↻ " + parsed.recurrence)); if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence)));
}; };
input.addEventListener("input", updatePreview); input.addEventListener("input", updatePreview);
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
@ -692,27 +715,62 @@ 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 = new Client(state.settings); state.client = makeClient();
closeModal(); closeModal();
reload(); reload();
}; };
const check = async () => { const check = async () => {
test.textContent = "Checking…"; setTest("Checking…", null);
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", {});
test.textContent = `✓ Connected (hephd ${v.version})`; setTest(`✓ Connected (hephd ${v.version})`, true);
test.className = "settings-test ok";
} catch (e) { } catch (e) {
test.textContent = `${e.message}`; setTest(`${e.message}`, false);
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(
@ -721,8 +779,14 @@ 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" }, "Token"), h("label", { class: "settings-label" }, "Sign-in"),
tok, authRow,
h(
"details",
{ class: "settings-manual" },
h("summary", {}, "Or paste a bearer token"),
tok,
),
test, test,
h( h(
"div", "div",
@ -730,7 +794,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" }, "The hub is your server-mode hephd. Leave the token blank if the hub runs without OIDC."), h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."),
), ),
); );
} }
@ -802,6 +866,20 @@ 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,3 +219,143 @@ 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;
}
}

204
heph-pwa/src/oauth.js Normal file
View file

@ -0,0 +1,204 @@
// 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,8 +49,10 @@ export class Client {
return !!this.settings.baseUrl; return !!this.settings.baseUrl;
} }
/** Low-level call: returns the `result` value, or throws RpcError. */ /** Low-level call: returns the `result` value, or throws RpcError. On a 401,
async call(method, params = {}) { * tries the optional `settings.refresh()` hook once (OIDC silent refresh) and
* 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);
} }
@ -68,7 +70,16 @@ 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) throw new RpcError("Unauthorized — set or refresh your token.", 401); if (resp.status === 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,6 +435,26 @@ 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-v3"; const CACHE = "heph-pwa-v4";
const SHELL = [ const SHELL = [
"./", "./",
"./index.html", "./index.html",
@ -9,6 +9,7 @@ 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",
@ -31,8 +32,10 @@ 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. // goes straight to the network. Skip URLs with a query string too, so the OAuth
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) { // redirect callback (`/?code=…&state=…`) is never cached or served from cache.
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,7 +3,12 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js"; import {
parseDate,
parseRecurrence,
humanizeRecurrence,
toEpochMs,
} from "../src/datespec.js";
import { parse } from "../src/quickadd.js"; 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);
@ -58,6 +63,60 @@ 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 = [