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 -->
## [v1.2.3] - 2026-06-06
### Features
- heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log.
### Documentation
- [[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it.
## [v1.2.2] - 2026-06-06
### Features
- Recurring tasks now show their schedule in plain language (`every other week`, `weekdays`, `yearly on Apr 15`) instead of a raw RRULE — in both the TUI detail pane and the mobile PWA. The TUI's project sidebar gained subproject indentation, per-project outstanding-task counts, a wider pane, and scrolling when the list overflows.
### Documentation
- New explanation card [[hub-spoke-data-evolution]] covering why heph's op-based sync lets most new features ship without a coordinated migration, and the narrow case (a new required SQLite column) that does need a hub-first rollout.
## [v1.2.1] - 2026-06-05
### Features
- heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider.
## [v1.2.0] - 2026-06-04
### Features

View file

@ -38,7 +38,7 @@ pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS};
pub use hlc::{Hlc, HlcClock};
pub use model::{
deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node,
NodeKind, SchedulePatch, SyncCursors, Task, TaskState,
NodeKind, ProjectOverview, SchedulePatch, SyncCursors, Task, TaskState,
};
pub use oplog::Op;
pub use ranking::{rank, Dimension, RankedTask, RANKING};

View file

@ -314,6 +314,24 @@ pub struct Health {
pub sync_status: String,
}
/// A project plus the two facts a sidebar needs to render it as a counted,
/// indented tree (§8.1): its parent project (via a `parent` link, if any) and
/// the number of outstanding tasks filed **directly** under it. Pure read-side —
/// both derive from existing data, so this carries no schema or sync change (see
/// [[hub-spoke-data-evolution]]).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectOverview {
/// The project node id.
pub id: String,
/// The project's title.
pub title: String,
/// The parent project's node id, or `None` for a top-level project.
pub parent_id: Option<String>,
/// Outstanding tasks filed directly in this project (children counted under
/// their own row, not summed here).
pub outstanding: usize,
}
/// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec
/// §12). The winning value is already in the store; this records what was
/// dropped so `heph conflicts` can show and settle it.

View file

@ -32,8 +32,8 @@ use crate::error::{Error, Result};
use crate::filter::ListFilter;
use crate::hlc::Hlc;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SyncCursors, Task, TaskState,
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
SchedulePatch, SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
use crate::ranking::RankedTask;
@ -297,6 +297,10 @@ impl Store for LocalStore {
tasks::health(&self.conn, &self.owner_id)
}
fn project_overview(&self) -> Result<Vec<ProjectOverview>> {
tasks::project_overview(&self.conn, &self.owner_id)
}
fn search(&self, query: &str) -> Result<Vec<Node>> {
nodes::search(&self.conn, &self.owner_id, query)
}
@ -498,6 +502,67 @@ mod tests {
assert!(store.project_scope("Nope").is_err());
}
#[test]
fn project_overview_carries_parent_and_direct_outstanding_count() {
use crate::model::{LinkType, NewNode, NewTask, NodeKind, TaskState};
let mut store = store_at(1);
let mk_proj = |store: &mut LocalStore, title: &str| {
store
.create_node(NewNode {
kind: NodeKind::Project,
title: title.into(),
body: None,
})
.unwrap()
.id
};
let mk_task = |store: &mut LocalStore, title: &str, project: Option<&str>| {
store
.create_task(NewTask {
title: title.into(),
attention: None,
do_date: None,
late_on: None,
recurrence: None,
project_id: project.map(String::from),
})
.unwrap()
.node_id
};
let work = mk_proj(&mut store, "Work");
let sub = mk_proj(&mut store, "Work Sub");
mk_proj(&mut store, "Garden");
// Work Sub is a child of Work (child holds the `parent` link → parent).
store.add_link(&sub, &work, LinkType::Parent).unwrap();
// Two outstanding + one done in Work; one outstanding in the subproject.
mk_task(&mut store, "ship", Some(&work));
mk_task(&mut store, "review", Some(&work));
let done = mk_task(&mut store, "archived", Some(&work));
store.set_task_state(&done, TaskState::Done).unwrap();
mk_task(&mut store, "nested", Some(&sub));
// An unfiled task counts toward no project.
mk_task(&mut store, "loose", None);
let overview = store.project_overview().unwrap();
// Title-sorted.
let titles: Vec<_> = overview.iter().map(|p| p.title.as_str()).collect();
assert_eq!(titles, ["Garden", "Work", "Work Sub"]);
let by_title = |t: &str| overview.iter().find(|p| p.title == t).unwrap();
assert_eq!(by_title("Work").outstanding, 2, "done task excluded");
assert_eq!(by_title("Work").parent_id, None);
assert_eq!(
by_title("Work Sub").outstanding,
1,
"direct only, not summed"
);
assert_eq!(by_title("Work Sub").parent_id, Some(work.clone()));
assert_eq!(by_title("Garden").outstanding, 0);
assert_eq!(by_title("Garden").parent_id, None);
}
#[test]
fn resolve_project_is_fuzzy_only_when_unambiguous() {
use crate::model::{NewNode, NodeKind};

View file

@ -3,6 +3,8 @@
//! A committed task is a `task` node plus a `tasks` row. On creation it also
//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, Row};
use serde_json::json;
@ -12,7 +14,7 @@ use crate::error::{Error, Result};
use crate::extract;
use crate::filter::ListFilter;
use crate::model::{
Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState,
Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState,
};
use crate::oplog::op_type;
use crate::ranking::{self, RankedTask};
@ -482,6 +484,57 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> {
})
}
/// Every project (owner-scoped, non-tombstoned) with its parent project (via a
/// `parent` link) and its direct outstanding-task count — the shape a sidebar
/// renders as a counted, indented tree (§8.1). Pure read-side: counts come from
/// a single `GROUP BY` over the `in-project` links, parents from the `parent`
/// links, both already in the store. Title-sorted for a stable sibling order.
pub(super) fn project_overview(conn: &Connection, owner: &str) -> Result<Vec<ProjectOverview>> {
// Direct outstanding count per project: each task's project is its first
// `in-project` link target (mirrors `list`/`load_candidates`).
let mut count_stmt = conn.prepare(
"SELECT (SELECT dst_id FROM links
WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS project_id,
COUNT(*)
FROM nodes n JOIN tasks t ON t.node_id = n.id
WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'
GROUP BY project_id",
)?;
let mut counts: HashMap<String, usize> = HashMap::new();
let rows = count_stmt.query_map([owner], |r| {
Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
})?;
for row in rows {
let (project_id, count) = row?;
if let Some(pid) = project_id {
counts.insert(pid, count as usize);
}
}
// Parent of each project: the dst of its (first) `parent` link.
let mut parent_stmt = conn.prepare(
"SELECT dst_id FROM links
WHERE src_id = ?1 AND type = 'parent' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1",
)?;
let mut out = Vec::new();
for node in nodes::list(conn, owner, Some(NodeKind::Project))? {
let parent_id = parent_stmt
.query_row([&node.id], |r| r.get::<_, String>(0))
.optional()?;
out.push(ProjectOverview {
outstanding: counts.get(&node.id).copied().unwrap_or(0),
id: node.id,
title: node.title,
parent_id,
});
}
out.sort_by(|a, b| a.title.cmp(&b.title));
Ok(out)
}
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
/// joining in its project and canonical-context link targets.
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {

View file

@ -7,8 +7,8 @@
use crate::error::Result;
use crate::filter::ListFilter;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SyncCursors, Task, TaskState,
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
SchedulePatch, SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
use crate::ranking::RankedTask;
@ -142,6 +142,12 @@ pub trait Store {
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
fn health(&self) -> Result<Health>;
/// Every project with its parent (via a `parent` link) and its direct
/// outstanding-task count — the shape a sidebar renders as a counted,
/// indented tree (§8.1). Read-only over existing data; no schema or sync
/// change (see [[hub-spoke-data-evolution]]).
fn project_overview(&self) -> Result<Vec<ProjectOverview>>;
/// Full-text search over title + body (FTS5), owner-scoped, best-match
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
fn search(&self, query: &str) -> Result<Vec<Node>>;

View file

@ -7,9 +7,9 @@ use std::collections::HashMap;
use anyhow::Result;
use chrono::NaiveDate;
use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
use crate::backend::{Backend, Project, SearchHit};
use crate::backend::{Backend, Project, SearchHit, SyncStatus};
use crate::fmt::{days_overdue, today_local};
/// How the task list is ordered (toggled in the UI, §8.1).
@ -313,8 +313,18 @@ pub enum Focus {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SidebarEntry {
Header(String),
View { name: String, title: String },
Project { id: String, title: String },
View {
name: String,
title: String,
},
/// A project row. `depth` is its nesting level (0 = top-level) for indent;
/// `count` is its direct outstanding-task count, shown as a trailing chip.
Project {
id: String,
title: String,
depth: u16,
count: usize,
},
}
impl SidebarEntry {
@ -323,6 +333,70 @@ impl SidebarEntry {
}
}
/// Turn the daemon's flat (title-sorted) project overview into sidebar rows in
/// tree order — each project followed by its descendants, carrying the nesting
/// `depth` and outstanding `count` the renderer needs.
fn project_entries(overview: Vec<ProjectOverview>) -> Vec<SidebarEntry> {
let order = order_projects(&overview);
let mut overview: Vec<Option<ProjectOverview>> = overview.into_iter().map(Some).collect();
order
.into_iter()
.map(|(i, depth)| {
let p = overview[i].take().expect("each index visited once");
SidebarEntry::Project {
id: p.id,
title: p.title,
depth,
count: p.outstanding,
}
})
.collect()
}
/// Depth-first display order over the project forest: returns `(index, depth)`
/// pairs, each project ahead of its children, siblings in the input's title
/// order. A project whose parent is missing (tombstoned, or not in the set)
/// renders at the top level; cycles can't loop (each node is emitted once).
fn order_projects(overview: &[ProjectOverview]) -> Vec<(usize, u16)> {
use std::collections::HashSet;
let ids: HashSet<&str> = overview.iter().map(|p| p.id.as_str()).collect();
let mut children: HashMap<&str, Vec<usize>> = HashMap::new();
let mut roots: Vec<usize> = Vec::new();
for (i, p) in overview.iter().enumerate() {
match &p.parent_id {
Some(pid) if ids.contains(pid.as_str()) => {
children.entry(pid.as_str()).or_default().push(i);
}
_ => roots.push(i),
}
}
let mut out = Vec::with_capacity(overview.len());
let mut visited = vec![false; overview.len()];
// Stack of (index, depth); push siblings reversed so we pop in title order.
let mut stack: Vec<(usize, u16)> = roots.iter().rev().map(|&i| (i, 0)).collect();
while let Some((i, depth)) = stack.pop() {
if visited[i] {
continue;
}
visited[i] = true;
out.push((i, depth));
if let Some(kids) = children.get(overview[i].id.as_str()) {
for &k in kids.iter().rev() {
if !visited[k] {
stack.push((k, depth + 1));
}
}
}
}
// Defensive: any node trapped in a parent-cycle still gets one top-level row.
for (i, seen) in visited.iter().enumerate() {
if !seen {
out.push((i, 0));
}
}
out
}
/// The selected sidebar source, as owned values (so reloads don't hold a borrow
/// on `self.sidebar` while calling the backend).
enum Target {
@ -359,6 +433,8 @@ pub struct App<B: Backend> {
undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>,
pub status: String,
/// Latest sync health for the status-line indicator (refreshed on a tick).
pub sync: SyncStatus,
pub should_quit: bool,
}
@ -376,9 +452,7 @@ impl<B: Backend> App<B> {
});
}
sidebar.push(SidebarEntry::Header("Projects".into()));
for Project { id, title } in backend.projects()? {
sidebar.push(SidebarEntry::Project { id, title });
}
sidebar.extend(project_entries(backend.project_overview()?));
let sidebar_cursor = sidebar
.iter()
@ -400,12 +474,23 @@ impl<B: Backend> App<B> {
undo_stack: Vec::new(),
redo_stack: Vec::new(),
status: String::new(),
sync: SyncStatus::default(),
should_quit: false,
};
app.reload();
app.refresh_sync();
Ok(app)
}
/// Refresh the sync-health snapshot for the status line. Best-effort: a
/// failed read leaves the previous snapshot in place (a stale indicator
/// beats a flicker), so this never disrupts navigation.
pub fn refresh_sync(&mut self) {
if let Ok(status) = self.backend.sync_status() {
self.sync = status;
}
}
/// The title shown above the task list (the selected source).
pub fn task_pane_title(&self) -> String {
match self.sidebar.get(self.sidebar_cursor) {
@ -423,7 +508,7 @@ impl<B: Backend> App<B> {
/// The title of a project node id, resolved from the sidebar.
pub fn project_name(&self, id: &str) -> Option<String> {
self.sidebar.iter().find_map(|e| match e {
SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()),
SidebarEntry::Project { id: pid, title, .. } if pid == id => Some(title.clone()),
_ => None,
})
}
@ -469,7 +554,7 @@ impl<B: Backend> App<B> {
self.sidebar
.iter()
.filter_map(|e| match e {
SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())),
SidebarEntry::Project { id, title, .. } => Some((id.clone(), title.clone())),
_ => None,
})
.collect()
@ -742,7 +827,7 @@ impl<B: Backend> App<B> {
/// become unfiled (they move to the Inbox), not deleted.
pub fn begin_delete_project(&mut self) {
match self.sidebar.get(self.sidebar_cursor) {
Some(SidebarEntry::Project { id, title }) => {
Some(SidebarEntry::Project { id, title, .. }) => {
self.pending_delete = Some(PendingDelete::Project {
project_id: id.clone(),
title: title.clone(),
@ -881,10 +966,8 @@ impl<B: Backend> App<B> {
.filter(|e| !matches!(e, SidebarEntry::Project { .. }))
.cloned()
.collect();
if let Ok(projects) = self.backend.projects() {
for Project { id, title } in projects {
rebuilt.push(SidebarEntry::Project { id, title });
}
if let Ok(overview) = self.backend.project_overview() {
rebuilt.extend(project_entries(overview));
}
self.sidebar = rebuilt;
// Restore the cursor: same entry if present, else the nearest selectable
@ -923,7 +1006,7 @@ impl<B: Backend> App<B> {
self.sidebar
.iter()
.filter_map(|e| match e {
SidebarEntry::Project { id, title } => Some(Project {
SidebarEntry::Project { id, title, .. } => Some(Project {
id: id.clone(),
title: title.clone(),
}),
@ -1213,4 +1296,67 @@ mod sort_tests {
// Alpha group (red before blue), then Beta, then project-less tasks last.
assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]);
}
fn po(id: &str, title: &str, parent: Option<&str>, outstanding: usize) -> ProjectOverview {
ProjectOverview {
id: id.into(),
title: title.into(),
parent_id: parent.map(str::to_string),
outstanding,
}
}
#[test]
fn project_entries_nest_children_under_parents_with_depth_and_count() {
// Input arrives title-sorted from the daemon.
let overview = vec![
po("g", "Garden", None, 0),
po("w", "Work", None, 2),
po("ws", "Work Sub", Some("w"), 1),
po("wsx", "Work Sub Sub", Some("ws"), 5),
];
let rows: Vec<(String, u16, usize)> = project_entries(overview)
.into_iter()
.map(|e| match e {
SidebarEntry::Project {
title,
depth,
count,
..
} => (title, depth, count),
_ => unreachable!("project_entries yields only Project rows"),
})
.collect();
assert_eq!(
rows,
vec![
("Garden".into(), 0, 0),
("Work".into(), 0, 2),
("Work Sub".into(), 1, 1),
("Work Sub Sub".into(), 2, 5),
]
);
}
#[test]
fn project_entries_treat_a_missing_parent_as_top_level() {
// A child whose parent isn't in the set (e.g. tombstoned) still shows.
let overview = vec![po("orphan", "Orphan", Some("gone"), 3)];
let rows = project_entries(overview);
assert!(matches!(
rows.as_slice(),
[SidebarEntry::Project {
depth: 0,
count: 3,
..
}]
));
}
#[test]
fn order_projects_does_not_loop_on_a_parent_cycle() {
// a→b→a is pathological but must still terminate, each row once.
let overview = vec![po("a", "A", Some("b"), 0), po("b", "B", Some("a"), 0)];
assert_eq!(order_projects(&overview).len(), 2);
}
}

View file

@ -22,10 +22,55 @@ pub struct SearchHit {
pub kind: String,
}
/// Sync health for the status line (the `sync.status` RPC). On a standalone
/// instance `hub_url` is `None` and `health` is absent; the conflict count is
/// always present.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
pub struct SyncStatus {
/// The hub this device syncs with, or `None` if standalone (no indicator).
pub hub_url: Option<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.
pub trait Backend {
/// All project nodes (for the sidebar), title-sorted.
fn projects(&mut self) -> Result<Vec<Project>>;
/// Projects enriched with parent + direct outstanding-task count, for the
/// indented, counted sidebar tree (§8.1). The default derives a flat list
/// from [`projects`](Self::projects); the real backend forwards the
/// dedicated `project.overview` RPC.
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
Ok(self
.projects()?
.into_iter()
.map(|p| heph_core::ProjectOverview {
id: p.id,
title: p.title,
parent_id: None,
outstanding: 0,
})
.collect())
}
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
/// Run a raw [`ListFilter`] (used for per-project scope).
@ -40,6 +85,11 @@ pub trait Backend {
/// A task's canonical-context doc id (where its description/checklist live),
/// for opening a task search-hit at the useful node. `None` if it has none.
fn context_of(&mut self, task_id: &str) -> Result<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) ---
@ -103,6 +153,11 @@ impl Backend for ClientBackend {
Ok(projects)
}
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
let v = self.call("project.overview", json!({}))?;
Ok(serde_json::from_value(v)?)
}
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
let v = self.call("view", json!({ "name": name }))?;
Ok(serde_json::from_value(v)?)
@ -149,6 +204,11 @@ impl Backend for ClientBackend {
.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<()> {
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
Ok(())

View file

@ -25,6 +25,29 @@ pub fn today_local() -> NaiveDate {
Local::now().date_naive()
}
/// Now, in epoch milliseconds (the reference for [`fmt_age`]).
pub fn now_ms() -> i64 {
Local::now().timestamp_millis()
}
/// A compact "how long ago" for the sync indicator: `Ns` under a minute, then
/// `Nm` / `Nh` / `Nd`. Second-granularity under a minute makes the chip a visible
/// heartbeat (the sync loop runs every 30s) and surfaces a missed beat as the age
/// climbing, rather than hiding under a flat "just now". Clamped at zero so a
/// little clock skew never shows a negative age.
pub fn fmt_age(now_ms: i64, then_ms: i64) -> String {
let secs = (now_ms - then_ms).max(0) / 1000;
if secs < 60 {
format!("{secs}s")
} else if secs < 3_600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3_600)
} else {
format!("{}d", secs / 86_400)
}
}
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
@ -102,6 +125,19 @@ mod tests {
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
}
#[test]
fn age_is_compact_and_clamped() {
let now = 1_000_000_000_000;
assert_eq!(fmt_age(now, now), "0s");
assert_eq!(fmt_age(now, now - 30_000), "30s");
assert_eq!(fmt_age(now, now - 59_000), "59s");
assert_eq!(fmt_age(now, now - 5 * 60_000), "5m");
assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h");
assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d");
// Clock skew (then in the future) never shows a negative age.
assert_eq!(fmt_age(now, now + 10_000), "0s");
}
#[test]
fn project_color_is_stable_distinct_and_neutral_when_absent() {
assert_eq!(project_color(None), Color::DarkGray);

View file

@ -61,14 +61,22 @@ fn run<B: heph_tui::Backend>(
mut app: App<B>,
socket: &std::path::Path,
) -> Result<()> {
// Poll with a timeout so the sync indicator's age advances and a sync
// failure surfaces within a couple of seconds even while the user is idle.
let tick = std::time::Duration::from_secs(2);
loop {
terminal.draw(|f| ui::render(f, &app))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
if event::poll(tick)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
}
}
}
} else {
// Idle tick: refresh the sync-health snapshot for the status line.
app.refresh_sync();
}
if app.should_quit {
return Ok(());

View file

@ -3,7 +3,7 @@
use heph_core::Attention;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
@ -14,8 +14,8 @@ use ratatui::{
};
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
use crate::backend::Backend;
use crate::fmt::{fmt_date, project_color, today_local};
use crate::backend::{Backend, SyncStatus};
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).
const HINTS: &str =
@ -37,7 +37,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(22),
Constraint::Length(28),
Constraint::Min(28),
Constraint::Length(38),
])
@ -151,8 +151,25 @@ fn pane_border(focused: bool) -> Style {
}
}
/// The (label, trailing-count) styles for a sidebar row given its selection
/// state: a full-width cyan bar when focus-selected, reversed when selected in
/// the unfocused pane, otherwise plain with a dimmed count.
fn sidebar_row_styles(selected: bool, focused: bool) -> (Style, Style) {
if selected {
let s = if focused {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().add_modifier(Modifier::REVERSED)
};
(s, s)
} else {
(Style::default(), Style::default().fg(Color::DarkGray))
}
}
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let focused = app.focus == Focus::Sidebar;
let width = area.width.saturating_sub(2) as usize; // inside borders
let items: Vec<ListItem> = app
.sidebar
.iter()
@ -166,17 +183,38 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))),
SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => {
let mut style = Style::default();
if selected {
style = if focused {
style.fg(Color::Black).bg(Color::Cyan)
} else {
style.add_modifier(Modifier::REVERSED)
};
}
SidebarEntry::View { title, .. } => {
let (style, _) = sidebar_row_styles(selected, focused);
ListItem::new(Line::from(Span::styled(format!(" {title}"), style)))
}
SidebarEntry::Project {
title,
depth,
count,
..
} => {
// Indent two columns per nesting level (one base level so a
// top-level project still clears the pane border).
let indent = " ".repeat(1 + *depth as usize);
// A right-aligned outstanding-task count (blank when zero).
let count_str = if *count > 0 {
format!(" {count}")
} else {
String::new()
};
let label_w = width.saturating_sub(count_str.chars().count());
let title_room = label_w.saturating_sub(indent.chars().count());
let title_trunc: String = title.chars().take(title_room).collect();
let mut label = format!("{indent}{title_trunc}");
let pad = label_w.saturating_sub(label.chars().count());
label.push_str(&" ".repeat(pad));
let (label_style, count_style) = sidebar_row_styles(selected, focused);
ListItem::new(Line::from(vec![
Span::styled(label, label_style),
Span::styled(count_str, count_style),
]))
}
}
})
.collect();
@ -187,7 +225,29 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
.border_style(pane_border(focused))
.title(" Views "),
);
frame.render_widget(list, area);
// Drive scroll-to-visible off the cursor so projects below the fold stay
// reachable; the row's own highlight remains the selection cue.
let mut state = ListState::default();
state.select(Some(app.sidebar_cursor));
frame.render_stateful_widget(list, area, &mut state);
// A scrollbar once the entries can't all fit at once (position tracks the
// cursor — an honest "where am I in the list" signal).
let inner_h = area.height.saturating_sub(2) as usize;
if app.sidebar.len() > inner_h {
let mut sb = ScrollbarState::new(app.sidebar.len()).position(app.sidebar_cursor);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(
bar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut sb,
);
}
}
/// A dimmed `──── Project ────` group header for the project sort mode, padded
@ -239,7 +299,7 @@ fn task_detail_lines<B: Backend>(
}
}
if let Some(rrule) = &t.recurrence {
field("recurs:", rrule.clone());
field("recurs:", hephd::datespec::humanize_rrule(rrule));
}
if let Some(d) = t.do_date {
field("do:", fmt_date(d, today));
@ -478,5 +538,130 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
} else {
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).
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
// A standalone daemon (no hub) shows no sync indicator — the `sync.status`
// RPC round-trips and reports `hub_url: null`.
assert!(
!s.contains('⟳'),
"sync indicator should be hidden without a hub:\n{s}"
);
}
#[test]
@ -206,7 +212,12 @@ fn recurring_task_shows_glyph_and_selected_detail_block() {
assert!(s.contains('↻'), "recurrence glyph missing:\n{s}");
// ...and the selected task's inline detail block (cursor starts on row 0).
assert!(s.contains("recurs:"), "no recurrence detail:\n{s}");
assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}");
// The RRULE is humanized for display (§8.1), not shown raw.
assert!(s.contains("daily"), "recurrence not humanized:\n{s}");
assert!(
!s.contains("FREQ=DAILY"),
"raw rrule leaked into detail:\n{s}"
);
assert!(s.contains("project:"), "no project detail:\n{s}");
assert!(s.contains("Routines"), "project name missing:\n{s}");
}

View file

@ -49,6 +49,13 @@ pub trait TokenVerifier: Send + Sync {
/// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its
/// claims, or an [`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).
@ -156,4 +163,9 @@ impl TokenVerifier for OidcVerifier {
.map_err(|e| AuthError::Invalid(e.to_string()))?;
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
}
// ---------------------------------------------------------------------------
// Reverse datespec: humanize an RRULE for display (§8.1).
// ---------------------------------------------------------------------------
/// Render an RFC-5545 RRULE back into the compact human phrasing the owner would
/// have typed — the inverse of [`parse_recurrence`] for the forms it produces:
/// `daily`, `every 3 days`, `every other day`, `weekly`, `every other week`,
/// `weekdays`, `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`,
/// `monthly on the 5th`, `yearly on Apr 15`. Any rule that uses parts we don't
/// model (`COUNT`, `UNTIL`, `BYSETPOS`, ordinal `BYDAY` like `2MO`, …) is
/// returned **verbatim** so nothing is silently hidden from the reader.
pub fn humanize_rrule(rrule: &str) -> String {
humanize_known(rrule).unwrap_or_else(|| rrule.trim().to_string())
}
/// The fallible core: `None` whenever the rule contains anything we don't model,
/// so [`humanize_rrule`] can fall back to the raw text.
fn humanize_known(rrule: &str) -> Option<String> {
let mut freq: Option<String> = None;
let mut interval: u32 = 1;
let mut byday: Option<String> = None;
let mut bymonth: Option<u32> = None;
let mut bymonthday: Option<i32> = None;
for part in rrule.trim().split(';') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (k, v) = part.split_once('=')?;
match k.trim().to_uppercase().as_str() {
"FREQ" => freq = Some(v.trim().to_uppercase()),
"INTERVAL" => interval = v.trim().parse().ok()?,
"BYDAY" => byday = Some(v.trim().to_uppercase()),
"BYMONTH" => bymonth = Some(v.trim().parse().ok()?),
"BYMONTHDAY" => bymonthday = Some(v.trim().parse().ok()?),
// A part we don't render → don't risk a misleading summary.
_ => return None,
}
}
match freq?.as_str() {
"DAILY" => {
if byday.is_some() || bymonth.is_some() || bymonthday.is_some() {
return None;
}
Some(every_unit(interval, "day", "days", "daily"))
}
"WEEKLY" => {
if bymonth.is_some() || bymonthday.is_some() {
return None;
}
match byday {
None => Some(every_unit(interval, "week", "weeks", "weekly")),
Some(days) => {
if interval == 1 && is_weekday_set(&days) {
return Some("weekdays".into());
}
let names = weekday_names(&days)?;
if names.len() == 1 {
let day = names[0];
Some(match interval {
1 => format!("every {day}"),
2 => format!("every other {day}"),
n => format!("every {n} weeks on {day}"),
})
} else {
let joined = names.join(", ");
Some(match interval {
1 => format!("weekly on {joined}"),
2 => format!("every other week on {joined}"),
n => format!("every {n} weeks on {joined}"),
})
}
}
}
}
"MONTHLY" => {
if byday.is_some() || bymonth.is_some() {
return None;
}
match bymonthday {
None => Some(every_unit(interval, "month", "months", "monthly")),
Some(d @ 1..=31) => {
let day = ordinal(d as u32);
Some(match interval {
1 => format!("monthly on the {day}"),
2 => format!("every other month on the {day}"),
n => format!("every {n} months on the {day}"),
})
}
Some(_) => None, // negative / out-of-range day-of-month → raw
}
}
"YEARLY" => {
if byday.is_some() {
return None;
}
match (bymonth, bymonthday) {
(None, None) => Some(every_unit(interval, "year", "years", "yearly")),
(Some(m @ 1..=12), Some(d @ 1..=31)) => {
let mon = MONTH_ABBR[(m - 1) as usize];
Some(match interval {
1 => format!("yearly on {mon} {d}"),
2 => format!("every other year on {mon} {d}"),
n => format!("every {n} years on {mon} {d}"),
})
}
_ => None,
}
}
_ => None,
}
}
const MONTH_ABBR: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/// `preset` for `n == 1`, `every other <singular>` for 2, `every N <plural>` otherwise.
fn every_unit(n: u32, singular: &str, plural: &str, preset: &str) -> String {
match n {
1 => preset.to_string(),
2 => format!("every other {singular}"),
n => format!("every {n} {plural}"),
}
}
/// `1st`, `2nd`, `3rd`, `4th`, … `11th`, `21st`, `22nd`.
fn ordinal(n: u32) -> String {
let suffix = match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
format!("{n}{suffix}")
}
/// `MO,TU,WE,TH,FR` in any order (and only those), the inverse of the `weekdays`
/// preset.
fn is_weekday_set(byday: &str) -> bool {
let mut days: Vec<&str> = byday.split(',').map(str::trim).collect();
days.sort_unstable();
days == ["FR", "MO", "TH", "TU", "WE"]
}
/// `BYDAY` tokens → capitalized weekday abbreviations, order preserved. `None` if
/// any token isn't a bare weekday (e.g. an ordinal `2MO`), so the caller falls
/// back to the raw rule.
fn weekday_names(byday: &str) -> Option<Vec<&'static str>> {
byday
.split(',')
.map(|t| match t.trim() {
"MO" => Some("Mon"),
"TU" => Some("Tue"),
"WE" => Some("Wed"),
"TH" => Some("Thu"),
"FR" => Some("Fri"),
"SA" => Some("Sat"),
"SU" => Some("Sun"),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -404,4 +570,71 @@ mod tests {
);
assert!(parse_recurrence("every blue moon").is_err());
}
#[test]
fn humanize_inverts_the_natural_language_forms() {
let cases = [
("FREQ=DAILY", "daily"),
("FREQ=DAILY;INTERVAL=2", "every other day"),
("FREQ=DAILY;INTERVAL=3", "every 3 days"),
("FREQ=WEEKLY", "weekly"),
("FREQ=WEEKLY;INTERVAL=2", "every other week"),
("FREQ=MONTHLY", "monthly"),
("FREQ=MONTHLY;INTERVAL=6", "every 6 months"),
("FREQ=YEARLY", "yearly"),
("FREQ=WEEKLY;BYDAY=FR", "every Fri"),
("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"),
("FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"),
("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"),
("FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"),
("FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"),
("FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"),
("FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"),
("FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"),
("FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"),
];
for (rrule, want) in cases {
assert_eq!(humanize_rrule(rrule), want, "humanizing {rrule}");
}
}
#[test]
fn humanize_round_trips_through_parse_recurrence() {
// For the interval/weekday forms the display text is itself a valid input:
// owner types text → we store an RRULE → we show it back → it re-parses to
// the same rule. (The `yearly on Apr 15` / `monthly on the 5th` forms are
// tuned for reading, not re-typing — the stored RRULE, never this string,
// is what gets parsed — so they're covered by the exact-output test above.)
for input in [
"every 3 days",
"every other day",
"every other wed",
"weekdays",
"every fri",
"every 6 months",
"every 2 weeks",
] {
let rrule = parse_recurrence(input).unwrap();
let shown = humanize_rrule(&rrule);
assert_eq!(
parse_recurrence(&shown).unwrap(),
rrule,
"{input:?} → {rrule:?} → shown {shown:?} must re-parse to the same rule"
);
}
}
#[test]
fn humanize_falls_back_to_raw_for_unmodeled_rules() {
// COUNT/UNTIL/BYSETPOS and ordinal BYDAY would be misleading if dropped.
for raw in [
"FREQ=DAILY;COUNT=5",
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
"FREQ=MONTHLY;BYDAY=2MO",
"FREQ=MONTHLY;BYMONTHDAY=-1",
"not an rrule at all",
] {
assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through");
}
}
}

View file

@ -221,6 +221,10 @@ impl Store for RemoteStore {
self.call_as("health", json!({}))
}
fn project_overview(&self) -> Result<Vec<heph_core::ProjectOverview>> {
self.call_as("project.overview", json!({}))
}
fn search(&self, query: &str) -> Result<Vec<Node>> {
self.call_as("search", json!({ "query": query }))
}

View file

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

View file

@ -10,9 +10,10 @@
//! ops with the configured hub (tech-spec §6.1, §12).
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -32,6 +33,23 @@ struct SpokeAuth {
client_id: String,
}
/// A spoke's observed sync health, updated after every exchange (background loop
/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether
/// sync is actually working instead of trusting silence (tech-spec §3.1 / the
/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet".
#[derive(Clone, Default, Serialize)]
struct SyncHealth {
/// When we last attempted an exchange.
last_attempt_ms: Option<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.
#[derive(Clone)]
struct Ctx {
@ -43,6 +61,41 @@ struct Ctx {
auth: Option<SpokeAuth>,
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
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 {
@ -87,6 +140,7 @@ impl Daemon {
.expect("building the daemon HTTP client"),
auth: None,
self_update: None,
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
},
}
}
@ -170,7 +224,10 @@ impl Daemon {
loop {
tick.tick().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"),
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;
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)),
Err(e) => Err(RpcError {
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> {
// Conflict count is meaningful even on a hub / standalone instance.
let store = ctx.store.clone();
let conflicts = tokio::task::spawn_blocking(move || {
let guard = store.lock().expect("store mutex poisoned");
guard.conflicts_list().map(|c| c.len())
})
.await
.map_err(|e| RpcError {
code: INTERNAL_ERROR,
message: format!("sync.status task failed: {e}"),
})?
.map_err(RpcError::from)?;
let Some(hub_url) = ctx.hub_url.clone() else {
return Ok(json!({ "hub_url": Value::Null }));
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
};
let store = ctx.store.clone();
let hub = hub_url.clone();
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}"),
})?
.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("/rpc", post(rpc_call))
.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.
.fallback(serve_static)
// Outermost: stamp CORS headers on every response and short-circuit the
@ -174,6 +178,20 @@ async fn cors(request: Request, next: Next) -> AxumResponse {
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
/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA
/// can own its own routing. Path traversal (`..`) is rejected.

View file

@ -12,10 +12,23 @@ use std::thread;
use std::time::Duration;
use heph_core::{FixedClock, LocalStore};
use hephd::auth::{AuthError, Claims, TokenVerifier};
use hephd::sync::{self, SharedStore};
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.
struct Resp {
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
/// for the test's duration.
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();
thread::spawn(move || {
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();
tx.send(listener.local_addr().unwrap()).unwrap();
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();
});
});
@ -161,3 +183,31 @@ fn no_web_root_yields_404_for_static_paths() {
// Even the 404 carries CORS headers (it passed through the layer).
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
- [[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).
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.
4. **Token:** the hub requires an OIDC bearer token, and the PWA does **not yet
implement the in-app device-code login** — paste a token into Settings →
Token for now. Obtain one via the device-code flow against the Authentik
client (the same flow the CLI uses; e.g. reuse the access token a logged-in
spoke cached, or run a one-off device-code grant). Tap **Test** to confirm.
4. **Sign in:** open **Settings → Login with Authentik**. The app reads the
hub's `GET /config` for the issuer + client id (zero-config) and runs an
Authorization-Code + PKCE redirect to Authentik; after you approve it lands
back on the app, signed in, and silently refreshes the token from then on.
(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
> Settings so login is in-app (open the verification URL, poll for the token,
> store it, and refresh it) — removing the manual paste. Tracked as follow-up
> work for `heph-pwa`.
> Re-prompted for login too often? The fix is the Authentik provider's
> **refresh token validity**, not the app — see the token-lifetime note in
> [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's
> storage after ~7 idle days; Add to Home Screen mitigates it.)
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a
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

View file

@ -51,6 +51,26 @@ need:
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
- **Client id** — the device-code client id (this is also the token *audience*).
### Token lifetime (avoid frequent re-logins)
Token lifetimes are set on the Authentik **provider**, not in heph — heph honors
whatever `expires_in` Authentik returns and silently refreshes using the
`offline_access` refresh token (both the CLI/daemon and the PWA do this). To
avoid re-authenticating often, set generous validities on the heph provider:
- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps
no revocation list, so this is the window in which a leaked token stays usable;
on a Tailscale-only hub, 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`
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
@ -98,10 +118,16 @@ and background-syncs on its interval.
## 4. Verify
```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 --status` also reports **sync health** — the time of the last
successful exchange, any last error, and whether the spoke is currently failing
to authenticate. The same signal is surfaced live in `heph-tui`'s status line
(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken
spoke is visible at a glance rather than buried in the daemon log.
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
## Current gaps (finalized by the blumeops deployment)

View file

@ -6,8 +6,9 @@
// rpc.js). Context/KB is read-only here (no nvim editing surface).
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
import * as oauth from "./oauth.js";
import { parse as quickParse } from "./quickadd.js";
import { today, parseDate, toEpochMs } from "./datespec.js";
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
import {
ATTENTION_COLORS,
fmtRelative,
@ -40,7 +41,29 @@ const state = {
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 --------------------------------------------------------
@ -189,7 +212,7 @@ function taskRow(t) {
function taskDetail(t) {
const meta = [];
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
if (t.recurrence) meta.push(["recurs", t.recurrence]);
if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]);
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
@ -350,7 +373,7 @@ function openQuickAdd() {
}
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + parsed.recurrence));
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence)));
};
input.addEventListener("input", updatePreview);
input.addEventListener("keydown", (e) => {
@ -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 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 setTest = (msg, ok) => {
test.textContent = msg;
test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : "");
};
const save = async () => {
state.settings.baseUrl = url.value.trim();
state.settings.token = tok.value.trim();
saveSettings(state.settings);
state.client = new Client(state.settings);
state.client = makeClient();
closeModal();
reload();
};
const check = async () => {
test.textContent = "Checking…";
setTest("Checking…", null);
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
try {
const v = await probe.call("version", {});
test.textContent = `✓ Connected (hephd ${v.version})`;
test.className = "settings-test ok";
setTest(`✓ Connected (hephd ${v.version})`, true);
} catch (e) {
test.textContent = `${e.message}`;
test.className = "settings-test bad";
setTest(`${e.message}`, false);
}
};
// 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(
h(
@ -721,8 +779,14 @@ function openSettings() {
h("div", { class: "modal-title" }, "Settings"),
h("label", { class: "settings-label" }, "Hub URL"),
url,
h("label", { class: "settings-label" }, "Token"),
tok,
h("label", { class: "settings-label" }, "Sign-in"),
authRow,
h(
"details",
{ class: "settings-manual" },
h("summary", {}, "Or paste a bearer token"),
tok,
),
test,
h(
"div",
@ -730,7 +794,7 @@ function openSettings() {
h("button", { class: "act", onclick: check }, "Test"),
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();
reload();

View file

@ -219,3 +219,143 @@ export function parseRecurrenceOrNull(spec) {
return null;
}
}
// ---------------------------------------------------------------------------
// Reverse: humanize an RRULE for display (§8.1) — a faithful port of hephd's
// `datespec::humanize_rrule`.
// ---------------------------------------------------------------------------
const MONTH_ABBR = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const DAY_ABBR = { MO: "Mon", TU: "Tue", WE: "Wed", TH: "Thu", FR: "Fri", SA: "Sat", SU: "Sun" };
function everyUnit(n, singular, plural, preset) {
if (n === 1) return preset;
if (n === 2) return `every other ${singular}`;
return `every ${n} ${plural}`;
}
function ordinal(n) {
const tens = n % 100;
if (tens >= 11 && tens <= 13) return `${n}th`;
switch (n % 10) {
case 1: return `${n}st`;
case 2: return `${n}nd`;
case 3: return `${n}rd`;
default: return `${n}th`;
}
}
function isWeekdaySet(byday) {
const days = byday.split(",").map((s) => s.trim()).sort();
return days.join(",") === "FR,MO,TH,TU,WE";
}
/** BYDAY tokens capitalized weekday abbreviations, order preserved, or null
* if any token isn't a bare weekday (e.g. an ordinal `2MO`). */
function weekdayNames(byday) {
const out = [];
for (const tok of byday.split(",")) {
const name = DAY_ABBR[tok.trim()];
if (!name) return null;
out.push(name);
}
return out;
}
/**
* Render an RFC-5545 RRULE back into the compact phrasing `parseRecurrence`
* accepts `daily`, `every 3 days`, `every other week`, `weekdays`,
* `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, `monthly on the
* 5th`, `yearly on Apr 15`. Any rule using parts we don't model (COUNT, UNTIL,
* ordinal BYDAY, ) is returned **verbatim** so nothing is silently hidden.
*/
export function humanizeRecurrence(rrule) {
const known = humanizeKnown(rrule);
return known === null ? rrule.trim() : known;
}
function humanizeKnown(rrule) {
let freq = null;
let interval = 1;
let byday = null;
let bymonth = null;
let bymonthday = null;
for (const rawPart of rrule.trim().split(";")) {
const part = rawPart.trim();
if (part === "") continue;
const eq = part.indexOf("=");
if (eq === -1) return null;
const k = part.slice(0, eq).trim().toUpperCase();
const v = part.slice(eq + 1).trim();
switch (k) {
case "FREQ": freq = v.toUpperCase(); break;
case "INTERVAL": {
const n = Number(v);
if (!Number.isInteger(n) || n < 1) return null;
interval = n;
break;
}
case "BYDAY": byday = v.toUpperCase(); break;
case "BYMONTH": {
const n = Number(v);
if (!Number.isInteger(n)) return null;
bymonth = n;
break;
}
case "BYMONTHDAY": {
const n = Number(v);
if (!Number.isInteger(n)) return null;
bymonthday = n;
break;
}
default: return null; // a part we don't render → don't risk a wrong summary
}
}
switch (freq) {
case "DAILY":
if (byday !== null || bymonth !== null || bymonthday !== null) return null;
return everyUnit(interval, "day", "days", "daily");
case "WEEKLY": {
if (bymonth !== null || bymonthday !== null) return null;
if (byday === null) return everyUnit(interval, "week", "weeks", "weekly");
if (interval === 1 && isWeekdaySet(byday)) return "weekdays";
const names = weekdayNames(byday);
if (names === null) return null;
if (names.length === 1) {
const day = names[0];
if (interval === 1) return `every ${day}`;
if (interval === 2) return `every other ${day}`;
return `every ${interval} weeks on ${day}`;
}
const joined = names.join(", ");
if (interval === 1) return `weekly on ${joined}`;
if (interval === 2) return `every other week on ${joined}`;
return `every ${interval} weeks on ${joined}`;
}
case "MONTHLY": {
if (byday !== null || bymonth !== null) return null;
if (bymonthday === null) return everyUnit(interval, "month", "months", "monthly");
if (bymonthday < 1 || bymonthday > 31) return null;
const day = ordinal(bymonthday);
if (interval === 1) return `monthly on the ${day}`;
if (interval === 2) return `every other month on the ${day}`;
return `every ${interval} months on the ${day}`;
}
case "YEARLY": {
if (byday !== null) return null;
if (bymonth === null && bymonthday === null) {
return everyUnit(interval, "year", "years", "yearly");
}
if (bymonth < 1 || bymonth > 12 || bymonthday < 1 || bymonthday > 31) return null;
const mon = MONTH_ABBR[bymonth - 1];
if (interval === 1) return `yearly on ${mon} ${bymonthday}`;
if (interval === 2) return `every other year on ${mon} ${bymonthday}`;
return `every ${interval} years on ${mon} ${bymonthday}`;
}
default: return null;
}
}

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;
}
/** Low-level call: returns the `result` value, or throws RpcError. */
async call(method, params = {}) {
/** Low-level call: returns the `result` value, or throws RpcError. On a 401,
* 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) {
throw new RpcError("No hub configured — open Settings and set the hub URL.", 0);
}
@ -68,7 +70,16 @@ export class Client {
} catch (e) {
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.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status);

View file

@ -435,6 +435,26 @@ body {
font-size: 12px;
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-pane {

View file

@ -1,7 +1,7 @@
// 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
// anyway). Bump CACHE when shell assets change to evict the old set.
const CACHE = "heph-pwa-v3";
const CACHE = "heph-pwa-v4";
const SHELL = [
"./",
"./index.html",
@ -9,6 +9,7 @@ const SHELL = [
"./manifest.webmanifest",
"./src/app.js",
"./src/rpc.js",
"./src/oauth.js",
"./src/quickadd.js",
"./src/datespec.js",
"./src/fmt.js",
@ -31,8 +32,10 @@ self.addEventListener("activate", (e) => {
self.addEventListener("fetch", (e) => {
const req = e.request;
// Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin)
// goes straight to the network.
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) {
// goes straight to the network. Skip URLs with a query string too, so the OAuth
// 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;
}
e.respondWith(

View file

@ -3,7 +3,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js";
import {
parseDate,
parseRecurrence,
humanizeRecurrence,
toEpochMs,
} from "../src/datespec.js";
import { parse } from "../src/quickadd.js";
const d = (y, m, day) => new Date(y, m - 1, day);
@ -58,6 +63,60 @@ test("recurrence natural language", () => {
assert.throws(() => parseRecurrence("every blue moon"));
});
// Parity with hephd's `humanize_rrule` unit tests (datespec.rs).
test("humanize inverts the natural-language forms", () => {
const cases = [
["FREQ=DAILY", "daily"],
["FREQ=DAILY;INTERVAL=2", "every other day"],
["FREQ=DAILY;INTERVAL=3", "every 3 days"],
["FREQ=WEEKLY", "weekly"],
["FREQ=WEEKLY;INTERVAL=2", "every other week"],
["FREQ=MONTHLY", "monthly"],
["FREQ=MONTHLY;INTERVAL=6", "every 6 months"],
["FREQ=YEARLY", "yearly"],
["FREQ=WEEKLY;BYDAY=FR", "every Fri"],
["FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"],
["FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"],
["FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"],
["FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"],
["FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"],
["FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"],
["FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"],
["FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"],
["FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"],
];
for (const [rrule, want] of cases) {
assert.equal(humanizeRecurrence(rrule), want, `humanizing ${rrule}`);
}
});
test("humanize round-trips the re-typeable forms through parseRecurrence", () => {
for (const input of [
"every 3 days",
"every other day",
"every other wed",
"weekdays",
"every fri",
"every 6 months",
"every 2 weeks",
]) {
const rrule = parseRecurrence(input);
assert.equal(parseRecurrence(humanizeRecurrence(rrule)), rrule, `round-trip ${input}`);
}
});
test("humanize falls back to raw for unmodeled rules", () => {
for (const raw of [
"FREQ=DAILY;COUNT=5",
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
"FREQ=MONTHLY;BYDAY=2MO",
"FREQ=MONTHLY;BYMONTHDAY=-1",
"not an rrule at all",
]) {
assert.equal(humanizeRecurrence(raw), raw, `passthrough ${raw}`);
}
});
// quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
const QTODAY = d(2026, 6, 3);
const PROJECTS = [