import { useEffect, useState } from "preact/hooks"; import { decodeJsonError } from "../../cardRuntime/api"; import { requestCardFeedRefresh } from "../../cardRuntime/store"; const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh"; interface InboxItem { path: string; title: string; kind: string; status: string; source: string; confidence: number | null; captured: string; updated: string; suggestedDue: string; tags: string[]; body: string; } function normalizeTaskTag(raw: string): string { const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-"); return trimmed ? `#${trimmed}` : ""; } function normalizeTaskTags(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const seen = new Set(); const tags: string[] = []; for (const value of raw) { const tag = normalizeTaskTag(String(value || "")); const key = tag.toLowerCase(); if (!tag || seen.has(key)) continue; seen.add(key); tags.push(tag); } return tags; } function normalizeInboxItem(raw: unknown): InboxItem | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const record = raw as Record; const path = typeof record.path === "string" ? record.path.trim() : ""; if (!path) return null; return { path, title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : "Untitled capture", kind: typeof record.kind === "string" ? record.kind.trim() : "unknown", status: typeof record.status === "string" ? record.status.trim() : "new", source: typeof record.source === "string" ? record.source.trim() : "unknown", confidence: typeof record.confidence === "number" && Number.isFinite(record.confidence) ? record.confidence : null, captured: typeof record.captured === "string" ? record.captured.trim() : "", updated: typeof record.updated === "string" ? record.updated.trim() : "", suggestedDue: typeof record.suggested_due === "string" ? record.suggested_due.trim() : "", tags: normalizeTaskTags(record.tags), body: typeof record.body === "string" ? record.body : "", }; } async function fetchInboxItems(limit = 4): Promise { const params = new URLSearchParams({ limit: String(limit) }); const resp = await fetch(`/inbox?${params.toString()}`, { cache: "no-store" }); if (!resp.ok) throw new Error(await decodeJsonError(resp)); const payload = (await resp.json()) as { items?: unknown }; const items = Array.isArray(payload.items) ? payload.items : []; return items.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null); } function requestInboxRefresh(): void { window.dispatchEvent(new Event(INBOX_REFRESH_EVENT)); } function extractCaptureTags(text: string): string[] { const matches = text.match(/(^|\s)#([A-Za-z0-9_-]+)/g) ?? []; return Array.from( new Set( matches .map((match) => match.trim()) .filter(Boolean) .map((tag) => normalizeTaskTag(tag)), ), ); } export async function captureInboxItem(text: string): Promise { const trimmed = text.trim(); if (!trimmed) throw new Error("capture text is required"); const resp = await fetch("/inbox/capture", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: trimmed, tags: extractCaptureTags(trimmed), source: "web-ui", }), }); if (!resp.ok) throw new Error(await decodeJsonError(resp)); const payload = (await resp.json()) as { item?: unknown }; const item = normalizeInboxItem(payload.item); if (!item) throw new Error("invalid inbox response"); requestInboxRefresh(); return item; } async function dismissInboxItem(itemPath: string): Promise { const resp = await fetch("/inbox/dismiss", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item: itemPath }), }); if (!resp.ok) throw new Error(await decodeJsonError(resp)); requestInboxRefresh(); } async function acceptInboxItemAsTask(itemPath: string): Promise { const resp = await fetch("/inbox/accept-task", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item: itemPath, lane: "backlog" }), }); if (!resp.ok) throw new Error(await decodeJsonError(resp)); requestInboxRefresh(); requestCardFeedRefresh(); } function formatInboxUpdatedAt(timestamp: string): string { const parsed = new Date(timestamp); if (Number.isNaN(parsed.getTime())) return ""; return parsed.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function summarizeInboxBody(body: string): string { return body.replace(/\r\n?/g, "\n").split("\n## Raw Capture")[0].replace(/\s+/g, " ").trim(); } function computeInboxScore(items: InboxItem[]): number { if (!items.length) return 0; const newest = items[0]; const newestMs = newest.updated ? new Date(newest.updated).getTime() : Number.NaN; const ageHours = Number.isFinite(newestMs) ? (Date.now() - newestMs) / (60 * 60 * 1000) : 999; if (ageHours <= 1) return 91; if (ageHours <= 6) return 86; if (ageHours <= 24) return 82; return 78; } export function InboxQuickAdd({ onSubmit, visible, }: { onSubmit(text: string): Promise; visible: boolean; }) { const [open, setOpen] = useState(false); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); useEffect(() => { if (!visible) { setOpen(false); setError(""); } }, [visible]); const close = () => { if (busy) return; setOpen(false); setError(""); }; const submit = async () => { const trimmed = value.trim(); if (!trimmed) return; setBusy(true); setError(""); try { await onSubmit(trimmed); setValue(""); setOpen(false); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; if (!visible) return null; return ( <> {open ? ( <>