hephaestus/heph-pwa/src/app.js
Erich Blume 9a487cbe3b
All checks were successful
Build / validate (pull_request) Successful in 6m57s
feat(heph-tui,heph-pwa): humanized recurrence + indented/counted/scrolling project sidebar
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side;
no schema or sync change (see hub-spoke-data-evolution).

- humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an
  RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back
  to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in
  the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa
  previews instead of the raw FREQ= string.
- project.overview RPC + Store::project_overview: each project's parent (via the
  existing 'parent' links) and direct outstanding-task count, a read-only query.
- TUI sidebar: subprojects indented by depth, per-project counts, wider pane,
  and ListState + scrollbar so it scrolls instead of clipping on overflow.

Tests: humanize parity (Rust + JS), round-trip through parse_recurrence,
raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle
safety.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:44:43 -07:00

895 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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