heph-pwa: mobile app (PWA mirror of heph-tui) + hub static serving #8

Merged
eblume merged 7 commits from feature/heph-pwa-mobile into main 2026-06-04 17:50:48 -07:00
23 changed files with 2739 additions and 11 deletions

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

@ -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,116 @@ 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))
// 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
}
/// 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,163 @@
//! 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::sync::{self, SharedStore};
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
/// 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 {
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, None, 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("*"));
}

View file

@ -0,0 +1 @@
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.

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,127 @@
---
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. **Token:** the hub requires an OIDC bearer token, and the PWA does **not yet
implement the in-app device-code login** — paste a token into Settings →
Token for now. Obtain one via the device-code flow against the Authentik
client (the same flow the CLI uses; e.g. reuse the access token a logged-in
spoke cached, or run a one-off device-code grant). Tap **Test** to confirm.
> **Known gap / next step:** wire the RFC 8628 device-code flow into the PWA's
> Settings so login is in-app (open the verification URL, poll for the token,
> store it, and refresh it) — removing the manual paste. Tracked as follow-up
> work for `heph-pwa`.
## 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

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" }
]
}

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

@ -0,0 +1,817 @@
// 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 { parse as quickParse } from "./quickadd.js";
import { today, parseDate, toEpochMs } 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 }
};
state.client = new Client(state.settings);
// --- 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", 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" }, "↻ " + 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 save = async () => {
state.settings.baseUrl = url.value.trim();
state.settings.token = tok.value.trim();
saveSettings(state.settings);
state.client = new Client(state.settings);
closeModal();
reload();
};
const check = async () => {
test.textContent = "Checking…";
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
try {
const v = await probe.call("version", {});
test.textContent = `✓ Connected (hephd ${v.version})`;
test.className = "settings-test ok";
} catch (e) {
test.textContent = `${e.message}`;
test.className = "settings-test bad";
}
};
openModal(
h(
"div",
{ class: "qa" },
h("div", { class: "modal-title" }, "Settings"),
h("label", { class: "settings-label" }, "Hub URL"),
url,
h("label", { class: "settings-label" }, "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" }, "The hub is your server-mode hephd. Leave the token blank if the hub runs 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();
}
});
render();
reload();
if ("serviceWorker" in navigator) {
try {
await navigator.serviceWorker.register("./sw.js");
} catch {
/* offline shell is best-effort */
}
}
}
init();

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

@ -0,0 +1,221 @@
// 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;
}
}

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%)`;
}

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

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

@ -0,0 +1,171 @@
// 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. */
async call(method, params = {}) {
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) throw new RpcError("Unauthorized — set or refresh your token.", 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;
}
}

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

@ -0,0 +1,504 @@
/* 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);
}
/* --- 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;
}

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

@ -0,0 +1,53 @@
// Service worker: cache the app shell so heph launches offline. Data is never
// cached — every /rpc call must hit the live hub (and POSTs aren't cacheable
// anyway). Bump CACHE when shell assets change to evict the old set.
const CACHE = "heph-pwa-v3";
const SHELL = [
"./",
"./index.html",
"./styles.css",
"./manifest.webmanifest",
"./src/app.js",
"./src/rpc.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.
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) {
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,125 @@
// 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, 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"));
});
// 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);
});