From 4baa8e1c9d316185af39d7df6ba132c63dcbca83 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 16:59:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(heph-pwa):=20mobile=20app=20shell=20?= =?UTF-8?q?=E2=80=94=20views,=20quick-add,=20triage,=20search,=20voice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A buildless, installable PWA that mirrors heph-tui: sidebar of built-in views (tom/tasks/work/chores/ondeck/inbox) + projects, a task list with attention flags / project bullets / date chips, tap-to-expand triage (done/drop/skip/ attention/reschedule/move/delete + undo), full-text search, and a read-only context+log preview. The primary surface is the quick-add modal (FAB or Cmd-'), which live-parses the TUI syntax into preview chips and supports voice via on-device dictation / the Web Speech API. rpc.js is the online-only JSON-RPC client mirroring heph-tui's Backend; settings persist in localStorage. Service worker caches the app shell for offline launch. Verified end-to-end against a local server-mode hephd (--web-root): the app boots, calls the view RPC, and renders RankedTasks in headless Chrome. Co-Authored-By: Claude Opus 4.8 (1M context) --- heph-pwa/README.md | 63 +++ heph-pwa/icons/icon-180.png | Bin 0 -> 3397 bytes heph-pwa/icons/icon-192.png | Bin 0 -> 3663 bytes heph-pwa/icons/icon-512.png | Bin 0 -> 10449 bytes heph-pwa/icons/icon-maskable.png | Bin 0 -> 4260 bytes heph-pwa/icons/icon.svg | 14 + heph-pwa/index.html | 24 + heph-pwa/manifest.webmanifest | 17 + heph-pwa/src/app.js | 800 +++++++++++++++++++++++++++++++ heph-pwa/src/fmt.js | 71 +++ heph-pwa/src/rpc.js | 163 +++++++ heph-pwa/styles.css | 504 +++++++++++++++++++ heph-pwa/sw.js | 53 ++ 13 files changed, 1709 insertions(+) create mode 100644 heph-pwa/README.md create mode 100644 heph-pwa/icons/icon-180.png create mode 100644 heph-pwa/icons/icon-192.png create mode 100644 heph-pwa/icons/icon-512.png create mode 100644 heph-pwa/icons/icon-maskable.png create mode 100644 heph-pwa/icons/icon.svg create mode 100644 heph-pwa/index.html create mode 100644 heph-pwa/manifest.webmanifest create mode 100644 heph-pwa/src/app.js create mode 100644 heph-pwa/src/fmt.js create mode 100644 heph-pwa/src/rpc.js create mode 100644 heph-pwa/styles.css create mode 100644 heph-pwa/sw.js 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 0000000000000000000000000000000000000000..dfa2b2a4fe4fa2c5cb2e87d641a8dad05e0bd6d7 GIT binary patch literal 3397 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�?%fAXQrG?Q1{zR_oyt0k{2&WBJR94Ov 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 C2^N_E literal 0 HcmV?d00001 diff --git a/heph-pwa/icons/icon-maskable.png b/heph-pwa/icons/icon-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..ec5c299a7b4fbad4195c5c974373c77837fa46d8 GIT binary patch literal 4260 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 literal 0 HcmV?d00001 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..6acc2a9 --- /dev/null +++ b/heph-pwa/src/app.js @@ -0,0 +1,800 @@ +// 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); + 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/fmt.js b/heph-pwa/src/fmt.js new file mode 100644 index 0000000..a3b98ad --- /dev/null +++ b/heph-pwa/src/fmt.js @@ -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 (0–359) 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%)`; +} diff --git a/heph-pwa/src/rpc.js b/heph-pwa/src/rpc.js new file mode 100644 index 0000000..cb8c6d9 --- /dev/null +++ b/heph-pwa/src/rpc.js @@ -0,0 +1,163 @@ +// 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() { + try { + const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"); + return { baseUrl: s.baseUrl || "", token: s.token || "" }; + } catch { + return { baseUrl: "", 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; + } +} diff --git a/heph-pwa/styles.css b/heph-pwa/styles.css new file mode 100644 index 0000000..ed64983 --- /dev/null +++ b/heph-pwa/styles.css @@ -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; +} diff --git a/heph-pwa/sw.js b/heph-pwa/sw.js new file mode 100644 index 0000000..fef89ff --- /dev/null +++ b/heph-pwa/sw.js @@ -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-v1"; +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")), + ), + ); +});