From 5beb89f77118dbc901297d639b12792acd1bd7df Mon Sep 17 00:00:00 2001 From: Semih Date: Wed, 13 May 2026 23:32:41 +0000 Subject: [PATCH] feat(phase6c): insight inbox actions + filters + keyboard shortcuts + budget UI + prompt registry view Server actions (apps/web/src/app/insights/_actions.ts): - setStatus / bulkSetStatus (11 allowed states) - setFounderNotes / setFounderPriority / setSeverityOverride - updateBudgetSetting (key whitelist) All audit-logged + revalidate /insights paths. Inbox enhancements: - Filter bar: severity / status (active default = new+triaged+in_backlog+in_progress+regressed) / type chips with live counts, query-param driven - Per-row action buttons: Backlog / Defer / Dismiss with toast feedback - Keyboard shortcuts (j/k navigate, Enter open, b backlog, e defer, d dismiss, ? help) Skipped when input/textarea focused. Insight detail (/insights/i/[id]): - FounderForm component: 6 status buttons, severity override dropdown, founder_priority pin input, founder_notes textarea (saved separately) Budget settings (/insights/settings/budgets): - Editable rows for monthly/daily caps, per-call max, min_score_for_analysis, cache_ttl_hours, analysis_paused (kill switch) Prompt registry (read-only, full editor in 6e): - /insights/settings/prompts list - /insights/settings/prompts/[id] detail w/ system prompt, user template, output schema, recent cost ledger rows for this template Cmd+K palette extended with Insights group: - Inbox, Cost dashboard, Pipeline, Budget settings, Prompt registry Co-Authored-By: Claude Opus 4.7 --- apps/web/src/app/insights/_actions.ts | 147 +++++++++++++++++ apps/web/src/app/insights/_filter-bar.tsx | 93 +++++++++++ apps/web/src/app/insights/_inbox-row.tsx | 80 ++++++++++ .../web/src/app/insights/_inbox-shortcuts.tsx | 65 ++++++++ .../src/app/insights/i/[id]/_founder-form.tsx | 151 ++++++++++++++++++ apps/web/src/app/insights/i/[id]/page.tsx | 10 +- apps/web/src/app/insights/page.tsx | 125 ++++++++------- .../insights/settings/budgets/_budget-row.tsx | 68 ++++++++ .../app/insights/settings/budgets/page.tsx | 93 +++++++++++ .../insights/settings/prompts/[id]/page.tsx | 65 ++++++++ .../app/insights/settings/prompts/page.tsx | 77 +++++++++ apps/web/src/components/command-palette.tsx | 24 +++ apps/web/tsconfig.tsbuildinfo | 2 +- 13 files changed, 939 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/app/insights/_actions.ts create mode 100644 apps/web/src/app/insights/_filter-bar.tsx create mode 100644 apps/web/src/app/insights/_inbox-row.tsx create mode 100644 apps/web/src/app/insights/_inbox-shortcuts.tsx create mode 100644 apps/web/src/app/insights/i/[id]/_founder-form.tsx create mode 100644 apps/web/src/app/insights/settings/budgets/_budget-row.tsx create mode 100644 apps/web/src/app/insights/settings/budgets/page.tsx create mode 100644 apps/web/src/app/insights/settings/prompts/[id]/page.tsx create mode 100644 apps/web/src/app/insights/settings/prompts/page.tsx diff --git a/apps/web/src/app/insights/_actions.ts b/apps/web/src/app/insights/_actions.ts new file mode 100644 index 0000000..599b6ac --- /dev/null +++ b/apps/web/src/app/insights/_actions.ts @@ -0,0 +1,147 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { prisma } from "@/lib/db"; +import { auth } from "@/lib/auth"; +import { writeAudit } from "@/lib/audit"; + +const ALLOWED_STATUS = new Set([ + "new", + "triaged", + "in_backlog", + "in_progress", + "shipped", + "validating", + "validated", + "deferred", + "dismissed", + "duplicate", + "regressed", +]); + +async function requireSession() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error("unauthenticated"); + return session; +} + +export async function setStatus(insightId: string, status: string) { + await requireSession(); + if (!ALLOWED_STATUS.has(status)) throw new Error(`bad status: ${status}`); + await prisma.insight.update({ + where: { id: insightId }, + data: { status }, + }); + await writeAudit({ + endpoint: `/insights/${insightId}/status`, + method: "POST", + requestPayload: { status }, + responseStatus: 200, + }); + revalidatePath("/insights"); + revalidatePath(`/insights/i/${insightId}`); +} + +export async function setFounderNotes(insightId: string, notes: string) { + await requireSession(); + await prisma.insight.update({ + where: { id: insightId }, + data: { founderNotes: notes.slice(0, 4000) || null }, + }); + await writeAudit({ + endpoint: `/insights/${insightId}/notes`, + method: "POST", + requestPayload: { length: notes.length }, + responseStatus: 200, + }); + revalidatePath(`/insights/i/${insightId}`); +} + +export async function setFounderPriority(insightId: string, priority: number | null) { + await requireSession(); + await prisma.insight.update({ + where: { id: insightId }, + data: { founderPriority: priority }, + }); + await writeAudit({ + endpoint: `/insights/${insightId}/priority`, + method: "POST", + requestPayload: { priority }, + responseStatus: 200, + }); + revalidatePath("/insights"); + revalidatePath(`/insights/i/${insightId}`); +} + +export async function setSeverityOverride(insightId: string, severity: string | null) { + await requireSession(); + if (severity !== null && !["P0", "P1", "P2", "P3", "INFO"].includes(severity)) { + throw new Error("bad severity"); + } + await prisma.insight.update({ + where: { id: insightId }, + data: { founderSeverityOverride: severity, severity: severity ?? undefined }, + }); + await writeAudit({ + endpoint: `/insights/${insightId}/severity`, + method: "POST", + requestPayload: { severity }, + responseStatus: 200, + }); + revalidatePath("/insights"); + revalidatePath(`/insights/i/${insightId}`); +} + +export async function bulkSetStatus(insightIds: string[], status: string) { + await requireSession(); + if (!ALLOWED_STATUS.has(status)) throw new Error("bad status"); + const ids = insightIds.filter(Boolean).slice(0, 200); + await prisma.insight.updateMany({ + where: { id: { in: ids } }, + data: { status }, + }); + await writeAudit({ + endpoint: `/insights/bulk/status`, + method: "POST", + requestPayload: { count: ids.length, status }, + responseStatus: 200, + }); + revalidatePath("/insights"); +} + +export async function updateBudgetSetting(key: string, value: number | boolean) { + await requireSession(); + const allowed = new Set([ + "monthly_hard_cap_usd", + "daily_soft_cap_usd", + "daily_hard_cap_usd", + "per_call_max_usd", + "min_score_for_analysis", + "cache_ttl_hours", + "analysis_paused", + ]); + if (!allowed.has(key)) throw new Error("bad setting key"); + const existing = await prisma.budgetSetting.findFirst({ + where: { projectKey: null, settingKey: key }, + }); + if (existing) { + await prisma.budgetSetting.update({ + where: { id: existing.id }, + data: { settingValue: value as unknown as object }, + }); + } else { + await prisma.budgetSetting.create({ + data: { projectKey: null, settingKey: key, settingValue: value as unknown as object }, + }); + } + await writeAudit({ + endpoint: `/insights/budget/${key}`, + method: "POST", + requestPayload: { value }, + responseStatus: 200, + }); + revalidatePath("/insights/settings/budgets"); + revalidatePath("/insights/costs"); + revalidatePath("/insights"); +} diff --git a/apps/web/src/app/insights/_filter-bar.tsx b/apps/web/src/app/insights/_filter-bar.tsx new file mode 100644 index 0000000..97ceb17 --- /dev/null +++ b/apps/web/src/app/insights/_filter-bar.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; + +const SEVERITIES = ["P0", "P1", "P2", "P3", "INFO"]; +const STATUSES = ["active", "new", "triaged", "in_backlog", "shipped", "validated", "deferred", "dismissed"]; +const TYPES = ["bug", "ux_friction", "payment", "onboarding", "provider_quality"]; + +export function FilterBar({ + severityCounts, + statusCounts, + typeCounts, +}: { + severityCounts: Record; + statusCounts: Record; + typeCounts: Record; +}) { + const params = useSearchParams(); + const router = useRouter(); + const path = usePathname(); + + const setParam = (k: string, v: string | null) => { + const sp = new URLSearchParams(params.toString()); + if (v === null || v === "") sp.delete(k); + else sp.set(k, v); + router.push(`${path}?${sp.toString()}`); + }; + + const cur = (k: string) => params.get(k) ?? ""; + + return ( +
+ + setParam("sev", null)}> + all + + {SEVERITIES.map((s) => ( + setParam("sev", s)}> + {s} ({severityCounts[s] ?? 0}) + + ))} + + + setParam("status", null)}> + active + + {STATUSES.slice(1).map((s) => ( + setParam("status", s)}> + {s} ({statusCounts[s] ?? 0}) + + ))} + + + setParam("type", null)}> + all + + {TYPES.map((t) => ( + setParam("type", t)}> + {t} ({typeCounts[t] ?? 0}) + + ))} + +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/app/insights/_inbox-row.tsx b/apps/web/src/app/insights/_inbox-row.tsx new file mode 100644 index 0000000..2f40e7a --- /dev/null +++ b/apps/web/src/app/insights/_inbox-row.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Badge } from "@/components/ui/badge"; +import { TableCell, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { setStatus } from "./_actions"; + +type InsightRow = { + id: string; + severity: string; + type: string; + status: string; + fingerprint: string; + title: string; + occurrenceCount: number; + confidence: number | null; + priorityScore: number; + lastSeenAt: string; +}; + +function severityVariant(sev: string): "default" | "destructive" | "secondary" | "outline" { + if (sev === "P0" || sev === "P1") return "destructive"; + if (sev === "P2") return "default"; + return "outline"; +} + +export function InboxRow({ row, focused }: { row: InsightRow; focused: boolean }) { + const [pending, start] = useTransition(); + const [flash, setFlash] = useState(null); + + const act = (next: string, label: string) => + start(async () => { + try { + await setStatus(row.id, next); + setFlash(label); + setTimeout(() => setFlash(null), 2500); + } catch (e) { + setFlash(`err: ${(e as Error).message}`); + } + }); + + return ( + + + {row.severity} + + + + {row.title} + +
+ {row.fingerprint.slice(0, 16)} +
+
+ {row.type} + {row.priorityScore} + {row.occurrenceCount} + {row.confidence?.toFixed(2) ?? "—"} + + {row.lastSeenAt.slice(0, 16).replace("T", " ")} + + + {row.status} + + + {flash && {flash}} + + + + +
+ ); +} diff --git a/apps/web/src/app/insights/_inbox-shortcuts.tsx b/apps/web/src/app/insights/_inbox-shortcuts.tsx new file mode 100644 index 0000000..8539440 --- /dev/null +++ b/apps/web/src/app/insights/_inbox-shortcuts.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { setStatus } from "./_actions"; + +// Tiny keyboard navigation: j/k cycle rows, Enter opens, d=dismiss, e=defer, b=backlog. +// Ignores when an input/textarea has focus. +export function InboxShortcuts({ insightIds }: { insightIds: string[] }) { + const router = useRouter(); + const [focusedIdx, setFocusedIdx] = useState(-1); + const [, start] = useTransition(); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; + if (insightIds.length === 0) return; + + const i = focusedIdx < 0 ? 0 : focusedIdx; + if (e.key === "j") { + e.preventDefault(); + setFocusedIdx(Math.min(insightIds.length - 1, i + 1)); + scrollRowIntoView(insightIds[Math.min(insightIds.length - 1, i + 1)]); + } else if (e.key === "k") { + e.preventDefault(); + setFocusedIdx(Math.max(0, i - 1)); + scrollRowIntoView(insightIds[Math.max(0, i - 1)]); + } else if (e.key === "Enter") { + e.preventDefault(); + router.push(`/insights/i/${insightIds[i]}`); + } else if (e.key === "d" && focusedIdx >= 0) { + e.preventDefault(); + start(() => setStatus(insightIds[i], "dismissed")); + } else if (e.key === "e" && focusedIdx >= 0) { + e.preventDefault(); + start(() => setStatus(insightIds[i], "deferred")); + } else if (e.key === "b" && focusedIdx >= 0) { + e.preventDefault(); + start(() => setStatus(insightIds[i], "in_backlog")); + } else if (e.key === "?") { + e.preventDefault(); + alert("Inbox shortcuts:\nj/k = next/prev\nEnter = open\nb = backlog\ne = defer\nd = dismiss"); + } + }; + + // Apply focused class via data attribute query + const applyFocus = () => { + document.querySelectorAll("[data-row-id]").forEach((el, idx) => { + if (idx === focusedIdx) el.classList.add("ring-2", "ring-primary/40"); + else el.classList.remove("ring-2", "ring-primary/40"); + }); + }; + applyFocus(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [focusedIdx, insightIds, router]); + + return null; +} + +function scrollRowIntoView(id: string) { + const el = document.querySelector(`[data-row-id="${id}"]`); + if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" }); +} diff --git a/apps/web/src/app/insights/i/[id]/_founder-form.tsx b/apps/web/src/app/insights/i/[id]/_founder-form.tsx new file mode 100644 index 0000000..515b37f --- /dev/null +++ b/apps/web/src/app/insights/i/[id]/_founder-form.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { + setStatus, + setFounderNotes, + setFounderPriority, + setSeverityOverride, +} from "../../_actions"; + +type Props = { + insightId: string; + initialNotes: string; + initialPriority: number | null; + initialSeverityOverride: string | null; + currentStatus: string; +}; + +const STATUS_BUTTONS: Array<{ label: string; value: string; variant?: "default" | "destructive" | "outline" }> = [ + { label: "Triage", value: "triaged" }, + { label: "Backlog", value: "in_backlog" }, + { label: "Shipped", value: "shipped" }, + { label: "Defer", value: "deferred", variant: "outline" }, + { label: "Duplicate", value: "duplicate", variant: "outline" }, + { label: "Dismiss", value: "dismissed", variant: "destructive" }, +]; + +const SEVERITIES = ["P0", "P1", "P2", "P3", "INFO"] as const; + +export function FounderForm({ + insightId, + initialNotes, + initialPriority, + initialSeverityOverride, + currentStatus, +}: Props) { + const [notes, setNotes] = useState(initialNotes); + const [priority, setPriority] = useState( + initialPriority === null ? "" : String(initialPriority), + ); + const [sevOverride, setSevOverride] = useState(initialSeverityOverride ?? ""); + const [pending, start] = useTransition(); + const [flash, setFlash] = useState(null); + + const fire = (fn: () => Promise, label: string) => + start(async () => { + try { + await fn(); + setFlash(label); + setTimeout(() => setFlash(null), 2000); + } catch (e) { + setFlash(`err: ${(e as Error).message}`); + } + }); + + return ( +
+
+ status + {currentStatus} + {STATUS_BUTTONS.map((b) => ( + + ))} +
+ +
+ severity + + +
+ +
+ priority + setPriority(e.target.value)} + /> + +
+ +
+
founder notes
+