Compare commits

...

21 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
Forgejo Actions
5f3e3225ec Update changelog for v1.2.0 [skip ci] 2026-06-04 17:51:55 -07:00
052f624e6f Merge pull request 'heph-pwa: mobile app (PWA mirror of heph-tui) + hub static serving' (#8) from feature/heph-pwa-mobile into main
All checks were successful
Build / validate (push) Successful in 6m44s
Reviewed-on: #8
2026-06-04 17:50:47 -07:00
936c2635ef doc(heph-pwa): production runbook — host the app from the hub (indri) with OIDC
All checks were successful
Build / validate (pull_request) Successful in 6m18s
Add host-heph-pwa.md: a deployment how-to for serving the PWA from the canonical
hub in the hub/spoke OIDC setup (post-release) — fetch the shell at the hub's
tag, add --web-root, terminate TLS (tailscale serve / reverse proxy), and the
token-paste caveat with the device-code-login follow-up. Cross-linked from
heph-pwa and the how-to index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:17:25 -07:00
271c609c14 feat(heph-pwa): re-fetch the current view when the app regains focus
The PWA shares the daemon's store with the TUI/desktop popover but only
re-fetched on a view switch or action — so a task marked done elsewhere left a
stale list on screen. Reload the current view on visibilitychange→visible
(switch back to the phone, unlock, tab re-show), skipping it mid-modal/search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:16:15 -07:00
0036c1a284 fix(hephd): supervise the ⌘' popover in server mode too; PWA defaults hub to its origin
Popover supervision was gated to Mode::Local, so running the store-owning
daemon in server mode (now needed to host heph-pwa) silently dropped the
desktop quick-capture popover. Server mode is local + an HTTP hub and owns the
same store/socket, so it should drive the popover too; broaden the guard to
Local | Server (client, a thin proxy, still opts out).

Also: when the PWA shell is served from the hub, default the hub URL to its own
origin so the app is zero-config on first open (Settings still overrides). Bump
the service-worker cache to v2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:13:28 -07:00
b24a148add doc(heph-pwa): how-to card, index entry, changelog fragment
Document serving the app from the hub (--web-root), connecting (hub URL +
optional token), quick-add syntax, voice, triage, and the deliberate
design choices (PWA over native iOS; online-only; token paste vs device flow)
with their known limitations to revisit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:59:38 -07:00
4baa8e1c9d feat(heph-pwa): mobile app shell — views, quick-add, triage, search, voice
A buildless, installable PWA that mirrors heph-tui: sidebar of built-in views
(tom/tasks/work/chores/ondeck/inbox) + projects, a task list with attention
flags / project bullets / date chips, tap-to-expand triage (done/drop/skip/
attention/reschedule/move/delete + undo), full-text search, and a read-only
context+log preview. The primary surface is the quick-add modal (FAB or Cmd-'),
which live-parses the TUI syntax into preview chips and supports voice via
on-device dictation / the Web Speech API. rpc.js is the online-only JSON-RPC
client mirroring heph-tui's Backend; settings persist in localStorage. Service
worker caches the app shell for offline launch.

Verified end-to-end against a local server-mode hephd (--web-root): the app
boots, calls the view RPC, and renders RankedTasks in headless Chrome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:59:37 -07:00
c3111d498b feat(heph-pwa): port quickadd + datespec parsers to JS (with parity tests)
Faithful JS ports of hephd's quickadd.rs / datespec.rs so the PWA's quick-add
accepts the identical syntax (p1-4, #Project greedy match, today/+3d/fri/ISO,
'every …' recurrence) and produces the same RRULEs and local-midnight do-dates
as the CLI/TUI. test/parsers.test.mjs replays the Rust unit cases under
`node --test` (13/13 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:42:09 -07:00
ca8f7d1ab2 feat(hephd): CORS + optional static serving on the hub HTTP endpoint
Add a permissive CORS middleware (answers the browser OPTIONS preflight and
stamps Access-Control-* on every response) and an optional --web-root static
file handler with an index.html SPA fallback. Together these let a browser
surface — the forthcoming heph-pwa mobile app — call /rpc cross-origin or be
hosted same-origin straight from the hub. No new crate dependencies; file
reads run on the blocking pool. Covered by tests/web_serve.rs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:39:20 -07:00
44 changed files with 4455 additions and 60 deletions

View file

@ -12,6 +12,42 @@ 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
- New **heph-pwa** mobile app: an installable, phone-first PWA that mirrors heph-tui — browse the built-in views and projects, triage tasks, and capture new tasks fast with the same quick-add syntax (`p1-4`, `#Project`, `today/+3d/fri`, `every …`) and live preview. Voice capture via on-device dictation. The hub (`hephd --mode server`) gains CORS and an optional `--web-root` so it can serve the app same-origin straight from the daemon.
## [v1.1.1] - 2026-06-04
### Bug Fixes

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

@ -60,6 +60,12 @@ struct Cli {
#[arg(long)]
http_addr: Option<String>,
/// Directory of static files to serve for non-API paths (server mode). Point
/// this at the `heph-pwa/` shell to host the mobile app same-origin from the
/// hub. Unset: the hub serves only its API routes.
#[arg(long)]
web_root: Option<PathBuf>,
/// Server to proxy to (client mode only; required there).
#[arg(long)]
server_url: Option<String>,
@ -190,7 +196,10 @@ async fn main() -> Result<()> {
anyhow::bail!("--oidc-issuer and --oidc-audience must be set together")
}
};
let app = sync::router(daemon.store(), verifier);
if let Some(root) = cli.web_root.as_deref() {
tracing::info!(web_root = %root.display(), "hub serving static PWA shell");
}
let app = sync::router_with_web(daemon.store(), verifier, cli.web_root.clone());
let http_listener = TcpListener::bind(&addr)
.await
.with_context(|| format!("binding hub HTTP endpoint {addr}"))?;
@ -222,14 +231,17 @@ async fn main() -> Result<()> {
tracing::info!(socket = %socket.display(), mode = ?cli.mode, "hephd listening");
// macOS local mode: supervise the global quick-capture popover (⌘'). hephd
// already runs as a `gui/$uid` LaunchAgent, so its child inherits the Aqua
// session the hotkey/GUI need — no separate launch agent. Opt-in via
// HEPH_QUICKADD=1 (the installed plist sets it) so dev/test runs that spawn a
// local daemon never pop a window. The helper self-exits when this daemon
// goes away, so killing hephd (even `kill -9`) leaves nothing behind.
// macOS store-owning modes: supervise the global quick-capture popover (⌘').
// hephd already runs as a `gui/$uid` LaunchAgent, so its child inherits the
// Aqua session the hotkey/GUI need — no separate launch agent. Both `local`
// and `server` own the local store on the device (server is local + an HTTP
// hub), so both should drive the desktop popover; only `client` (a thin
// remote proxy) does not. Opt-in via HEPH_QUICKADD=1 (the installed plist
// sets it) so dev/test runs that spawn a daemon never pop a window. The
// helper self-exits when this daemon goes away, so killing hephd (even
// `kill -9`) leaves nothing behind.
#[cfg(target_os = "macos")]
if cli.mode == Mode::Local && quickadd_enabled() {
if matches!(cli.mode, Mode::Local | Mode::Server) && quickadd_enabled() {
spawn_quickadd_supervisor(socket.clone());
}

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

@ -10,6 +10,12 @@
//! - `POST /rpc` — the full daemon API ([`crate::rpc::dispatch`]) over HTTP, for
//! a no-replica `client`-mode [`crate::remote::RemoteStore`] to proxy against.
//!
//! All routes carry permissive CORS headers and answer the browser preflight
//! (`OPTIONS`), so a browser surface (the `heph-pwa` mobile app) can call `/rpc`
//! cross-origin. When the hub is given a `web_root`, unmatched paths fall back to
//! serving that directory's static files (the PWA shell), so the app can be
//! hosted same-origin straight from the hub.
//!
//! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]):
//! each side transfers only the tail it hasn't sent/seen. Merge is idempotent,
//! so a re-pushed op the hub already has is a harmless no-op. When the hub is
@ -17,13 +23,14 @@
//! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach
//! that token via the `bearer` argument to [`sync_once`].
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use axum::extract::{Query, Request, State};
use axum::http::StatusCode;
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
use axum::middleware::{self, Next};
use axum::response::Response as AxumResponse;
use axum::response::{IntoResponse, Response as AxumResponse};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
@ -44,6 +51,9 @@ pub type SharedStore = Arc<Mutex<dyn Store + Send>>;
struct HubState {
store: SharedStore,
verifier: Option<Arc<dyn TokenVerifier>>,
/// When set, unmatched paths serve static files from this directory (the
/// `heph-pwa` shell), so the app can be hosted same-origin from the hub.
web_root: Option<PathBuf>,
}
/// A batch of ops in flight (push body / pull response).
@ -102,15 +112,134 @@ fn apply_batch(
/// `verifier` is `Some`, every route requires a valid OIDC bearer token whose
/// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev).
pub fn router(store: SharedStore, verifier: Option<Arc<dyn TokenVerifier>>) -> Router {
let state = HubState { store, verifier };
router_with_web(store, verifier, None)
}
/// [`router`] plus an optional `web_root`: when `Some(dir)`, paths that don't
/// match an API route serve static files from `dir` (the `heph-pwa` shell),
/// with a `index.html` fallback so the single-page app can deep-link. Static
/// files are served without authentication — they are only the app shell; all
/// data still flows through the auth-gated `/rpc` and `/sync/*` routes.
pub fn router_with_web(
store: SharedStore,
verifier: Option<Arc<dyn TokenVerifier>>,
web_root: Option<PathBuf>,
) -> Router {
let state = HubState {
store,
verifier,
web_root,
};
Router::new()
.route("/sync/pull", get(pull))
.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
// browser's `OPTIONS` preflight (before it reaches auth or routing).
.layer(middleware::from_fn(cors))
.with_state(state)
}
/// Permissive-CORS middleware. Answers the browser preflight (`OPTIONS`) with a
/// 204 and stamps `Access-Control-*` headers on every response. The hub is a
/// personal endpoint guarded by bearer tokens (not cookies), so a wildcard
/// origin is safe — there are no ambient credentials for `*` to expose.
async fn cors(request: Request, next: Next) -> AxumResponse {
let is_preflight = request.method() == Method::OPTIONS;
let mut response = if is_preflight {
StatusCode::NO_CONTENT.into_response()
} else {
next.run(request).await
};
let h = response.headers_mut();
h.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
h.insert(
header::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET, POST, OPTIONS"),
);
h.insert(
header::ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static("authorization, content-type"),
);
h.insert(
header::ACCESS_CONTROL_MAX_AGE,
HeaderValue::from_static("86400"),
);
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.
async fn serve_static(State(state): State<HubState>, uri: Uri) -> AxumResponse {
let Some(root) = state.web_root.as_ref() else {
return StatusCode::NOT_FOUND.into_response();
};
let rel = uri.path().trim_start_matches('/');
if rel.split('/').any(|seg| seg == "..") {
return StatusCode::BAD_REQUEST.into_response();
}
let rel = if rel.is_empty() { "index.html" } else { rel };
let direct = root.join(rel);
let index = root.join("index.html");
// File reads run on the blocking pool (tokio's `fs` feature is off, and DB /
// disk I/O never runs on an async worker, tech-spec §3).
let read = tokio::task::spawn_blocking(move || {
match std::fs::read(&direct) {
Ok(bytes) => Some((content_type(&direct), bytes)),
// SPA fallback: serve index.html for unknown (extension-less) routes.
Err(_) => std::fs::read(&index)
.ok()
.map(|bytes| ("text/html; charset=utf-8", bytes)),
}
})
.await;
match read {
Ok(Some((ctype, bytes))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
/// Best-effort content type from a file extension (the handful the PWA serves).
fn content_type(path: &std::path::Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json" | "webmanifest") => "application/json; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
}
}
/// Reject any request lacking a valid bearer token whose `sub` owns this hub.
/// A no-op when the hub has no verifier configured (open dev mode).
async fn require_auth(

View file

@ -0,0 +1,213 @@
//! The hub's browser-facing surface (for the `heph-pwa` mobile app): permissive
//! CORS on every response, an `OPTIONS` preflight answer, and—when a `web_root`
//! is configured—static serving of the app shell with an `index.html` SPA
//! fallback. A tiny raw-HTTP client keeps this dependency-free and lets us drive
//! arbitrary methods (`OPTIONS`) and inspect response headers directly.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
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,
headers: Vec<(String, String)>,
body: String,
}
impl Resp {
fn header(&self, name: &str) -> Option<&str> {
let name = name.to_ascii_lowercase();
self.headers
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.as_str())
}
}
/// Issue one HTTP/1.1 request over a fresh connection (`Connection: close`, so
/// we can read the whole response to EOF) and parse the response.
fn request(addr: &str, method: &str, path: &str) -> Resp {
let mut stream = TcpStream::connect(addr).unwrap();
let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
stream.write_all(req.as_bytes()).unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
let mut lines = head.split("\r\n");
let status = lines
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse().ok())
.unwrap();
let headers = lines
.filter_map(|l| l.split_once(": "))
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
.collect();
Resp {
status,
headers,
body: body.to_string(),
}
}
/// Start the hub router (with the given `web_root`) over a temp `LocalStore` on
/// 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()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let dir = tempfile::tempdir().unwrap();
let store =
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
let shared: SharedStore = Arc::new(Mutex::new(store));
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, verifier, web_root);
axum::serve(listener, app).await.unwrap();
});
});
rx.recv_timeout(Duration::from_secs(5)).unwrap().to_string()
}
#[test]
fn cors_headers_on_rpc_and_preflight_answered() {
let addr = start(None);
// The browser preflight gets a 204 with the CORS allowances, without auth.
let pre = request(&addr, "OPTIONS", "/rpc");
assert_eq!(pre.status, 204);
assert_eq!(pre.header("access-control-allow-origin"), Some("*"));
assert!(pre
.header("access-control-allow-headers")
.unwrap()
.contains("authorization"));
assert!(pre
.header("access-control-allow-methods")
.unwrap()
.contains("POST"));
// A regular GET also carries the origin header (so XHR can read the body).
let get = request(&addr, "GET", "/sync/pull");
assert_eq!(get.header("access-control-allow-origin"), Some("*"));
}
#[test]
fn serves_static_shell_with_index_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("index.html"),
"<!doctype html><title>heph</title>",
)
.unwrap();
std::fs::write(dir.path().join("app.js"), "export const x = 1;\n").unwrap();
let addr = start(Some(dir.path().to_path_buf()));
// Root serves index.html as HTML.
let root = request(&addr, "GET", "/");
assert_eq!(root.status, 200);
assert!(root.body.contains("<title>heph</title>"));
assert_eq!(
root.header("content-type"),
Some("text/html; charset=utf-8")
);
// A real asset is served with a JS content type.
let js = request(&addr, "GET", "/app.js");
assert_eq!(js.status, 200);
assert!(js.body.contains("export const x"));
assert_eq!(
js.header("content-type"),
Some("text/javascript; charset=utf-8")
);
// An unknown (extension-less) route falls back to index.html for the SPA.
let deep = request(&addr, "GET", "/inbox");
assert_eq!(deep.status, 200);
assert!(deep.body.contains("<title>heph</title>"));
// Path traversal never escapes web_root (whether the client/proxy normalizes
// the `..` away or our guard rejects it, the crate's Cargo.toml never leaks).
let escape = request(&addr, "GET", "/../../Cargo.toml");
assert!(
!escape.body.contains("[package]"),
"must not serve files outside web_root"
);
// The temp dir must outlive the server thread's reads.
drop(dir);
}
#[test]
fn no_web_root_yields_404_for_static_paths() {
let addr = start(None);
let resp = request(&addr, "GET", "/inbox");
assert_eq!(resp.status, 404);
// 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.

119
docs/how-to/heph-pwa.md Normal file
View file

@ -0,0 +1,119 @@
---
title: heph-pwa (mobile app)
modified: 2026-06-04
tags:
- how-to
---
# heph-pwa — the mobile app
`heph-pwa` is a phone-first, installable web app that mirrors [[v1-prototype-tech-spec|heph-tui]]:
browse the built-in views and projects, triage tasks, and — the primary use
case — **capture tasks fast** with the same quick-add syntax as the TUI's `a` /
Cmd-' popover. Context/KB is **read-only** here (no Neovim editing surface).
It is a thin, online-only client: every read and write is a JSON-RPC call to a
**server-mode `hephd`** (the sync hub, see [[set-up-sync-hub]]). There is no
local replica or background sync — when the hub is unreachable, the app shows an
error rather than queueing offline.
> **Why a PWA and not native iOS?** A native Swift app cannot be signed, built,
> or installed without an Apple Developer account. A PWA delivers the primary
> use case today — installable to the home screen, full-screen, with home-screen
> launch and offline app-shell — and keeps the door open to a native wrapper
> later. This was a deliberate first-cut choice; revisit if a native app becomes
> worthwhile.
## Serve it from the hub
The hub can serve the app shell same-origin (no CORS or separate static host
needed). Point `hephd` at the `heph-pwa/` directory:
```bash
hephd --mode server \
--http-addr 0.0.0.0:8787 \
--web-root /path/to/hephaestus/heph-pwa \
--oidc-issuer https://auth.example.com/... \
--oidc-audience heph-mobile
```
- `--web-root` is optional. Unset, the hub serves only its API routes (unchanged
behavior). Set, it serves the static shell for any non-API path, with an
`index.html` SPA fallback. The shell is unauthenticated (it's just HTML/JS);
all data still flows through the auth-gated `/rpc`.
- Every hub response now carries permissive CORS headers and answers the browser
`OPTIONS` preflight, so you can alternatively host the shell anywhere (any
static server, GitHub Pages, etc.) and still call the hub cross-origin.
Then open `https://<hub-host>:8787/` on your phone and **Add to Home Screen**.
## Connect
On first launch the app opens **Settings**:
- **Hub URL** — the server-mode `hephd` base URL (e.g. `https://hub.example.com:8787`).
When served from the hub, use that same origin.
- **Token** — a bearer token, if the hub requires OIDC (`--oidc-issuer`/`-audience`).
Leave blank for an unauthenticated hub (local network / dev). Tap **Test** to
verify the connection (it calls the `version` RPC).
Settings persist in the browser's local storage.
> The device-code OIDC login flow (RFC 8628) the CLI/daemon use is **not** yet
> wired into the PWA — for now paste a bearer token obtained out-of-band. Wiring
> the in-app device flow is the obvious next step.
## Quick-add
Tap **+** (or press Cmd-' / Ctrl-' on a keyboard) to capture. The single input
accepts the exact [[v1-prototype-tech-spec|tech-spec §8.1]] syntax, parsed live
into preview chips before you submit:
| Token | Example | Effect |
|-------|---------|--------|
| `p1``p4` | `p1` | attention: red / orange / blue / white |
| `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) |
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) |
Unmatched `#tags` stay in the title verbatim. With no `#Project` token, the task
files into the currently selected project (or Inbox). The parser is a faithful
JS port of the Rust `quickadd`/`datespec` modules, covered by parity tests
(`heph-pwa/test/parsers.test.mjs`, run with `node --test`).
## Voice
The quick-add field supports voice two ways:
- **iOS / iPadOS:** use the **microphone key on the on-screen keyboard** — Apple
dictation works in the text field for free, no app permission needed.
- **Chrome / Android / desktop:** a 🎤 button appears when the Web Speech API is
available and dictates straight into the field.
(Anthropic has no speech-to-text endpoint, so transcription leans on the
platform. A server-side transcription proxy could be added later if needed.)
## Triage
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`),
**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete**
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
task's canonical-context body + recent log tail (read-only).
Search (🔍 or `/`) runs full-text search across tasks and docs.
## Known limitations (first cut)
- Online-only; no offline write queue or CRDT replica.
- No in-app OIDC device-code login yet (paste a token).
- Context/KB is read-only (no wiki-link navigation or editing).
- Undo covers Done/Drop only.
## Related
- [[host-heph-pwa]] — serve this app from the hub (indri) with OIDC, in the hub/spoke deployment
- [[set-up-sync-hub]] — stand up the server-mode hub the app talks to
- [[run-the-daemon]] — run `hephd` as a managed service
- [[v1-prototype-tech-spec]] — data model, RPC API, quick-add spec
- [[design]] — vision and rationale

View file

@ -0,0 +1,135 @@
---
title: Host heph-pwa from the hub
modified: 2026-06-04
tags:
- how-to
---
# Host heph-pwa from the hub
How to serve the [[heph-pwa]] mobile app from the canonical **hub** (`indri`) in
the hub-and-spoke deployment, with OIDC auth — the production counterpart of the
unauthenticated single-machine demo. Assumes the `heph-pwa` work is **merged and
released**, so the installed `hephd` already has `--web-root` and CORS.
> Read [[set-up-sync-hub]] first — this builds directly on the hub it stands up
> (server mode, Authentik OIDC, Tailscale transport).
## What the app needs from the hub
The PWA is a thin, online-only client: it loads its static shell over HTTP and
makes JSON-RPC calls to the hub's `/rpc`. So the hub must (1) serve the shell
files and (2) accept the app's authenticated RPC calls. Both are already in
`hephd --mode server`:
- `--web-root <dir>` serves the shell for any non-API path (with an `index.html`
SPA fallback). The shell is unauthenticated — it is only HTML/JS; all data
still flows through the OIDC-gated `/rpc`.
- Every response carries permissive CORS headers and answers the `OPTIONS`
preflight, so the shell may instead be hosted anywhere and still call the hub
cross-origin.
## 1. Put the shell on the hub
The release does not yet bundle the app, so fetch the `heph-pwa/` directory at
the **same version tag** the hub runs (keeping shell and hub in lockstep matters
— see *Upgrades* below), and copy it to a stable path:
```bash
# on indri, matching the running hephd version (e.g. v1.4.0)
git clone --depth 1 --branch v1.4.0 \
https://forge.ops.eblu.me/eblume/hephaestus.git /tmp/heph-src
sudo mkdir -p /var/lib/heph/web
sudo cp -r /tmp/heph-src/heph-pwa/. /var/lib/heph/web/
```
> **Future improvement:** have the release workflow package a `heph-pwa-<version>.tar.gz`
> asset (as it already does for docs), so this step becomes "download + extract"
> and the lockstep is automatic. Until then, pin the clone to the hub's tag.
## 2. Add `--web-root` to the hub service
Extend the hub invocation from [[set-up-sync-hub]] with `--web-root` (everything
else — issuer, audience, db — unchanged):
```bash
hephd --mode server \
--http-addr 0.0.0.0:8787 \
--db /var/lib/heph/heph.db \
--web-root /var/lib/heph/web \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-audience <heph-client-id>
```
In the systemd unit (or launchd plist), add the two `--web-root` arguments and
`systemctl restart hephd`. Self-update is compatible now that the release ships
the flag — just refresh the web-root on each upgrade (next section).
## 3. Terminate TLS (recommended)
Serve the app over **HTTPS** so it is a *secure context*: only then do the
service worker (offline launch), proper PWA install, and the Web Speech mic
work. (On iOS, "Add to Home Screen" and keyboard dictation work over plain HTTP
too, so HTTPS is a polish step, not a blocker.) Two good options:
- **Tailscale serve** — tailnet-only, automatic MagicDNS cert, no public
exposure:
```bash
tailscale serve --bg --https=443 http://127.0.0.1:8787
# app is then at https://indri.<tailnet>.ts.net/
```
Bind `hephd` to `127.0.0.1:8787` in this case and let Tailscale be the only
thing exposing it.
- **Reverse proxy** (Caddy / nginx) terminating a real cert, if the hub should
be reachable beyond the tailnet. Proxy all paths (`/`, `/rpc`, `/sync/*`) to
`hephd`.
Either way the app is same-origin with the hub, so no CORS is involved and the
app defaults its hub URL to its own origin.
## 4. Connect a phone
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. **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.)
> 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
On each hub upgrade, refresh the shell so it matches the running `hephd`:
```bash
git -C /tmp/heph-src fetch --depth 1 origin v1.5.0 && git -C /tmp/heph-src checkout v1.5.0
sudo cp -r /tmp/heph-src/heph-pwa/. /var/lib/heph/web/
```
The service worker is versioned (`CACHE = "heph-pwa-vN"`), so an updated shell
evicts the old cache on next load. Hard-refresh once if a phone seems stuck on a
stale version.
## Related
- [[heph-pwa]] — the app itself (features, quick-add, voice, triage)
- [[set-up-sync-hub]] — stand up the hub + Authentik OIDC this doc extends
- [[run-the-daemon]] — run `hephd` as a managed service
- [[v1-prototype-tech-spec]] — RPC API and auth model

View file

@ -21,3 +21,5 @@ Task-oriented guides for common operations.
- [[set-up-sync-hub]] — Stand up the canonical hub (indri) and connect an existing device as an offline-capable spoke
- [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`)
- [[self-update]] — Opt-in `hephd` self-update: poll the forge for new releases and auto-update
- [[heph-pwa]] — The mobile app: an installable PWA mirror of heph-tui (browse, triage, fast quick-add, voice)
- [[host-heph-pwa]] — Serve the mobile app from the hub (indri) with OIDC, in the hub/spoke deployment

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)

63
heph-pwa/README.md Normal file
View file

@ -0,0 +1,63 @@
# heph-pwa
A phone-first, installable **Progressive Web App** that mirrors `heph-tui`:
browse the built-in views and projects, triage tasks, and — the primary use
case — capture tasks fast with the same quick-add syntax as the TUI's `a` /
Cmd-' popover. Context/KB is read-only here.
Full guide: [`docs/how-to/heph-pwa.md`](../docs/how-to/heph-pwa.md).
## What it is
- **Thin, online-only client.** Every read/write is a JSON-RPC call to a
server-mode `hephd` (the sync hub). No local replica, no offline write queue.
- **Buildless.** Plain ES modules, no bundler, no `npm install`. Serve the
directory and go.
- **Same parser as the TUI.** `src/quickadd.js` + `src/datespec.js` are faithful
ports of the Rust `hephd::quickadd` / `hephd::datespec` modules, verified by
parity tests against the original Rust unit cases.
## Layout
```
index.html # app shell
styles.css # dark, terminal-flavored, touch-tuned
manifest.webmanifest # PWA manifest (installable)
sw.js # service worker — caches the app shell for offline launch
icons/ # app icons (svg + rasterized png, incl. maskable)
src/
app.js # UI controller: views, list, quick-add, triage, search, voice
rpc.js # hephd JSON-RPC-over-HTTP client + settings (localStorage)
quickadd.js # quick-add parser (port of quickadd.rs)
datespec.js # date + recurrence parser (port of datespec.rs)
fmt.js # display helpers (date chips, attention colors, bullets)
test/
parsers.test.mjs # parity tests for the parser ports
```
## Run it
Serve from the hub (recommended — same-origin, no CORS):
```bash
hephd --mode server --http-addr 0.0.0.0:8787 --web-root /path/to/heph-pwa
# then open http://<host>:8787/ on your phone and Add to Home Screen
```
Or from any static server (the hub now sends CORS headers, so cross-origin
`/rpc` calls work); set the hub URL in the app's Settings screen.
## Test
```bash
node --test heph-pwa/test/parsers.test.mjs
```
## Status / next steps
First cut (C1). Known gaps, roughly in priority order:
- In-app OIDC device-code login (today: paste a bearer token in Settings).
- Offline write queue / CRDT replica (today: online-only).
- Read-only context could grow wiki-link navigation.
- A native Swift wrapper, if/when an Apple Developer account is in play.

BIN
heph-pwa/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
heph-pwa/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
heph-pwa/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

14
heph-pwa/icons/icon.svg Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#15181d"/>
<!-- anvil: the forge of Hephaestus -->
<g fill="#6db3f2">
<!-- horn + body -->
<path d="M120 214 h300 a16 16 0 0 1 16 16 v8 a40 40 0 0 1 -40 40 h-70
l-14 40 h-92 l-14 -40 h-44 a48 48 0 0 1 -48 -48 v-8
a8 8 0 0 1 8 -8 z"/>
<!-- waist -->
<rect x="206" y="338" width="100" height="34" rx="6"/>
<!-- base -->
<rect x="150" y="372" width="212" height="40" rx="12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 578 B

24
heph-pwa/index.html Normal file
View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
/>
<meta name="theme-color" content="#15181d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="heph" />
<title>heph</title>
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-180.png" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app"></div>
<noscript>heph needs JavaScript enabled.</noscript>
<script type="module" src="./src/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
{
"name": "heph",
"short_name": "heph",
"description": "Capture and triage hephaestus tasks from your phone.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#15181d",
"theme_color": "#15181d",
"icons": [
{ "src": "./icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

895
heph-pwa/src/app.js Normal file
View file

@ -0,0 +1,895 @@
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
// views and projects, triage tasks, and (the primary use case) capture new
// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover.
//
// Online-only thin client: every action is an RPC to the configured hub (see
// 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, humanizeRecurrence } from "./datespec.js";
import {
ATTENTION_COLORS,
fmtRelative,
hasFlag,
isOverdue,
nextAttention,
projectColor,
} from "./fmt.js";
// The built-in views, in the TUI sidebar order (filter.rs BUILTIN_VIEWS).
const VIEWS = [
{ id: "tom", title: "Top of Mind" },
{ id: "tasks", title: "Tasks" },
{ id: "work", title: "Work Tasks" },
{ id: "chores", title: "Chores" },
{ id: "ondeck", title: "On Deck" },
{ id: "inbox", title: "Inbox" },
];
const state = {
settings: loadSettings(),
client: null,
target: { type: "view", id: "tom", title: "Top of Mind" },
tasks: [],
projects: [],
expandedId: null,
loading: false,
error: null,
search: null, // null, or { query, results }
lastUndo: null, // { label, run }
};
// 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 --------------------------------------------------------
/** h("div", {class:"x", onclick:fn}, child, child...) → HTMLElement. */
function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "html") el.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else el.setAttribute(k, v === true ? "" : String(v));
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
el.append(c.nodeType ? c : document.createTextNode(String(c)));
}
return el;
}
const $ = (sel) => document.querySelector(sel);
function toast(message, action) {
const root = $("#toast");
root.innerHTML = "";
const node = h(
"div",
{ class: "toast-body" },
h("span", {}, message),
action &&
h(
"button",
{
class: "toast-action",
onclick: () => {
root.innerHTML = "";
action.run();
},
},
action.label,
),
);
root.append(node);
if (!action) setTimeout(() => (root.innerHTML === "" ? null : (root.innerHTML = "")), 2600);
}
// --- data -------------------------------------------------------------------
async function reload() {
if (!state.client.configured) {
state.error = "Set your hub URL in Settings to begin.";
render();
openSettings();
return;
}
state.loading = true;
state.error = null;
render();
try {
const [tasks, projects] = await Promise.all([
state.target.type === "view"
? state.client.view(state.target.id)
: state.client.list({ scope: [state.target.id] }),
state.client.projects(),
]);
state.tasks = tasks;
state.projects = projects;
state.error = null;
} catch (e) {
state.error = e instanceof RpcError ? e.message : String(e);
} finally {
state.loading = false;
render();
}
}
function projectTitle(id) {
if (!id) return null;
return state.projects.find((p) => p.id === id)?.title || id;
}
async function refreshProjects() {
try {
state.projects = await state.client.projects();
} catch {
/* keep stale list */
}
}
// --- rendering --------------------------------------------------------------
function render() {
renderHeader();
renderMain();
}
function renderHeader() {
$("#view-title").textContent = state.search ? "Search" : state.target.title;
}
function attentionDot(att) {
return h("span", {
class: "flag",
style: hasFlag(att) ? `color:${ATTENTION_COLORS[att]}` : "color:transparent",
}, hasFlag(att) ? "⚑" : "·");
}
function dateChip(t) {
const now = Date.now();
if (isOverdue(t.late_on, now)) {
return h("span", { class: "chip overdue" }, `late ${fmtRelative(t.late_on, now)}`);
}
if (t.do_date != null) {
return h("span", { class: "chip" }, fmtRelative(t.do_date, now));
}
return null;
}
function taskRow(t) {
const expanded = state.expandedId === t.node_id;
const row = h(
"div",
{ class: "row" + (expanded ? " expanded" : "") },
h(
"div",
{
class: "row-head",
onclick: () => {
state.expandedId = expanded ? null : t.node_id;
render();
if (!expanded) loadPreview(t);
},
},
attentionDot(t.attention),
h("span", { class: "bullet", style: `color:${projectColor(t.project_id)}` }, "●"),
h("span", { class: "title" }, t.title),
t.recurrence && h("span", { class: "recur" }, "↻"),
dateChip(t),
),
expanded && taskDetail(t),
);
return row;
}
function taskDetail(t) {
const meta = [];
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]);
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
return h(
"div",
{ class: "detail" },
meta.length &&
h(
"div",
{ class: "meta" },
meta.map(([k, v]) => h("div", { class: "meta-row" }, h("span", { class: "meta-k" }, k), h("span", {}, v))),
),
h(
"div",
{ class: "actions" },
actionBtn("✓ Done", () => triage(t, "done")),
actionBtn("⤓ Drop", () => triage(t, "dropped")),
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
actionBtn("⚑ Attn", () => cycleAttention(t)),
actionBtn("📅 Date", () => openReschedule(t)),
actionBtn("📁 Move", () => openMove(t)),
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
),
h("pre", { class: "preview", id: `preview-${t.node_id}` }, "…"),
);
}
function actionBtn(label, onclick, extra = "") {
return h("button", { class: `act ${extra}`, onclick }, label);
}
async function loadPreview(t) {
const pre = $(`#preview-${t.node_id}`);
if (!pre) return;
try {
const ctxId = t.canonical_context_id || (await state.client.contextOf(t.node_id));
const [body, log] = await Promise.all([
ctxId ? state.client.nodeBody(ctxId) : Promise.resolve(""),
state.client.logTail(t.node_id, 5).catch(() => []),
]);
const parts = [];
if (body.trim()) parts.push(body.trim());
if (log && log.length) parts.push("— log —\n" + log.join("\n"));
pre.textContent = parts.join("\n\n") || "(no context yet)";
} catch (e) {
pre.textContent = `(could not load context: ${e.message})`;
}
}
function renderMain() {
const main = $("#main");
main.innerHTML = "";
if (state.search) {
main.append(searchPane());
return;
}
if (state.error) {
main.append(h("div", { class: "notice error" }, state.error));
}
if (state.loading && state.tasks.length === 0) {
main.append(h("div", { class: "notice" }, "Loading…"));
return;
}
if (!state.loading && state.tasks.length === 0 && !state.error) {
main.append(h("div", { class: "notice" }, "Nothing here. Tap + to capture a task."));
return;
}
const list = h("div", { class: "list" }, state.tasks.map(taskRow));
main.append(list);
}
// --- drawer (views + projects) ---------------------------------------------
function renderDrawer() {
const body = $("#drawer-body");
body.innerHTML = "";
body.append(h("div", { class: "drawer-section" }, "Views"));
for (const v of VIEWS) {
body.append(drawerItem(v.title, state.target.type === "view" && state.target.id === v.id, () => {
state.target = { type: "view", id: v.id, title: v.title };
closeDrawer();
reload();
}));
}
body.append(h("div", { class: "drawer-section" }, "Projects"));
if (state.projects.length === 0) {
body.append(h("div", { class: "drawer-empty" }, "(none yet)"));
}
for (const p of state.projects) {
body.append(drawerItem(p.title, state.target.type === "project" && state.target.id === p.id, () => {
state.target = { type: "project", id: p.id, title: p.title };
closeDrawer();
reload();
}, projectColor(p.id)));
}
}
function drawerItem(label, active, onclick, dot) {
return h(
"div",
{ class: "drawer-item" + (active ? " active" : ""), onclick },
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
h("span", {}, label),
);
}
function openDrawer() {
renderDrawer();
$("#drawer").classList.add("open");
$("#backdrop").classList.add("show");
}
function closeDrawer() {
$("#drawer").classList.remove("open");
$("#backdrop").classList.remove("show");
}
// --- modal scaffolding ------------------------------------------------------
function openModal(node) {
const root = $("#modal-root");
root.innerHTML = "";
root.append(h("div", { class: "modal-backdrop", onclick: closeModal }, h("div", { class: "modal", onclick: (e) => e.stopPropagation() }, node)));
root.classList.add("show");
}
function closeModal() {
$("#modal-root").classList.remove("show");
$("#modal-root").innerHTML = "";
}
function modalOpen() {
return $("#modal-root").classList.contains("show");
}
// --- quick-add (the primary use case) --------------------------------------
function openQuickAdd() {
closeDrawer();
const input = h("input", {
class: "qa-input",
type: "text",
placeholder: "Buy milk tomorrow p2 #Work every week",
autocomplete: "off",
autocapitalize: "sentences",
enterkeyhint: "done",
});
const preview = h("div", { class: "qa-preview" });
const updatePreview = () => {
const parsed = quickParse(input.value, today(), state.projects);
preview.innerHTML = "";
if (!input.value.trim()) {
preview.append(h("span", { class: "qa-hint" }, "p1p4 · #Project · today/+3d/fri · every week"));
return;
}
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
if (parsed.attention) {
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention));
}
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence)));
};
input.addEventListener("input", updatePreview);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
submitQuickAdd(input.value);
} else if (e.key === "Escape") {
closeModal();
}
});
const mic = voiceButton(input, updatePreview);
const node = h(
"div",
{ class: "qa" },
h("div", { class: "qa-row" }, input, mic),
preview,
h(
"div",
{ class: "qa-foot" },
state.target.type === "project"
? h("span", { class: "qa-dest" }, "→ " + state.target.title)
: h("span", { class: "qa-dest" }, "→ Inbox (unless #Project given)"),
h("button", { class: "qa-add", onclick: () => submitQuickAdd(input.value) }, "Add"),
),
);
openModal(node);
updatePreview();
setTimeout(() => input.focus(), 50);
}
async function submitQuickAdd(raw) {
const text = raw.trim();
if (!text) return;
const parsed = quickParse(text, today(), state.projects);
if (!parsed.title) {
toast("Needs a title.");
return;
}
const projectId =
parsed.projectId || (state.target.type === "project" ? state.target.id : null);
closeModal();
try {
await state.client.createTask({
title: parsed.title,
attention: parsed.attention,
doDate: parsed.doDate,
recurrence: parsed.recurrence,
projectId,
});
toast(`Added: ${parsed.title}`);
reload();
} catch (e) {
toast(`Add failed: ${e.message}`);
}
}
// --- voice input ------------------------------------------------------------
// Web Speech API where available (desktop Chrome, Android). On iOS Safari the
// API is absent, but the on-screen keyboard's dictation mic works in the text
// field for free — so we simply omit the button there.
function voiceButton(input, onUpdate) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return null;
let rec = null;
const btn = h("button", { class: "qa-mic", title: "Dictate" }, "🎤");
btn.addEventListener("click", () => {
if (rec) {
rec.stop();
return;
}
rec = new SR();
rec.lang = navigator.language || "en-US";
rec.interimResults = true;
btn.classList.add("listening");
let base = input.value ? input.value + " " : "";
rec.onresult = (ev) => {
let text = "";
for (const r of ev.results) text += r[0].transcript;
input.value = base + text;
onUpdate();
};
rec.onend = () => {
rec = null;
btn.classList.remove("listening");
input.focus();
};
rec.onerror = () => toast("Voice input unavailable.");
rec.start();
});
return btn;
}
// --- reschedule -------------------------------------------------------------
function openReschedule(t) {
const input = h("input", {
class: "qa-input",
type: "text",
placeholder: "today · tomorrow · +3d · fri · 2026-07-01 · (blank to clear)",
value: t.do_date != null ? fmtRelative(t.do_date) : "",
autocomplete: "off",
enterkeyhint: "done",
});
const apply = async () => {
const v = input.value.trim();
let doDate = null;
if (v) {
try {
doDate = toEpochMs(parseDate(v, today()));
} catch {
toast("Unrecognized date.");
return;
}
}
closeModal();
try {
await state.client.setSchedule(t.node_id, { doDate });
toast(doDate ? `Rescheduled: ${fmtRelative(doDate)}` : "Do-date cleared");
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") (e.preventDefault(), apply());
if (e.key === "Escape") closeModal();
});
openModal(
h(
"div",
{ class: "qa" },
h("div", { class: "modal-title" }, "Reschedule"),
input,
h("div", { class: "qa-foot" }, h("button", { class: "qa-add", onclick: apply }, "Set")),
),
);
setTimeout(() => input.focus(), 50);
}
// --- move / project picker --------------------------------------------------
function openMove(t) {
const filter = h("input", { class: "qa-input", type: "text", placeholder: "Filter or new project…", autocomplete: "off" });
const list = h("div", { class: "picker-list" });
const renderOptions = () => {
const q = filter.value.trim().toLowerCase();
list.innerHTML = "";
list.append(pickerItem("(Unfile)", () => move(t, null)));
for (const p of state.projects) {
if (q && !p.title.toLowerCase().includes(q)) continue;
list.append(pickerItem(p.title, () => move(t, p.id), projectColor(p.id)));
}
const exact = state.projects.some((p) => p.title.toLowerCase() === q);
if (q && !exact) {
list.append(pickerItem(`+ New project "${filter.value.trim()}"`, () => createAndMove(t, filter.value.trim())));
}
};
filter.addEventListener("input", renderOptions);
filter.addEventListener("keydown", (e) => e.key === "Escape" && closeModal());
openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Move "${t.title}"`), filter, list));
renderOptions();
setTimeout(() => filter.focus(), 50);
}
function pickerItem(label, onclick, dot) {
return h(
"div",
{ class: "picker-item", onclick },
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
h("span", {}, label),
);
}
async function move(t, projectId) {
closeModal();
try {
await state.client.setProject(t.node_id, projectId);
toast(projectId ? `Moved to ${projectTitle(projectId)}` : "Unfiled");
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function createAndMove(t, name) {
closeModal();
try {
const id = await state.client.createProject(name);
await refreshProjects();
await state.client.setProject(t.node_id, id);
toast(`Moved to ${name}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
// --- triage actions ---------------------------------------------------------
async function triage(t, newState) {
state.expandedId = null;
try {
await state.client.setState(t.node_id, newState);
const verb = newState === "done" ? "Done" : "Dropped";
toast(`${verb}: ${t.title}`, {
label: "Undo",
run: async () => {
try {
await state.client.setState(t.node_id, "outstanding");
reload();
} catch (e) {
toast(`Undo failed: ${e.message}`);
}
},
});
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function doSkip(t) {
try {
await state.client.skip(t.node_id);
toast(`Skipped: ${t.title}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function cycleAttention(t) {
const next = nextAttention(t.attention);
try {
await state.client.setAttention(t.node_id, next);
toast(`Attention: ${next}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function doDelete(t) {
if (!confirm(`Delete "${t.title}"? This removes it for good (use Drop to triage instead).`)) {
return;
}
state.expandedId = null;
try {
await state.client.tombstone(t.node_id);
toast(`Deleted: ${t.title}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
// --- search -----------------------------------------------------------------
function openSearch() {
state.search = { query: "", results: [] };
render();
setTimeout(() => $("#search-input")?.focus(), 50);
}
function closeSearch() {
state.search = null;
render();
}
function searchPane() {
const input = h("input", {
id: "search-input",
class: "search-input",
type: "search",
placeholder: "Search tasks & docs…",
value: state.search.query,
autocomplete: "off",
enterkeyhint: "search",
});
let timer = null;
const run = async () => {
state.search.query = input.value;
const q = input.value.trim();
if (!q) {
state.search.results = [];
renderSearchResults();
return;
}
try {
state.search.results = await state.client.search(q);
} catch (e) {
state.search.results = [];
toast(e.message);
}
renderSearchResults();
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(run, 200);
});
input.addEventListener("keydown", (e) => e.key === "Escape" && closeSearch());
return h(
"div",
{ class: "search-pane" },
h("div", { class: "search-bar" }, input, h("button", { class: "search-close", onclick: closeSearch }, "✕")),
h("div", { class: "search-results", id: "search-results" }),
);
}
function renderSearchResults() {
const root = $("#search-results");
if (!root) return;
root.innerHTML = "";
if (!state.search.results.length) {
root.append(h("div", { class: "notice" }, state.search.query ? "No matches." : "Type to search."));
return;
}
for (const hit of state.search.results) {
root.append(
h(
"div",
{ class: "search-hit" },
h("span", { class: "hit-kind" }, `[${hit.kind}]`),
h("span", {}, hit.title),
),
);
}
}
// --- settings ---------------------------------------------------------------
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 = makeClient();
closeModal();
reload();
};
const check = async () => {
setTest("Checking…", null);
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
try {
const v = await probe.call("version", {});
setTest(`✓ Connected (hephd ${v.version})`, true);
} catch (e) {
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(
"div",
{ class: "qa" },
h("div", { class: "modal-title" }, "Settings"),
h("label", { class: "settings-label" }, "Hub URL"),
url,
h("label", { class: "settings-label" }, "Sign-in"),
authRow,
h(
"details",
{ class: "settings-manual" },
h("summary", {}, "Or paste a bearer token"),
tok,
),
test,
h(
"div",
{ class: "qa-foot settings-foot" },
h("button", { class: "act", onclick: check }, "Test"),
h("button", { class: "qa-add", onclick: save }, "Save"),
),
h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."),
),
);
}
// --- keyboard ---------------------------------------------------------------
function onKeydown(e) {
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
// Cmd/Ctrl + ' opens quick-add anywhere (mirrors the global popover).
if ((e.metaKey || e.ctrlKey) && e.key === "'") {
e.preventDefault();
openQuickAdd();
return;
}
if (typing || modalOpen()) {
if (e.key === "Escape" && modalOpen()) closeModal();
return;
}
if (e.key === "a") (e.preventDefault(), openQuickAdd());
else if (e.key === "/") (e.preventDefault(), openSearch());
else if (e.key === "r") reload();
else if (e.key === "Escape") state.search ? closeSearch() : closeDrawer();
}
// --- shell + init -----------------------------------------------------------
function buildShell() {
const app = $("#app");
app.append(
h(
"header",
{ class: "appbar" },
h("button", { class: "icon-btn", title: "Menu", onclick: openDrawer }, "☰"),
h("div", { id: "view-title", class: "appbar-title" }, state.target.title),
h("button", { class: "icon-btn", title: "Search", onclick: openSearch }, "🔍"),
h("button", { class: "icon-btn", title: "Settings", onclick: openSettings }, "⚙"),
),
h("main", { id: "main" }),
h("button", { id: "fab", class: "fab", title: "Quick add (Cmd-)", onclick: openQuickAdd }, "+"),
h("div", { id: "backdrop", class: "backdrop", onclick: closeDrawer }),
h(
"aside",
{ id: "drawer", class: "drawer" },
h("div", { class: "drawer-head" }, "heph"),
h("div", { id: "drawer-body", class: "drawer-body" }),
),
h("div", { id: "modal-root", class: "modal-root" }),
h("div", { id: "toast", class: "toast" }),
);
}
async function init() {
buildShell();
document.addEventListener("keydown", onKeydown);
// The PWA shares the daemon's store with the TUI / desktop popover, but only
// re-fetches on a view switch or an action. So another surface marking a task
// done leaves a stale list on screen until then. Re-fetch the current view
// whenever the app regains focus (switching back to the phone, unlock, tab
// re-show) — but not while a modal or search is mid-interaction.
document.addEventListener("visibilitychange", () => {
if (
document.visibilityState === "visible" &&
state.client.configured &&
!modalOpen() &&
!state.search
) {
reload();
}
});
// 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();
if ("serviceWorker" in navigator) {
try {
await navigator.serviceWorker.register("./sw.js");
} catch {
/* offline shell is best-effort */
}
}
}
init();

361
heph-pwa/src/datespec.js Normal file
View file

@ -0,0 +1,361 @@
// Human-friendly date and recurrence parsing — a faithful JS port of hephd's
// `datespec.rs` (tech-spec §1, §8, §8.1) so the PWA's quick-add accepts the
// exact same forms as the CLI/TUI and produces identical RRULEs and do-dates.
//
// Dates are date-grained and stored as epoch ms at *local midnight* (matching
// `to_epoch_ms`). All pure functions take an explicit `today` so they stay
// deterministically testable; the thin wrappers read the local clock.
/** A local-midnight Date for today (time component stripped). */
export function today() {
const n = new Date();
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
}
/** Local-midnight epoch ms for a Date (the form do_date/late_on are stored in). */
export function toEpochMs(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function addDays(date, n) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n);
}
function addMonths(date, n) {
return new Date(date.getFullYear(), date.getMonth() + n, date.getDate());
}
// JS getDay(): 0=Sun..6=Sat.
const WEEKDAYS = {
mon: 1, monday: 1,
tue: 2, tues: 2, tuesday: 2,
wed: 3, weds: 3, wednesday: 3,
thu: 4, thur: 4, thurs: 4, thursday: 4,
fri: 5, friday: 5,
sat: 6, saturday: 6,
sun: 0, sunday: 0,
};
const BYDAY = { 0: "SU", 1: "MO", 2: "TU", 3: "WE", 4: "TH", 5: "FR", 6: "SA" };
/** Weekday name (full or common abbreviation) → JS day index, or null. */
function parseWeekday(s) {
return Object.prototype.hasOwnProperty.call(WEEKDAYS, s) ? WEEKDAYS[s] : null;
}
/** The soonest date on/after `today` whose weekday is `wd` (JS day index). */
function soonestWeekday(today, wd) {
let d = today;
for (let i = 0; i < 7; i++) {
if (d.getDay() === wd) return d;
d = addDays(d, 1);
}
return today;
}
function parseOffset(rest, today) {
rest = rest.trim();
const m = rest.match(/^(\d+)\s*([a-z]*)$/);
if (!m) throw new Error(`not a relative date offset: +${rest}`);
const n = parseInt(m[1], 10);
switch (m[2]) {
case "": case "d": case "day": case "days": return addDays(today, n);
case "w": case "wk": case "week": case "weeks": return addDays(today, n * 7);
case "m": case "mo": case "month": case "months": return addMonths(today, n);
default: throw new Error(`unknown offset unit "${m[2]}" (use d, w, or m)`);
}
}
/**
* Parse a human date spec relative to `today` (a local-midnight Date) into a
* local-midnight Date. Accepts: today/now, tomorrow/tom, yesterday; +Nd/+Nw/+Nm
* (bare +N = days); weekday names (soonest on/after today); ISO YYYY-MM-DD.
* Throws on anything unrecognized.
*/
export function parseDate(input, todayDate) {
const s = input.trim().toLowerCase();
if (s === "") throw new Error("empty date");
switch (s) {
case "today": case "now": return todayDate;
case "tomorrow": case "tom": return addDays(todayDate, 1);
case "yesterday": return addDays(todayDate, -1);
}
const wd = parseWeekday(s);
if (wd !== null) return soonestWeekday(todayDate, wd);
if (s.startsWith("+")) return parseOffset(s.slice(1), todayDate);
// ISO YYYY-MM-DD (strict; construct as local midnight).
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) {
const [, y, mo, d] = iso;
const date = new Date(Number(y), Number(mo) - 1, Number(d));
if (
date.getFullYear() === Number(y) &&
date.getMonth() === Number(mo) - 1 &&
date.getDate() === Number(d)
) {
return date;
}
}
throw new Error(
`unrecognized date: "${input}" (try today, tomorrow, +3d, fri, or YYYY-MM-DD)`,
);
}
/** parseDate to epoch ms, or null if unparseable (convenience for quick-add). */
export function parseDateMsOrNull(input, todayDate) {
try {
return toEpochMs(parseDate(input, todayDate));
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Recurrence
// ---------------------------------------------------------------------------
const MONTHS = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
function parseMonthDay(s) {
const toks = s.split(/\s+/).filter(Boolean);
if (toks.length !== 2) return null;
const month = (t) => MONTHS[t.slice(0, 3)] ?? null;
const day = (t) => {
const m = t.match(/^(\d+)/);
return m ? parseInt(m[1], 10) : null;
};
let m = month(toks[0]);
let d = day(toks[1]);
if (m !== null && d !== null) return [m, d];
d = day(toks[0]);
m = month(toks[1]);
if (m !== null && d !== null) return [m, d];
return null;
}
function parseMonthdayOrdinal(s) {
const m = s.match(/^(\d+)(st|nd|rd|th)$/);
if (!m) return null;
const d = parseInt(m[1], 10);
return d >= 1 && d <= 31 ? d : null;
}
function intervalForm(n, unit) {
const wd = parseWeekday(unit);
if (wd !== null) {
return n === 1
? `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}`
: `FREQ=WEEKLY;INTERVAL=${n};BYDAY=${BYDAY[wd]}`;
}
let freq;
switch (unit) {
case "day": case "days": freq = "DAILY"; break;
case "week": case "weeks": freq = "WEEKLY"; break;
case "month": case "months": freq = "MONTHLY"; break;
case "year": case "years": freq = "YEARLY"; break;
default:
throw new Error(
`unrecognized recurrence "${unit}" (try daily/weekly/monthly/yearly, ` +
`'every 3 days', 'every fri', or a raw RRULE)`,
);
}
return n === 1 ? `FREQ=${freq}` : `FREQ=${freq};INTERVAL=${n}`;
}
/**
* Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE (anything
* containing FREQ=), presets (daily/weekly/monthly/yearly/weekdays), and the
* common natural-language forms (§6.2.1): every N (day|week|month|year)s, every
* <weekday>, every other <weekday|unit>, every workday, every <Month> <day>,
* every <Nth>. A trailing "at <time>" is ignored. Throws if unrecognized.
*/
export function parseRecurrence(spec) {
const raw = spec.trim();
if (raw.toUpperCase().includes("FREQ=")) return raw;
let s = raw.toLowerCase();
const at = s.indexOf(" at ");
if (at !== -1) s = s.slice(0, at);
s = s.trim();
switch (s) {
case "daily": case "day": return "FREQ=DAILY";
case "weekly": case "week": return "FREQ=WEEKLY";
case "monthly": case "month": return "FREQ=MONTHLY";
case "yearly": case "annually": case "year": return "FREQ=YEARLY";
case "weekdays": case "workdays": case "workday": case "weekday":
return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
}
const body = (s.startsWith("every ") ? s.slice("every ".length) : s).trim();
if (body.startsWith("other ")) return intervalForm(2, body.slice("other ".length).trim());
if (body === "workday" || body === "weekday" || body === "workdays" || body === "weekdays") {
return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
}
const wd = parseWeekday(body);
if (wd !== null) return `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}`;
const md = parseMonthDay(body);
if (md) return `FREQ=YEARLY;BYMONTH=${md[0]};BYMONTHDAY=${md[1]}`;
const ord = parseMonthdayOrdinal(body);
if (ord !== null) return `FREQ=MONTHLY;BYMONTHDAY=${ord}`;
const toks = body.split(/\s+/).filter(Boolean);
const first = toks[0] ?? "";
const asNum = /^\d+$/.test(first) ? parseInt(first, 10) : null;
if (asNum !== null) return intervalForm(asNum, toks[1] ?? "");
return intervalForm(1, first);
}
/** parseRecurrence, but returns null instead of throwing. */
export function parseRecurrenceOrNull(spec) {
try {
return parseRecurrence(spec);
} catch {
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;
}
}

71
heph-pwa/src/fmt.js Normal file
View file

@ -0,0 +1,71 @@
// Display helpers — the PWA mirror of heph-tui's fmt.rs: relative date chips,
// attention colors/flags, and a stable per-project bullet color.
/** Attention color string → the CSS custom-property color used for flags/dots. */
export const ATTENTION_COLORS = {
red: "var(--att-red)",
orange: "var(--att-orange)",
blue: "var(--att-blue)",
white: "var(--att-white)",
};
/** The cycle order used by the attention toggle (matches the TUI's `A` key). */
export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"];
/** Next attention in the cycle: none → white → orange → red → blue → white. */
export function nextAttention(att) {
const i = ATTENTION_CYCLE.indexOf(att ?? null);
// After blue (last), wrap to white (index 1), not back to none.
const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length;
return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white";
}
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
export function hasFlag(att) {
return att === "red" || att === "orange" || att === "blue";
}
function startOfDay(ms) {
const d = new Date(ms);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
/**
* Compact, relative date label for an epoch-ms date (heph-tui fmt.rs):
* today/tomorrow/yesterday, else MM-DD within the current year, else YYYY-MM-DD.
*/
export function fmtRelative(ms, nowMs = Date.now()) {
if (ms == null) return "";
const day = startOfDay(ms);
const today = startOfDay(nowMs);
const oneDay = 86_400_000;
if (day === today) return "today";
if (day === today + oneDay) return "tomorrow";
if (day === today - oneDay) return "yesterday";
const d = new Date(ms);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
if (d.getFullYear() === new Date(nowMs).getFullYear()) return `${mm}-${dd}`;
return `${d.getFullYear()}-${mm}-${dd}`;
}
/** True when `lateOn` is strictly in the past — the sole urgency signal (§7). */
export function isOverdue(lateOn, nowMs = Date.now()) {
return lateOn != null && nowMs > lateOn;
}
/** A stable hue (0359) for a project id, so its bullet color is deterministic. */
export function projectHue(id) {
if (!id) return null;
let h = 0;
for (let i = 0; i < id.length; i++) {
h = (h * 31 + id.charCodeAt(i)) >>> 0;
}
return h % 360;
}
/** CSS color for a project's bullet, or a neutral default when unfiled. */
export function projectColor(id) {
const hue = projectHue(id);
return hue == null ? "var(--bullet-none)" : `hsl(${hue} 55% 62%)`;
}

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;
}
}

113
heph-pwa/src/quickadd.js Normal file
View file

@ -0,0 +1,113 @@
// Single-line natural-language quick-add — a faithful JS port of hephd's
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
// `Water plants tomorrow p2 #Chores every 3 days`
//
// Recognized inline tokens are extracted and the remainder is the title (order
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white)
// - Project #Name → resolved against existing projects, greedily matching
// multi-word titles (#Camano Chores). Unresolved #tags
// stay in the title verbatim (no surprise project).
// - Do-date a datespec token: today/tomorrow/+3d/fri/ISO
// - Recurrence an `every …` phrase (the longest suffix that parses)
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
function priorityAttention(token) {
switch (token.toLowerCase()) {
case "p1": return "red";
case "p2": return "orange";
case "p3": return "blue";
case "p4": return "white";
default: return null;
}
}
/**
* Greedily match `first` (+ following words) against a known project title,
* case-insensitively, longest-first. Returns [projectId, extraWordsTaken] or
* null. `projects` is an array of { id, title }.
*/
function matchProject(first, rest, projects) {
const maxExtra = Math.min(rest.length, 4);
for (let extra = maxExtra; extra >= 0; extra--) {
const candidate = [first, ...rest.slice(0, extra)].join(" ");
const p = projects.find((p) => p.title.toLowerCase() === candidate.toLowerCase());
if (p) return [p.id, extra];
}
return null;
}
/** Find the first `every` token and consume the longest suffix that parses. */
function extractRecurrence(tokens, out) {
const start = tokens.findIndex((t) => t.toLowerCase() === "every");
if (start === -1) return;
for (let end = tokens.length; end > start + 1; end--) {
const phrase = tokens.slice(start, end).join(" ");
const rrule = parseRecurrenceOrNull(phrase);
if (rrule) {
out.recurrence = rrule;
tokens.splice(start, end - start);
return;
}
}
}
/**
* Parse a quick-add line against `today` (a local-midnight Date) and the known
* `projects` (array of { id, title }). Returns:
* { title, attention|null, doDate(ms)|null, recurrence(RRULE)|null, projectId|null }
*/
export function parse(input, todayDate, projects = []) {
const tokens = input.split(/\s+/).filter(Boolean);
const out = {
title: "",
attention: null,
doDate: null,
recurrence: null,
projectId: null,
};
extractRecurrence(tokens, out);
const title = [];
let i = 0;
while (i < tokens.length) {
const tok = tokens[i];
const att = priorityAttention(tok);
if (att !== null) {
out.attention = att;
i += 1;
continue;
}
if (tok.startsWith("#")) {
const stripped = tok.slice(1);
const matched = matchProject(stripped, tokens.slice(i + 1), projects);
if (matched) {
out.projectId = matched[0];
i += 1 + matched[1];
continue;
}
// Unresolved #tag: keep the word (with the #) in the title.
}
if (out.doDate === null) {
try {
out.doDate = toEpochMs(parseDate(tok, todayDate));
i += 1;
continue;
} catch {
// not a date token; fall through to title
}
}
title.push(tok);
i += 1;
}
out.title = title.join(" ");
return out;
}

182
heph-pwa/src/rpc.js Normal file
View file

@ -0,0 +1,182 @@
// hephd JSON-RPC-over-HTTP client for the PWA. The PWA is a thin, online-only
// client (no local CRDT replica): every read and write is a POST to the hub's
// `/rpc` endpoint, exactly mirroring heph-tui's socket Backend (backend.rs).
//
// Connection settings (hub base URL + optional bearer token) live in
// localStorage so the install remembers them across launches.
const SETTINGS_KEY = "heph-pwa:settings";
export function loadSettings() {
let s = {};
try {
s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
} catch {
s = {};
}
let baseUrl = s.baseUrl || "";
// Served from the hub? Default the hub URL to our own origin so the app is
// zero-config out of the box (the Settings screen still lets you override,
// e.g. when the shell is hosted separately from the hub).
if (!baseUrl && typeof location !== "undefined" && /^https?:/.test(location.origin)) {
baseUrl = location.origin;
}
return { baseUrl, token: s.token || "" };
}
export function saveSettings(settings) {
localStorage.setItem(
SETTINGS_KEY,
JSON.stringify({ baseUrl: settings.baseUrl || "", token: settings.token || "" }),
);
}
/** Thrown for transport/auth/method failures, carrying an HTTP-ish status. */
export class RpcError extends Error {
constructor(message, status = 0) {
super(message);
this.name = "RpcError";
this.status = status;
}
}
export class Client {
constructor(settings) {
this.settings = settings;
}
get configured() {
return !!this.settings.baseUrl;
}
/** 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);
}
const base = this.settings.baseUrl.replace(/\/+$/, "");
const headers = { "Content-Type": "application/json" };
if (this.settings.token) headers["Authorization"] = `Bearer ${this.settings.token}`;
let resp;
try {
resp = await fetch(`${base}/rpc`, {
method: "POST",
headers,
body: JSON.stringify({ method, params }),
});
} catch (e) {
throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0);
}
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);
const body = await resp.json();
if (body.error) throw new RpcError(body.error.message || "RPC error", 200);
return body.result;
}
// --- Reads (mirror heph-tui's Backend) ---------------------------------
/** Built-in named view (tom|tasks|work|chores|ondeck|inbox) → RankedTask[]. */
view(name) {
return this.call("view", { name });
}
/** Raw filter listing → RankedTask[]. */
list(filter) {
return this.call("list", filter);
}
/** Projects, title-sorted → [{ id, title }]. */
async projects() {
const nodes = await this.call("node.list", { kind: "project" });
return nodes.map((n) => ({ id: n.id, title: n.title }));
}
async nodeBody(id) {
const node = await this.call("node.get", { id });
return node && node.body ? node.body : "";
}
logTail(taskId, n = 5) {
return this.call("log.tail", { task_id: taskId, n });
}
/** Full-text search → [{ id, title, kind }]. */
async search(query) {
const nodes = await this.call("search", { query });
return nodes.map((n) => ({ id: n.id, title: n.title, kind: n.kind }));
}
health() {
return this.call("health", {});
}
// --- Writes ------------------------------------------------------------
/** Create a task. attention/doDate/recurrence/projectId may be null. */
createTask({ title, attention = null, doDate = null, recurrence = null, projectId = null }) {
return this.call("task.create", {
title,
attention,
do_date: doDate,
recurrence,
project_id: projectId,
});
}
setState(id, state) {
return this.call("task.set_state", { id, state });
}
setAttention(id, attention) {
return this.call("task.set_attention", { id, attention });
}
/** Patch schedule scalars. Pass undefined to leave a field unchanged; pass
* null to clear it; pass a value to set it (double-option semantics). */
setSchedule(id, patch) {
const params = { id };
if ("doDate" in patch) params.do_date = patch.doDate;
if ("lateOn" in patch) params.late_on = patch.lateOn;
if ("recurrence" in patch) params.recurrence = patch.recurrence;
return this.call("task.set_schedule", params);
}
setProject(id, projectId) {
return this.call("task.set_project", { id, project_id: projectId });
}
skip(id) {
return this.call("task.skip", { id });
}
tombstone(id) {
return this.call("node.tombstone", { id });
}
async createProject(title) {
const node = await this.call("node.create", { kind: "project", title });
return node.id;
}
/** The canonical context doc id for a task, if any (links.outgoing). */
async contextOf(taskId) {
const links = await this.call("links.outgoing", { id: taskId });
const ctx = links.find((l) => l.link_type === "canonical-context");
return ctx ? ctx.dst_id : null;
}
}

524
heph-pwa/styles.css Normal file
View file

@ -0,0 +1,524 @@
/* heph-pwa — a dark, terminal-flavored mirror of heph-tui, tuned for touch. */
:root {
--bg: #15181d;
--bg-elev: #1c2027;
--bg-row: #1a1e24;
--border: #2a2f38;
--fg: #e6e9ef;
--fg-dim: #8b94a3;
--accent: #6db3f2;
--att-red: #ff6b6b;
--att-orange: #ffb454;
--att-blue: #6db3f2;
--att-white: #e6e9ef;
--bullet-none: #5a6373;
--danger: #ff6b6b;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
overscroll-behavior-y: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
/* --- App bar --- */
.appbar {
display: flex;
align-items: center;
gap: 4px;
padding: calc(var(--safe-top) + 6px) 8px 6px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
}
.appbar-title {
flex: 1;
font-weight: 600;
font-size: 18px;
padding-left: 4px;
}
.icon-btn {
background: none;
border: 0;
color: var(--fg);
font-size: 20px;
width: 40px;
height: 40px;
border-radius: 8px;
}
.icon-btn:active {
background: var(--bg-row);
}
/* --- Main / list --- */
#main {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(var(--safe-bottom) + 96px);
}
.notice {
padding: 32px 20px;
text-align: center;
color: var(--fg-dim);
}
.notice.error {
color: var(--att-orange);
}
.list {
display: flex;
flex-direction: column;
}
.row {
border-bottom: 1px solid var(--border);
}
.row-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
min-height: 28px;
}
.row.expanded {
background: var(--bg-row);
}
.flag {
width: 14px;
text-align: center;
flex: 0 0 auto;
}
.bullet {
flex: 0 0 auto;
font-size: 12px;
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row.expanded .title {
white-space: normal;
}
.recur {
color: #c678dd;
flex: 0 0 auto;
}
.chip {
flex: 0 0 auto;
font-size: 13px;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
.chip.overdue {
color: var(--att-red);
font-weight: 700;
}
/* --- Task detail --- */
.detail {
padding: 4px 14px 14px 36px;
}
.meta {
margin-bottom: 10px;
font-size: 13px;
color: var(--fg-dim);
}
.meta-row {
display: flex;
gap: 8px;
}
.meta-k {
width: 64px;
flex: 0 0 auto;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.act {
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--fg);
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
}
.act:active {
background: var(--border);
}
.act.danger {
color: var(--danger);
border-color: #4a2a2a;
}
.preview {
margin: 12px 0 0;
padding: 10px;
background: #0f1216;
border: 1px solid var(--border);
border-radius: 8px;
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow-y: auto;
}
/* --- FAB --- */
.fab {
position: fixed;
right: 18px;
bottom: calc(var(--safe-bottom) + 18px);
width: 60px;
height: 60px;
border-radius: 30px;
border: 0;
background: var(--accent);
color: #0c1014;
font-size: 34px;
line-height: 1;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
z-index: 6;
}
.fab:active {
transform: scale(0.95);
}
/* --- Drawer --- */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s;
z-index: 9;
}
.backdrop.show {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 78%;
max-width: 320px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
transform: translateX(-100%);
transition: transform 0.2s ease;
z-index: 10;
display: flex;
flex-direction: column;
}
.drawer.open {
transform: translateX(0);
}
.drawer-head {
padding: calc(var(--safe-top) + 16px) 16px 12px;
font-weight: 700;
font-size: 20px;
border-bottom: 1px solid var(--border);
}
.drawer-body {
overflow-y: auto;
padding-bottom: var(--safe-bottom);
}
.drawer-section {
padding: 14px 16px 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-dim);
}
.drawer-empty {
padding: 4px 16px 8px;
color: var(--fg-dim);
font-size: 14px;
}
.drawer-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
}
.drawer-item.active {
background: var(--bg-row);
box-shadow: inset 3px 0 0 var(--accent);
}
.drawer-item:active {
background: var(--border);
}
/* --- Modals --- */
.modal-root {
position: fixed;
inset: 0;
z-index: 20;
display: none;
}
.modal-root.show {
display: block;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: calc(var(--safe-top) + 12vh) 12px 12px;
}
.modal {
width: 100%;
max-width: 560px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-weight: 600;
margin-bottom: 10px;
}
.qa {
display: flex;
flex-direction: column;
gap: 10px;
}
.qa-row {
display: flex;
gap: 8px;
align-items: center;
}
.qa-input,
.search-input {
flex: 1;
width: 100%;
background: #0f1216;
border: 1px solid var(--border);
color: var(--fg);
border-radius: 10px;
padding: 13px 12px;
font-size: 17px; /* ≥16px so iOS doesn't zoom on focus */
}
.qa-input:focus,
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.qa-mic {
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-row);
font-size: 20px;
}
.qa-mic.listening {
border-color: var(--att-red);
animation: pulse 1s infinite;
}
@keyframes pulse {
50% {
background: #3a2326;
}
}
.qa-preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 22px;
}
.qa-hint {
color: var(--fg-dim);
font-size: 13px;
}
.qa-title {
font-weight: 600;
}
.qa-tag {
font-size: 13px;
color: var(--fg-dim);
background: var(--bg-row);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 6px;
}
.qa-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.qa-dest {
color: var(--fg-dim);
font-size: 13px;
}
.qa-add {
background: var(--accent);
color: #0c1014;
border: 0;
border-radius: 10px;
padding: 11px 22px;
font-size: 16px;
font-weight: 600;
}
.picker-list {
max-height: 50vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.picker-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 6px;
border-bottom: 1px solid var(--border);
}
.picker-item:active {
background: var(--bg-row);
}
.settings-label {
font-size: 13px;
color: var(--fg-dim);
margin-bottom: -4px;
}
.settings-foot {
justify-content: flex-end;
}
.settings-test {
font-size: 13px;
min-height: 18px;
color: var(--fg-dim);
}
.settings-test.ok {
color: #7ec77e;
}
.settings-test.bad {
color: var(--att-red);
}
.settings-hint {
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 {
display: flex;
flex-direction: column;
height: 100%;
}
.search-bar {
display: flex;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-close {
background: var(--bg-row);
border: 1px solid var(--border);
color: var(--fg);
border-radius: 10px;
width: 46px;
font-size: 18px;
}
.search-results {
overflow-y: auto;
}
.search-hit {
display: flex;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.hit-kind {
color: var(--fg-dim);
font: 13px ui-monospace, monospace;
flex: 0 0 auto;
}
/* --- Toast --- */
.toast {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--safe-bottom) + 90px);
display: flex;
justify-content: center;
z-index: 30;
pointer-events: none;
padding: 0 12px;
}
.toast-body {
pointer-events: auto;
background: #2a2f38;
color: var(--fg);
border-radius: 10px;
padding: 11px 14px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
max-width: 560px;
}
.toast-action {
background: none;
border: 0;
color: var(--accent);
font-weight: 700;
font-size: 15px;
}

56
heph-pwa/sw.js Normal file
View file

@ -0,0 +1,56 @@
// 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-v4";
const SHELL = [
"./",
"./index.html",
"./styles.css",
"./manifest.webmanifest",
"./src/app.js",
"./src/rpc.js",
"./src/oauth.js",
"./src/quickadd.js",
"./src/datespec.js",
"./src/fmt.js",
"./icons/icon.svg",
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
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. 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(
caches.match(req).then(
(hit) =>
hit ||
fetch(req)
.then((resp) => {
if (resp.ok) {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return resp;
})
.catch(() => caches.match("./index.html")),
),
);
});

View file

@ -0,0 +1,184 @@
// Parity tests for the JS parser ports — the exact cases from hephd's
// quickadd.rs / datespec.rs unit tests. Run: `node --test heph-pwa/test/`.
import test from "node:test";
import assert from "node:assert/strict";
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);
const ms = (y, m, day) => toEpochMs(d(y, m, day));
// datespec.rs uses 2026-06-02 (a Tuesday) as `today`.
const DTODAY = d(2026, 6, 2);
test("parse_date keywords and offsets", () => {
assert.deepEqual(parseDate("today", DTODAY), d(2026, 6, 2));
assert.deepEqual(parseDate("tomorrow", DTODAY), d(2026, 6, 3));
assert.deepEqual(parseDate("yesterday", DTODAY), d(2026, 6, 1));
assert.deepEqual(parseDate("+3d", DTODAY), d(2026, 6, 5));
assert.deepEqual(parseDate("+2w", DTODAY), d(2026, 6, 16));
assert.deepEqual(parseDate("+1m", DTODAY), d(2026, 7, 2));
assert.deepEqual(parseDate("+5", DTODAY), d(2026, 6, 7));
});
test("parse_date weekdays are soonest on/after today", () => {
assert.deepEqual(parseDate("tue", DTODAY), d(2026, 6, 2)); // today
assert.deepEqual(parseDate("fri", DTODAY), d(2026, 6, 5));
assert.deepEqual(parseDate("mon", DTODAY), d(2026, 6, 8)); // wraps
});
test("parse_date iso and errors", () => {
assert.deepEqual(parseDate("2026-12-25", DTODAY), d(2026, 12, 25));
assert.throws(() => parseDate("someday", DTODAY));
assert.throws(() => parseDate("", DTODAY));
});
test("recurrence presets and raw", () => {
assert.equal(parseRecurrence("daily"), "FREQ=DAILY");
assert.equal(parseRecurrence("weekly"), "FREQ=WEEKLY");
assert.equal(parseRecurrence("monthly"), "FREQ=MONTHLY");
assert.equal(parseRecurrence("yearly"), "FREQ=YEARLY");
assert.equal(parseRecurrence("weekdays"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
assert.equal(parseRecurrence("FREQ=DAILY;INTERVAL=2"), "FREQ=DAILY;INTERVAL=2");
});
test("recurrence natural language", () => {
assert.equal(parseRecurrence("every day"), "FREQ=DAILY");
assert.equal(parseRecurrence("every 3 days"), "FREQ=DAILY;INTERVAL=3");
assert.equal(parseRecurrence("every 2 weeks"), "FREQ=WEEKLY;INTERVAL=2");
assert.equal(parseRecurrence("every 6 months"), "FREQ=MONTHLY;INTERVAL=6");
assert.equal(parseRecurrence("every fri"), "FREQ=WEEKLY;BYDAY=FR");
assert.equal(parseRecurrence("every other wed"), "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
assert.equal(parseRecurrence("every other day"), "FREQ=DAILY;INTERVAL=2");
assert.equal(parseRecurrence("every workday at 08:00"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
assert.equal(parseRecurrence("every April 15"), "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15");
assert.equal(parseRecurrence("every 5th"), "FREQ=MONTHLY;BYMONTHDAY=5");
assert.equal(parseRecurrence("every 22nd"), "FREQ=MONTHLY;BYMONTHDAY=22");
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 = [
{ id: "work", title: "Work" },
{ id: "camano", title: "Camano Chores" },
];
const p = (input) => parse(input, QTODAY, PROJECTS);
test("plain title", () => {
const r = p("Buy milk");
assert.equal(r.title, "Buy milk");
assert.equal(r.attention, null);
assert.equal(r.doDate, null);
assert.equal(r.recurrence, null);
assert.equal(r.projectId, null);
});
test("priority maps to attention", () => {
assert.equal(p("Email boss p1").attention, "red");
assert.equal(p("Email boss p2").attention, "orange");
assert.equal(p("Email boss p3").attention, "blue");
assert.equal(p("Email boss p4").attention, "white");
assert.equal(p("Email boss p1").title, "Email boss");
});
test("relative date is extracted", () => {
const r = p("Call dentist tomorrow");
assert.equal(r.title, "Call dentist");
assert.equal(r.doDate, ms(2026, 6, 4));
});
test("single + multi-word projects resolve", () => {
assert.equal(p("Standup #Work").projectId, "work");
assert.equal(p("Standup #Work").title, "Standup");
const r = p("Sweep deck #Camano Chores");
assert.equal(r.title, "Sweep deck");
assert.equal(r.projectId, "camano");
});
test("unresolved tag stays in title", () => {
const r = p("Buy #groceries milk");
assert.equal(r.title, "Buy #groceries milk");
assert.equal(r.projectId, null);
});
test("recurrence phrase is extracted", () => {
const r = p("Water plants every 3 days");
assert.equal(r.title, "Water plants");
assert.equal(r.recurrence, "FREQ=DAILY;INTERVAL=3");
});
test("everything at once", () => {
const r = p("Plan trip p2 friday #Work every week");
assert.equal(r.title, "Plan trip");
assert.equal(r.attention, "orange");
assert.equal(r.doDate, ms(2026, 6, 5));
assert.equal(r.projectId, "work");
assert.equal(r.recurrence, "FREQ=WEEKLY");
});
test("non-recurrence every stays in title", () => {
const r = p("Review every report");
assert.equal(r.title, "Review every report");
assert.equal(r.recurrence, null);
});