/* global React, ReactDOM, PRShell, PRViews, PRPassport, PRDash, PRModal, PRUsers */
const { useState, useEffect, useRef, useMemo } = React;
const { Sidebar, Topbar, PageHead } = PRShell;
const { TableView, KanbanView, CardsView } = PRViews;
const { PassportView } = PRPassport;
const { DashboardView } = PRDash;
const { ModalRouter } = PRModal;
const { UsersView } = PRUsers;
const PAGE_META = {
registry: { title: "Registry", sub: "Single source of truth for every R&D hypothesis. Lives free of procurement until handed off at Scale.", crumbs: ["Workspace", "Registry"] },
kanban: { title: "Pipeline", sub: "Hypotheses by funnel stage. Advance a card when the gate criteria are met.", crumbs: ["Workspace", "Pipeline"] },
cards: { title: "Cards", sub: "Grid view, optimised for scanning core-loops at a glance.", crumbs: ["Workspace", "Cards"] },
dashboard: { title: "Dashboard", sub: "Health of the pipeline: throughput, conversion, decision velocity.", crumbs: ["Workspace", "Dashboard"] },
passport: { title: "R&D Passport", sub: "Full record of a hypothesis — what travels into procurement on Scale.", crumbs: ["Workspace", "Registry", "Passport"] },
decision: { title: "Awaiting decision", sub: "Hypotheses that finished Cold Test and need a Kill / Pivot / Scale call.", crumbs: ["Views", "Awaiting decision"] },
scaled: { title: "Scaled", sub: "Handed off to production. Soft-launch tracking lives elsewhere.", crumbs: ["Views", "Scaled"] },
archive: { title: "Archive", sub: "Killed hypotheses. Searchable so the same idea isn't tested twice.", crumbs: ["Views", "Archive"] },
users: { title: "Users & roles", sub: "Manage team members, assign roles and permissions.", crumbs: ["Admin", "Users & roles"] },
};
// ── API helpers ───────────────────────────────────────────────────────────────
async function apiFetch(url, opts = {}) {
const resp = await fetch(url, { headers: { "Content-Type": "application/json" }, ...opts });
if (resp.status === 401) { window.location.href = "/login"; throw new Error("Unauthorized"); }
return resp;
}
// ── Hash routing ──────────────────────────────────────────────────────────────
function parseHash() {
const hash = window.location.hash.replace(/^#\/?/, "");
const parts = hash.split("/").filter(Boolean);
if (!parts.length) return { section: "registry", id: null };
return { section: parts[0], id: parts[1] || null };
}
// ── Overlay panel ─────────────────────────────────────────────────────────────
function OverlayPanel({ openId, data, user, onClose, onDecide, onAdvance, onRefresh }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (openId) {
// Next frame so CSS transition fires
requestAnimationFrame(() => setVisible(true));
} else {
setVisible(false);
}
}, [openId]);
useEffect(() => {
if (!openId) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [openId, onClose]);
if (!openId) return null;
return (
<>
>
);
}
// ── App ───────────────────────────────────────────────────────────────────────
function App() {
const [active, setActive] = useState("registry");
const [view, setView] = useState("table");
const [theme, setThemeState] = useState(() => localStorage.getItem("theme") || "light");
const [data, setData] = useState(null);
const [activity, setActivity] = useState([]);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null);
const [passportId, setPassportId] = useState(null);
const [openId, setOpenId] = useState(null);
const [filters, setFilters] = useState({ set: "all", stage: "", genre: "", author: "", risk: "" });
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
// ── Hash routing ────────────────────────────────────────────────────────────
const applyHash = () => {
const { section, id } = parseHash();
const sec = PAGE_META[section] ? section : "registry";
setActive(sec);
if (sec === "passport" && id) setPassportId(id);
else if (sec !== "passport") setPassportId(null);
};
useEffect(() => {
applyHash();
window.addEventListener("popstate", applyHash);
return () => window.removeEventListener("popstate", applyHash);
}, []);
const navigate = (section, id = null) => {
const hash = id ? `/${section}/${id}` : `/${section}`;
window.history.pushState(null, "", "#" + hash);
setActive(section);
if (id) setPassportId(id);
else setPassportId(null);
};
// ── Auth + initial load ──────────────────────────────────────────────────
useEffect(() => { bootstrap(); }, []);
const bootstrap = async () => {
try {
const setup = await fetch("/api/setup/status");
if (setup.ok) {
const { initialized } = await setup.json();
if (!initialized) { window.location.href = "/setup"; return; }
}
const me = await apiFetch("/api/auth/me");
if (!me.ok) return;
setUser(await me.json());
await loadData();
} finally {
setLoading(false);
}
};
const loadData = async () => {
const [dataResp, actResp] = await Promise.all([
apiFetch("/api/data"),
apiFetch("/api/activity"),
]);
if (dataResp.ok) setData(await dataResp.json());
if (actResp.ok) setActivity(await actResp.json());
};
const refresh = () => loadData();
// ── Navigation ────────────────────────────────────────────────────────────
const handleOpen = (id) => setOpenId(id);
// ── Actions ───────────────────────────────────────────────────────────────
const handleAdvance = async (rndId, notes) => {
await apiFetch(`/api/hypotheses/${rndId}/advance`, {
method: "POST",
body: JSON.stringify({ notes: notes || null }),
});
refresh();
};
const handleMove = async (rndId, targetStage) => {
await apiFetch(`/api/hypotheses/${rndId}/move`, {
method: "POST",
body: JSON.stringify({ stage: targetStage }),
});
refresh();
};
const handleDecide = (rndId) => setModal({ type: "decide", rndId });
const handleEdit = (rndId) => setModal({ type: "edit", rndId });
const handleLogout = async () => {
await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login";
};
// ── Filter logic ──────────────────────────────────────────────────────────
const filteredData = useMemo(() => {
if (!data) return null;
let hyps = data.hypotheses;
if (filters.set === "active") hyps = hyps.filter(h => !h.decision);
if (filters.set === "mine") hyps = hyps.filter(h => h.author === data._meta?.currentUserId);
if (filters.stage) hyps = hyps.filter(h => h.stage === filters.stage);
if (filters.genre) hyps = hyps.filter(h => h.tags.includes(filters.genre));
if (filters.author) hyps = hyps.filter(h => h.author === filters.author);
if (filters.risk) hyps = hyps.filter(h => h.risk === filters.risk);
return { ...data, hypotheses: hyps };
}, [data, filters]);
// ── Loading / guard ───────────────────────────────────────────────────────
if (loading) {
return (
Loading…
);
}
if (!data) return null;
const meta = { ...(PAGE_META[active] || PAGE_META.registry) };
const effectiveView =
active === "registry" ? view :
active === "kanban" ? "kanban" :
active === "cards" ? "cards" :
active === "dashboard" ? "dashboard":
active === "passport" ? "passport" :
active === "archive" ? "archive" :
active === "decision" ? "decision" :
active === "scaled" ? "scaled" :
active === "users" ? "users" : "table";
const fd = filteredData;
// Section subsets applied after user filters
const archiveData = { ...fd, hypotheses: fd.hypotheses.filter(h => h.decision === "kill") };
const scaledData = { ...fd, hypotheses: fd.hypotheses.filter(h => h.decision === "scale") };
const decisionData = { ...fd, hypotheses: fd.hypotheses.filter(h => h.stage === "decision" && !h.decision) };
const dashData = { ...fd, _activity: activity };
if (active === "passport" && passportId) {
meta.crumbs = ["Workspace", "Registry", passportId];
meta.sub = `${passportId} · The full record of a hypothesis. This is what travels into procurement on Scale.`;
}
const filterOptions = {
stages: data.stages.map(s => ({ value: s.id, label: s.label })),
genres: data.tags.map(t => ({ value: t, label: t })),
authors: data.authors.map(a => ({ value: a.id, label: a.name })),
risks: [{ value: "low", label: "Low" }, { value: "med", label: "Medium" }, { value: "high", label: "High" }],
};
const showFilters = ["registry", "kanban", "cards", "archive", "decision", "scaled"].includes(active);
return (
navigate(id)}
theme={theme}
setTheme={setThemeState}
data={data}
user={user}
onLogout={handleLogout}
/>
setModal({ type: "new" })} user={user} />
{active !== "users" && (
)}
{effectiveView === "table" && }
{effectiveView === "kanban" && setModal({ type: "new" })} onMove={handleMove} />}
{effectiveView === "cards" && }
{effectiveView === "dashboard" && }
{effectiveView === "passport" && (
)}
{effectiveView === "archive" && }
{effectiveView === "scaled" && }
{effectiveView === "decision" && }
{effectiveView === "users" && }
setOpenId(null)}
onDecide={(rndId) => { setModal({ type: "decide", rndId }); }}
onAdvance={handleAdvance}
onRefresh={refresh}
/>
{modal && (
setModal(null)}
onSuccess={(newId) => {
setModal(null);
refresh();
if (newId) setOpenId(newId);
}}
/>
)}
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render();