commit 40529c599182c6d86504871af11608f526e6eca7 Author: FoundingEngineer (Paperclip) Date: Sat Jun 27 04:00:59 2026 +0200 feat: initial miq.stats-plugin implementation Operating-numbers view plugin (MIQ-1734): per-agent load, in-flight per project, pending-decision age, 7-day throughput, and budget tile. Co-Authored-By: Paperclip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..813947a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.paperclip-sdk/ +*.tgz diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4cba972 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@paperclipai:registry=file:.paperclip-sdk/ diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..b5cfd36 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..646a72e --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "@miq/stats-plugin", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Operating-numbers view: per-agent load, in-flight per project, pending-decision age, 7-day throughput, and budget summary.", + "scripts": { + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "dev": "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4179", + "test": "NODE_ENV=development vitest run --config ./vitest.config.ts", + "typecheck": "tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "ui", + "stats", + "operating-numbers" + ], + "author": "MIQ", + "license": "MIT", + "devDependencies": { + "@paperclipai/plugin-sdk": "file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz", + "@paperclipai/shared": "file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.2.3", + "esbuild": "^0.27.3", + "jsdom": "^29.1.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..5cd3671 --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,45 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: "miq.stats-plugin", + apiVersion: 1, + version: "0.1.0", + displayName: "MIQ Stats", + description: + "Operating-numbers view: per-agent load, in-flight per project, pending-decision age, last-7-days throughput, and budget summary.", + author: "MIQ", + categories: ["ui"], + capabilities: [ + "ui.page.register", + "ui.sidebar.register", + "events.subscribe", + "issues.read", + "agents.read", + "projects.read", + "costs.read", + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "page", + id: "stats-page", + displayName: "Stats", + exportName: "StatsPage", + routePath: "stats", + }, + { + type: "sidebar", + id: "stats-sidebar", + displayName: "Stats", + exportName: "StatsSidebar", + order: -9, + }, + ], + }, +}; + +export default manifest; diff --git a/src/ui/index.tsx b/src/ui/index.tsx new file mode 100644 index 0000000..2ae3c25 --- /dev/null +++ b/src/ui/index.tsx @@ -0,0 +1,714 @@ +import { useEffect, useState } from "react"; +import { + useHostContext, + usePluginStream, + type PluginPageProps, + type PluginSidebarProps, +} from "@paperclipai/plugin-sdk/ui"; + +// ── types ──────────────────────────────────────────────────────────────────── + +type IssueRow = { + id: string; + identifier: string; + title: string; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + projectId: string | null; + updatedAt: string; + lastActivityAt: string | null; +}; + +type AgentRow = { + id: string; + name: string; + role: string | null; + title: string | null; +}; + +type ProjectRow = { + id: string; + name: string; + urlKey: string | null; +}; + +type DashboardData = { + costs?: { + monthSpendCents: number; + monthBudgetCents: number; + monthUtilizationPercent: number; + }; + tasks?: { + open: number; + inProgress: number; + blocked: number; + done: number; + }; +}; + +const IN_FLIGHT_STATUSES = "todo,in_progress,in_review,blocked"; +const STREAM_NAME = "stats-refresh"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +async function jsonFetch(url: string, signal?: AbortSignal): Promise { + const res = await fetch(url, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`${url} → ${res.status}: ${text.slice(0, 200)}`); + } + return (await res.json()) as T; +} + +function formatRelative(iso: string | null): string { + if (!iso) return ""; + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return iso; + const delta = Date.now() - ts; + const sec = Math.round(delta / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + const d = Math.round(hr / 24); + if (d < 30) return `${d}d ago`; + return new Date(ts).toLocaleDateString(); +} + +function formatDuration(iso: string | null): string { + if (!iso) return ""; + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return ""; + const delta = Date.now() - ts; + const min = Math.round(delta / 60000); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h`; + const d = Math.round(hr / 24); + return `${d}d`; +} + +function formatCents(cents: number): string { + return `€${(cents / 100).toFixed(2)}`; +} + +// ── shared styles ───────────────────────────────────────────────────────────── + +function panelStyle(small?: boolean): React.CSSProperties { + return { + border: "1px solid var(--pc-border, #e5e7eb)", + borderRadius: 8, + padding: small ? "10px 14px" : "12px 16px", + background: "var(--pc-bg-panel, transparent)", + }; +} + +function rowStyle(): React.CSSProperties { + return { + display: "flex", + alignItems: "baseline", + gap: 8, + padding: "5px 0", + borderBottom: "1px solid var(--pc-border-subtle, #f3f4f6)", + }; +} + +function emptyHint(text: string): React.ReactElement { + return ( +
+ {text} +
+ ); +} + +function badge(label: string, color: string): React.ReactElement { + return ( + + {label} + + ); +} + +function countBadge(n: number, color?: string): React.ReactElement { + const c = color ?? "#374151"; + return ( + + {n} + + ); +} + +function sectionTitle(text: string, small?: boolean): React.ReactElement { + return ( +

+ {text} +

+ ); +} + +// ── Panel 1: Per-agent load ──────────────────────────────────────────────── + +export function AgentLoadPanel({ + companyId, + refreshTick, +}: { + companyId: string | null; + refreshTick: number; +}): React.ReactElement { + const [issues, setIssues] = useState(null); + const [agents, setAgents] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!companyId) return; + const ctl = new AbortController(); + setError(null); + Promise.all([ + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/issues?status=${IN_FLIGHT_STATUSES}&limit=500`, + ctl.signal, + ), + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/agents?limit=100`, + ctl.signal, + ), + ]) + .then(([iss, ags]) => { + setIssues(Array.isArray(iss) ? iss : []); + setAgents(Array.isArray(ags) ? ags : []); + }) + .catch((e: unknown) => { + if ((e as Error).name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }); + return () => ctl.abort(); + }, [companyId, refreshTick]); + + const agentMap = new Map((agents ?? []).map((a) => [a.id, a])); + + const byAgent = new Map(); + for (const issue of issues ?? []) { + const key = issue.assigneeAgentId; + if (!byAgent.has(key)) byAgent.set(key, []); + byAgent.get(key)!.push(issue); + } + + const rows = [...byAgent.entries()] + .sort((a, b) => (b[1].length - a[1].length)) + .map(([agentId, issueList]) => { + const agent = agentId ? agentMap.get(agentId) : null; + const label = agent?.name ?? agent?.title ?? (agentId ? agentId.slice(0, 8) + "…" : "Unassigned"); + return { agentId, label, count: issueList.length }; + }); + + const maxCount = rows[0]?.count ?? 1; + + return ( +
+ {sectionTitle("Work in flight — by agent")} + {!companyId ? ( + emptyHint("No active company.") + ) : error ? ( +
Failed to load: {error}
+ ) : issues === null ? ( +
Loading…
+ ) : rows.length === 0 ? ( + emptyHint("No in-flight issues.") + ) : ( +
+ {rows.map(({ agentId, label, count }) => ( +
+ {label} +
+
= 10 ? "#b91c1c" : count >= 5 ? "#d97706" : "#0369a1", + borderRadius: 4, + transition: "width 0.3s", + }} + /> +
+ {countBadge(count, count >= 10 ? "#b91c1c" : count >= 5 ? "#d97706" : "#374151")} +
+ ))} +
+ )} +
+ ); +} + +// ── Panel 2: In-flight per project ───────────────────────────────────────── + +export function ProjectInflightPanel({ + companyId, + companyPrefix, + refreshTick, +}: { + companyId: string | null; + companyPrefix: string | null; + refreshTick: number; +}): React.ReactElement { + const [issues, setIssues] = useState(null); + const [projects, setProjects] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!companyId) return; + const ctl = new AbortController(); + setError(null); + Promise.all([ + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/issues?status=${IN_FLIGHT_STATUSES}&limit=500`, + ctl.signal, + ), + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/projects?limit=100`, + ctl.signal, + ), + ]) + .then(([iss, prj]) => { + setIssues(Array.isArray(iss) ? iss : []); + setProjects(Array.isArray(prj) ? prj : []); + }) + .catch((e: unknown) => { + if ((e as Error).name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }); + return () => ctl.abort(); + }, [companyId, refreshTick]); + + const projectMap = new Map((projects ?? []).map((p) => [p.id, p])); + + const byProject = new Map(); + for (const issue of issues ?? []) { + const key = issue.projectId; + if (!byProject.has(key)) byProject.set(key, []); + byProject.get(key)!.push(issue); + } + + const rows = [...byProject.entries()] + .sort((a, b) => b[1].length - a[1].length) + .map(([projectId, issueList]) => { + const proj = projectId ? projectMap.get(projectId) : null; + const label = proj?.name ?? (projectId ? projectId.slice(0, 8) + "…" : "No project"); + const href = + proj?.urlKey && companyPrefix + ? `/${companyPrefix}/projects/${proj.urlKey}` + : null; + return { projectId, label, href, count: issueList.length }; + }); + + const maxCount = rows[0]?.count ?? 1; + + return ( +
+ {sectionTitle("Active issues — by project")} + {!companyId ? ( + emptyHint("No active company.") + ) : error ? ( +
Failed to load: {error}
+ ) : issues === null ? ( +
Loading…
+ ) : rows.length === 0 ? ( + emptyHint("No in-flight issues.") + ) : ( +
+ {rows.map(({ projectId, label, href, count }) => ( +
+ {href ? ( + + {label} + + ) : ( + + {label} + + )} +
+
+
+ {countBadge(count, "#7c3aed")} +
+ ))} +
+ )} +
+ ); +} + +// ── Panel 3: Pending-decision age ────────────────────────────────────────── + +export function PendingDecisionAgePanel({ + companyId, + companyPrefix, + refreshTick, +}: { + companyId: string | null; + companyPrefix: string | null; + refreshTick: number; +}): React.ReactElement { + const [issues, setIssues] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!companyId) return; + const ctl = new AbortController(); + setError(null); + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/issues?status=in_review&limit=200`, + ctl.signal, + ) + .then((data) => setIssues(Array.isArray(data) ? data : [])) + .catch((e: unknown) => { + if ((e as Error).name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }); + return () => ctl.abort(); + }, [companyId, refreshTick]); + + const sorted = (issues ?? []).slice().sort((a, b) => { + const ta = Date.parse(a.updatedAt); + const tb = Date.parse(b.updatedAt); + return ta - tb; + }); + + function ageColor(iso: string): string { + const hours = (Date.now() - Date.parse(iso)) / 3600000; + if (hours > 72) return "#b91c1c"; + if (hours > 24) return "#d97706"; + return "#059669"; + } + + return ( +
+ {sectionTitle("Pending decisions — in_review age")} + {!companyId ? ( + emptyHint("No active company.") + ) : error ? ( +
Failed to load: {error}
+ ) : issues === null ? ( +
Loading…
+ ) : sorted.length === 0 ? ( + emptyHint("No issues awaiting review. ✓") + ) : ( +
+ {sorted.map((issue) => { + const href = companyPrefix + ? `/${companyPrefix}/issues/${issue.identifier}` + : `/issues/${issue.identifier}`; + const color = ageColor(issue.updatedAt); + const age = formatDuration(issue.updatedAt); + return ( +
+ + {issue.identifier} + + + {issue.title} + + {badge(age, color)} +
+ ); + })} +
+ )} +
+ ); +} + +// ── Panel 4: Last-7-days throughput ──────────────────────────────────────── + +export function ThroughputPanel({ + companyId, + refreshTick, +}: { + companyId: string | null; + refreshTick: number; +}): React.ReactElement { + const [issues, setIssues] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!companyId) return; + const ctl = new AbortController(); + setError(null); + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/issues?status=done&limit=500`, + ctl.signal, + ) + .then((data) => setIssues(Array.isArray(data) ? data : [])) + .catch((e: unknown) => { + if ((e as Error).name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }); + return () => ctl.abort(); + }, [companyId, refreshTick]); + + const sevenDaysAgo = Date.now() - 7 * 24 * 3600000; + const recent = (issues ?? []).filter((i) => { + const ts = Date.parse(i.lastActivityAt ?? i.updatedAt); + return Number.isFinite(ts) && ts >= sevenDaysAgo; + }); + + const byDay = new Map(); + for (const issue of recent) { + const d = new Date(Date.parse(issue.lastActivityAt ?? issue.updatedAt)).toISOString().slice(0, 10); + byDay.set(d, (byDay.get(d) ?? 0) + 1); + } + + const days: string[] = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(Date.now() - i * 24 * 3600000).toISOString().slice(0, 10); + days.push(d); + } + + const maxDay = Math.max(...days.map((d) => byDay.get(d) ?? 0), 1); + const rate = recent.length / 7; + + return ( +
+ {sectionTitle("Throughput — last 7 days")} + {!companyId ? ( + emptyHint("No active company.") + ) : error ? ( +
Failed to load: {error}
+ ) : issues === null ? ( +
Loading…
+ ) : ( +
+
+ {recent.length} + + issues completed  ·  {rate.toFixed(1)}/day avg + +
+
+ {days.map((d) => { + const count = byDay.get(d) ?? 0; + const h = Math.max(4, (count / maxDay) * 44); + return ( +
+
0 ? "#059669" : "var(--pc-border, #e5e7eb)", + borderRadius: 2, + }} + title={`${d}: ${count} done`} + /> + + {d.slice(8)} + +
+ ); + })} +
+
+ )} +
+ ); +} + +// ── Panel 5: Budget (de-emphasized) ──────────────────────────────────────── + +export function BudgetPanel({ + companyId, + refreshTick, +}: { + companyId: string | null; + refreshTick: number; +}): React.ReactElement { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!companyId) return; + const ctl = new AbortController(); + setError(null); + jsonFetch( + `/api/companies/${encodeURIComponent(companyId)}/dashboard`, + ctl.signal, + ) + .then((d) => setData(d)) + .catch((e: unknown) => { + if ((e as Error).name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }); + return () => ctl.abort(); + }, [companyId, refreshTick]); + + const costs = data?.costs; + const pct = costs?.monthUtilizationPercent ?? 0; + const barColor = pct >= 90 ? "#b91c1c" : pct >= 70 ? "#d97706" : "#059669"; + + return ( +
+ {sectionTitle("Budget — this month", true)} + {!companyId ? ( + emptyHint("No active company.") + ) : error ? ( +
Failed to load: {error}
+ ) : data === null ? ( +
Loading…
+ ) : !costs ? ( + emptyHint("No budget data.") + ) : ( +
+
+ {formatCents(costs.monthSpendCents)} + {costs.monthBudgetCents > 0 && ( + + of {formatCents(costs.monthBudgetCents)} budget + + )} + {costs.monthBudgetCents > 0 && ( + + {pct.toFixed(0)}% + + )} +
+ {costs.monthBudgetCents > 0 && ( +
+
+
+ )} +
+ )} +
+ ); +} + +// ── Page root ───────────────────────────────────────────────────────────────── + +export function StatsPage(_props: PluginPageProps): React.ReactElement { + const host = useHostContext(); + const stream = usePluginStream<{ reason: string; at: string }>(STREAM_NAME, { + companyId: host.companyId ?? undefined, + }); + const [refreshTick, setRefreshTick] = useState(0); + + useEffect(() => { + if (stream.events.length > 0) { + setRefreshTick((t) => t + 1); + } + }, [stream.events.length]); + + return ( +
+
+

Operating Numbers

+

+ Live MIQ operating metrics: load, backlog, decision latency, and throughput. +

+
+ +
+ + +
+ + + + + +
+ +
+
+
+ ); +} + +// ── Sidebar entry ────────────────────────────────────────────────────────── + +export function StatsSidebar(_props: PluginSidebarProps): React.ReactElement { + const host = useHostContext(); + const href = host.companyPrefix ? `/${host.companyPrefix}/stats` : "/stats"; + return ( + + + Stats + + ); +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..6f294af --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,38 @@ +import { definePlugin, runWorker, type PluginEvent } from "@paperclipai/plugin-sdk"; + +const STREAM_CHANNEL = "stats-refresh"; + +const plugin = definePlugin({ + async setup(ctx) { + const openedForCompany = new Set(); + + function notify(reason: string, event: PluginEvent) { + const companyId = event.companyId; + if (!companyId) return; + if (!openedForCompany.has(companyId)) { + ctx.streams.open(STREAM_CHANNEL, companyId); + openedForCompany.add(companyId); + } + ctx.streams.emit(STREAM_CHANNEL, { + reason, + entityId: event.entityId ?? null, + at: new Date().toISOString(), + }); + } + + ctx.events.on("issue.updated", async (event) => { + notify("issue.updated", event); + }); + + ctx.events.on("issue.comment.created", async (event) => { + notify("issue.comment.created", event); + }); + }, + + async onHealth() { + return { status: "ok", message: "Stats plugin worker is running" }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d5676d8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..09b620e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + }, +});