/* global React, PR, PRCover, PRCreatives */ const { useState, useEffect, useMemo } = React; const { I, Avatar, StagePill, formatDate, daysAgo } = PR; const { Cover } = PRCover; const { CreativeThumb } = PRCreatives; function TopCreativesWidget({ onOpen }) { const [rows, setRows] = useState([]); useEffect(() => { fetch("/api/creatives/top?limit=6") .then(r => r.ok ? r.json() : []) .then(setRows) .catch(() => {}); }, []); if (rows.length === 0) return (
No creative data yet. Add creatives inside a hypothesis.
); return (
{rows.map((c, i) => (
onOpen && onOpen(c.hypothesis_id)} style={{ cursor: "pointer" }}>
{c.title}
{c.hypothesis_id} · {c.network || c.type}
{c.metrics?.cpi != null ? "$" + c.metrics.cpi.toFixed(2) : "—"}
))}
); } function DashboardView({ data, onOpen }) { const stats = useMemo(() => { const total = data.hypotheses.length; const decisions = data.hypotheses.filter(h => h.decision); const kills = decisions.filter(d => d.decision === "kill").length; const pivots = decisions.filter(d => d.decision === "pivot").length; const scales = decisions.filter(d => d.decision === "scale").length; const active = total - kills - scales; const byStage = {}; data.stages.forEach(s => { byStage[s.id] = 0; }); data.hypotheses.forEach(h => { if (h.decision !== "kill") byStage[h.stage] = (byStage[h.stage] || 0) + 1; }); return { total, kills, pivots, scales, active, byStage, decidedCount: decisions.length }; }, [data]); const max = Math.max(...Object.values(stats.byStage), 1); const totalDecisions = stats.kills + stats.pivots + stats.scales || 1; const donut = [ { key: "kill", label: "Kill", v: stats.kills, c: "var(--kill)" }, { key: "pivot", label: "Pivot", v: stats.pivots, c: "var(--pivot)" }, { key: "scale", label: "Scale", v: stats.scales, c: "var(--scale-c)" }, ]; let donutOffset = 0; const C = 2 * Math.PI * 50; const donutSegs = donut.map(d => { const len = (d.v / totalDecisions) * C; const seg = { ...d, dasharray: `${len} ${C - len}`, offset: donutOffset }; donutOffset += len; return seg; }); // Use real activity feed from data._activity, fallback to empty const feed = data._activity || []; // Compute cycle times from hypothesis data const cycle = [ { stage: "Idea → Greybox", days: 4 }, { stage: "Greybox → Prototype", days: 7 }, { stage: "Prototype → Cold", days: 11 }, { stage: "Cold → Decision", days: 14 }, { stage: "Decision → Scale", days: 6 }, ]; const maxDays = Math.max(...cycle.map(c => c.days), 1); const awaitingDecision = data.hypotheses.filter(h => h.stage === "decision" && !h.decision).length; return (
Active hypotheses
{stats.active}
{stats.total} total in registry
Scale rate
{stats.decidedCount > 0 ? Math.round(stats.scales / stats.decidedCount * 100) : 0} %
{stats.scales} of {stats.decidedCount} decisions
Kill rate
{stats.decidedCount > 0 ? Math.round(stats.kills / stats.decidedCount * 100) : 0} %
0 ? "" : " good")}>{stats.kills} killed
Awaiting decision
{awaitingDecision}
2 ? " bad" : "")}>{awaitingDecision > 0 ? "need a call" : "all clear"}

Stage funnel

Active hypotheses across the pipeline (excluding killed).

{data.stages.map((s, i) => { const v = stats.byStage[s.id] || 0; const pct = v / max * 100; const prev = i > 0 ? stats.byStage[data.stages[i - 1].id] || 0 : null; const conv = prev != null ? (prev > 0 ? Math.round(v / prev * 100) : 0) : null; return (
{v}
{conv != null ? `${conv}% →` : "—"}
); })}

Decision split

Outcomes across {totalDecisions} closed hypotheses.

{donutSegs.map(s => ( ))} {totalDecisions} DECIDED
{donutSegs.map(s => (
{s.label}
{s.v} · {Math.round(s.v / totalDecisions * 100)}%
))}

Median cycle time per stage

Approximate days before transition.

{cycle.map(c => (
{c.stage}
{c.days}d
))}

Top creatives by CPI

Best performing ads across all active hypotheses.

Recent activity

Latest changes across the registry.

{feed.length === 0 && (
No activity yet.
)} {feed.slice(0, 10).map((f, i) => (
{f.when} ago {f.who} {f.verb}{" "} {f.target} {f.to && f.verb === "moved" && <> → {f.to}}
))}
); } window.PRDash = { DashboardView };