/* 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 => (
{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 };