diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index ad3517c..fde7d57 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -60,6 +60,12 @@ struct Cli { #[arg(long)] http_addr: Option, + /// 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, + /// Server to proxy to (client mode only; required there). #[arg(long)] server_url: Option, @@ -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()); } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 266cae1..41e5524 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -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>; struct HubState { store: SharedStore, verifier: Option>, + /// 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, } /// 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>) -> 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>, + web_root: Option, +) -> 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, 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( diff --git a/crates/hephd/tests/web_serve.rs b/crates/hephd/tests/web_serve.rs new file mode 100644 index 0000000..b176137 --- /dev/null +++ b/crates/hephd/tests/web_serve.rs @@ -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) -> 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"), + "heph", + ) + .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("heph")); + 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("heph")); + + // 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("*")); +} diff --git a/docs/changelog.d/feature-heph-pwa-mobile.feature.md b/docs/changelog.d/feature-heph-pwa-mobile.feature.md new file mode 100644 index 0000000..3dffb02 --- /dev/null +++ b/docs/changelog.d/feature-heph-pwa-mobile.feature.md @@ -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. diff --git a/docs/how-to/heph-pwa.md b/docs/how-to/heph-pwa.md new file mode 100644 index 0000000..2a158e9 --- /dev/null +++ b/docs/how-to/heph-pwa.md @@ -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://: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 diff --git a/docs/how-to/host-heph-pwa.md b/docs/how-to/host-heph-pwa.md new file mode 100644 index 0000000..0be1d59 --- /dev/null +++ b/docs/how-to/host-heph-pwa.md @@ -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 ` 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-.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 +``` + +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..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..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 diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index eb0a6c8..c20c904 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -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 diff --git a/heph-pwa/README.md b/heph-pwa/README.md new file mode 100644 index 0000000..197d86a --- /dev/null +++ b/heph-pwa/README.md @@ -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://: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. diff --git a/heph-pwa/icons/icon-180.png b/heph-pwa/icons/icon-180.png new file mode 100644 index 0000000..dfa2b2a Binary files /dev/null and b/heph-pwa/icons/icon-180.png differ diff --git a/heph-pwa/icons/icon-192.png b/heph-pwa/icons/icon-192.png new file mode 100644 index 0000000..571bc1e Binary files /dev/null and b/heph-pwa/icons/icon-192.png differ diff --git a/heph-pwa/icons/icon-512.png b/heph-pwa/icons/icon-512.png new file mode 100644 index 0000000..8ac6bbc Binary files /dev/null and b/heph-pwa/icons/icon-512.png differ diff --git a/heph-pwa/icons/icon-maskable.png b/heph-pwa/icons/icon-maskable.png new file mode 100644 index 0000000..ec5c299 Binary files /dev/null and b/heph-pwa/icons/icon-maskable.png differ diff --git a/heph-pwa/icons/icon.svg b/heph-pwa/icons/icon.svg new file mode 100644 index 0000000..a0beeed --- /dev/null +++ b/heph-pwa/icons/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/heph-pwa/index.html b/heph-pwa/index.html new file mode 100644 index 0000000..1943625 --- /dev/null +++ b/heph-pwa/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + heph + + + + + + +
+ + + + diff --git a/heph-pwa/manifest.webmanifest b/heph-pwa/manifest.webmanifest new file mode 100644 index 0000000..d5b14c8 --- /dev/null +++ b/heph-pwa/manifest.webmanifest @@ -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" } + ] +} diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js new file mode 100644 index 0000000..f06ba06 --- /dev/null +++ b/heph-pwa/src/app.js @@ -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" }, "p1–p4 · #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(); diff --git a/heph-pwa/src/datespec.js b/heph-pwa/src/datespec.js new file mode 100644 index 0000000..7c2986a --- /dev/null +++ b/heph-pwa/src/datespec.js @@ -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 + * , every other , every workday, every , + * every . A trailing "at