/* eslint-disable */
// ─── tweaks-panel.jsx (inlined) ──────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ────────────────────────────────────────────────────────────────
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ──────────────────────────────────────────────────────────────
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
{title}
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
)}
>
);
}
function TweakSection({ title, label, children }) {
return (
<>
{title || label}
{children}
>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
{label}
{value != null && {value}}
{children}
);
}
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
onChange(Number(e.target.value))} />
);
}
function TweakToggle({ label, value, onChange }) {
return (
{label}
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const valueRef = React.useRef(value);
valueRef.current = value;
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
{opts.map((o) => (
))}
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
);
}
// ─── End tweaks-panel ────────────────────────────────────────────────────────
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// ─── Taxonomy (display) ─────────────────────────────────────────────────────
// Backend returns the canonical `category` and `family` keys per org.
// These constants are display-only.
const CATEGORY_LABELS = {
physical_assets: "Physical Asset",
personal_valuables: "Personal Valuable",
digital_devices: "Digital Devices",
financial_accounts: "Financial Accounts",
investment_business: "Investment & Business",
financial_protection: "Financial Protection",
employment_benefits: "Employment Benefits",
digital_accounts: "Digital Accounts",
government_legal: "Government & Legal",
cards_memberships: "Cards & Memberships",
subscriptions: "Subscriptions",
unknown: "Other",
};
const CATEGORY_ORDER = [
"physical_assets",
"personal_valuables",
"digital_devices",
"financial_accounts",
"investment_business",
"financial_protection",
"employment_benefits",
"digital_accounts",
"government_legal",
"cards_memberships",
"subscriptions",
"unknown",
];
// CATEGORY_TO_FAMILY now comes from the backend (/org-summaries -> category_family),
// threaded to as a prop. Kept out of the frontend to avoid a duplicate list.
if (typeof window !== "undefined") {
window.CATEGORY_LABELS = CATEGORY_LABELS;
window.CATEGORY_ORDER = CATEGORY_ORDER;
}
// ─── Clerk (person identity) ─────────────────────────────────────────────────
let clerk = null;
function loadScript(src, attrs = {}) {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.crossOrigin = "anonymous";
for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v);
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
// Memoised so the mount effect + checkAuth share one init (no duplicate script
// tags or repeat clerk.load() calls).
let __clerkInit = null;
function initClerk() {
if (__clerkInit) return __clerkInit;
__clerkInit = _initClerk();
return __clerkInit;
}
async function _initClerk() {
const res = await fetch("/auth/config");
const { publishable_key } = await res.json();
if (!publishable_key) return null; // Clerk not configured (dev bypass)
// Frontend API domain is encoded in the publishable key (Clerk-documented derivation)
const fapi = atob(publishable_key.split("_")[2]).slice(0, -1);
// @clerk/ui must load first; clerk.browser.js reads the key from its data-attr
// and exposes window.Clerk as a ready-made INSTANCE (not a constructor).
await loadScript(`https://${fapi}/npm/@clerk/ui@1/dist/ui.browser.js`);
await loadScript(`https://${fapi}/npm/@clerk/clerk-js@6/dist/clerk.browser.js`, {
"data-clerk-publishable-key": publishable_key,
});
if (!window.Clerk) throw new Error("ClerkJS failed to initialise");
clerk = window.Clerk; // global instance, no `new`
await clerk.load({ ui: { ClerkUI: window.__internal_ClerkUICtor } });
window.__clerk = clerk;
return clerk;
}
async function authHeaders() {
if (!clerk || !clerk.session) return {};
const token = await clerk.session.getToken(); // cached by ClerkJS, ~60s TTL
return token ? { Authorization: `Bearer ${token}` } : {};
}
// ─── API ─────────────────────────────────────────────────────────────────────
// Build a readable message from a failed response. FastAPI/pydantic 422s return
// { detail: [{ loc, msg, type }] } — pull out the field name + reason so the UI
// can flag exactly which value didn't match its expected type.
async function errorFromRes(res, fallback) {
const body = await res.json().catch(() => null);
const detail = body && body.detail;
if (Array.isArray(detail)) {
return detail
.map(e => `${(e.loc || []).slice(-1)[0] || "value"}: ${e.msg}`)
.join("; ");
}
if (typeof detail === "string") return detail;
return fallback || `Request failed: ${res.status}`;
}
async function apiGet(path) {
const res = await fetch(path, { credentials: "include", headers: await authHeaders() });
if (res.status === 401) throw Object.assign(new Error("Not authenticated"), { status: 401 });
if (!res.ok) throw new Error(`${path} failed: ${res.status}`);
return res;
}
async function fetchMe() {
const res = await apiGet("/auth/status"); // { status, person_id }
return res.json();
}
async function fetchOrgSummaries() {
const res = await apiGet("/org-summaries");
return res.json();
}
async function fetchAccounts() {
const res = await apiGet("/accounts");
return res.json();
}
async function postAccount(grantId, email) {
const res = await fetch("/accounts", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify({ grant_id: grantId, email }),
});
if (!res.ok) throw new Error(await errorFromRes(res, `Couldn't connect mailbox (${res.status})`));
return res.json();
}
// NOTE: backend endpoint DELETE /accounts/{email_account_id} may not exist yet.
async function deleteAccount(emailAccountId) {
const res = await fetch(`/accounts/${encodeURIComponent(emailAccountId)}`, {
method: "DELETE",
credentials: "include",
headers: await authHeaders(),
});
if (!res.ok && res.status !== 204) {
throw new Error(`DELETE /accounts/${emailAccountId} failed: ${res.status}`);
}
}
async function triggerIngest() {
const res = await fetch("/emails/ingest", { method: "POST", credentials: "include", headers: await authHeaders() });
if (!res.ok) throw new Error(`POST /emails/ingest failed: ${res.status}`);
return res.json();
}
async function shareReport(to) {
const res = await fetch("/report/share", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify({ to }),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || `Could not send (${res.status})`);
}
}
// Print a single report doc to PDF. `cls` ("print-report" | "print-org") scopes
// the print CSS so only that doc is shown. Cleaned up after the dialog closes.
function printDoc(cls) {
document.body.classList.add(cls);
const cleanup = () => {
document.body.classList.remove(cls);
window.removeEventListener("afterprint", cleanup);
};
window.addEventListener("afterprint", cleanup);
window.print();
}
async function fetchIngestStatus() {
const res = await apiGet("/emails/ingest/status");
return res.json(); // { status: queued|active|complete|failed|idle|no_user }
}
async function fetchWizard() {
const res = await apiGet("/wizard"); // { sections, profile, contacts, completed }
return res.json();
}
async function postWizardAnswers(answers) {
const res = await fetch("/wizard/answers", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify({ answers }),
});
if (!res.ok) throw new Error(`/wizard/answers failed: ${res.status}`);
return res.json();
}
async function fetchResources() {
const res = await apiGet("/wizard/resources"); // { categories, items }
return res.json();
}
async function fetchUserResources() {
const res = await apiGet("/user-resources"); // { total, user_resources }
return res.json();
}
async function postResourceItem(body) {
const res = await fetch("/wizard/resources", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await errorFromRes(res, `/wizard/resources failed: ${res.status}`));
return res.json();
}
async function deleteResourceItem(id) {
const res = await fetch(`/wizard/resources/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
headers: await authHeaders(),
});
if (!res.ok) throw new Error(`DELETE /wizard/resources failed: ${res.status}`);
return res.json();
}
async function patchResourceItem(id, body) {
const res = await fetch(`/wizard/resources/${encodeURIComponent(id)}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await errorFromRes(res, `PATCH /wizard/resources failed: ${res.status}`));
return res.json();
}
async function confirmResourceItem(id) {
const res = await fetch(`/wizard/resources/${encodeURIComponent(id)}/confirm`, {
method: "POST",
credentials: "include",
headers: await authHeaders(),
});
if (!res.ok) throw new Error(`POST /wizard/resources/confirm failed: ${res.status}`);
return res.json();
}
const RESOURCES_API = { load: fetchResources, add: postResourceItem, remove: deleteResourceItem, edit: patchResourceItem, confirm: confirmResourceItem };
async function fetchGaps() {
const res = await apiGet("/wizard/gaps"); // { missing_details, coverage }
return res.json();
}
async function fillGapDetails(body) {
const res = await fetch("/wizard/gaps/details", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`/wizard/gaps/details failed: ${res.status}`);
return res.json();
}
async function saveGapOpenNotes(body) {
const res = await fetch("/wizard/gaps/open", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`/wizard/gaps/open failed: ${res.status}`);
return res.json();
}
const GAPS_API = { load: fetchGaps, fill: fillGapDetails, saveOpen: saveGapOpenNotes };
async function lookupAddress(postcode) {
const res = await apiGet(`/wizard/address-lookup?postcode=${encodeURIComponent(postcode)}`);
if (!res.ok) throw new Error(`address lookup failed: ${res.status}`);
return res.json(); // { valid, town, county, postcode }
}
const ADDRESS_API = { lookup: lookupAddress };
// First-run gate: the personal-details step shows until these core profile fields
// exist. County/town are optional, so they're not required here.
const REQUIRED_PROFILE_KEYS = ["first_name", "surname", "dob", "address_line1", "postcode"];
function needsPersonalDetails(profile) {
return REQUIRED_PROFILE_KEYS.some(k => !(profile?.[k] || "").trim());
}
async function fetchFilters() {
const res = await apiGet("/filters");
return res.json();
}
async function postSender(email) {
const res = await fetch("/filters/senders", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST /filters/senders failed: ${res.status} ${text}`);
}
return res.json();
}
async function deleteSender(email) {
const res = await fetch(`/filters/senders/${encodeURIComponent(email)}`, {
method: "DELETE",
credentials: "include",
headers: await authHeaders(),
});
if (!res.ok && res.status !== 204) {
throw new Error(`DELETE /filters/senders/${email} failed: ${res.status}`);
}
}
async function putSelectedSender(email) {
const res = await fetch("/filters/selected", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json", ...(await authHeaders()) },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error(`PUT /filters/selected failed: ${res.status}`);
}
return res.json();
}
// ─── Transform — backend already returns the canonical shape, we just group ──
function transformApiData(apiResponse) {
const orgs = (apiResponse.summaries || []).map(s => ({
...s,
category: s.category || "unknown",
family: s.family || "unknown",
accounts: s.accounts || [],
}));
const orgs_by_category = {};
for (const c of CATEGORY_ORDER) orgs_by_category[c] = [];
for (const o of orgs) (orgs_by_category[o.category] ||= []).push(o);
const categoryCount = Object.keys(orgs_by_category).filter(k => orgs_by_category[k].length > 0).length;
return {
org_count: orgs.length,
category_count: categoryCount,
orgs,
orgs_by_category,
category_types: apiResponse.category_types || {},
category_family: apiResponse.category_family || {},
};
}
// ─── Heuristic — only used in the per-org report modal to colour the
// priority pills when the backend doesn't supply an explicit priority.
const HIGH_KW = /(overdue|expir|urgent|verify|suspend|locked|fraud|breach|cancel|close|deadline|outstanding)/i;
const MEDIUM_KW = /(renew|update|change|confirm|review|upcoming|file|rebalance|reconcile|transfer|add )/i;
function actionPriority(text) {
const s = String(text || "");
if (HIGH_KW.test(s)) return "HIGH";
if (MEDIUM_KW.test(s)) return "MEDIUM";
return "LOW";
}
if (typeof window !== "undefined") window.actionPriority = actionPriority;
// ─── Org-level helpers (over accounts) ───────────────────────────────────────
function orgHasAttention(org) {
return (org.accounts || []).some(a => (a.actions && a.actions.length > 0) || a.status === "lapsing");
}
function orgPaymentLine(org) {
for (const a of (org.accounts || [])) {
const p = a.payment;
if (!p || !p.amount) continue;
if (p.cadence === "month") return `${p.amount}/month`;
if (p.cadence === "annual") return `${p.amount}/year`;
if (p.cadence && p.cadence !== "n/a" && p.cadence !== "—") return `${p.amount} (${p.cadence})`;
return p.amount;
}
return null;
}
function orgNextCharge(org) {
for (const a of (org.accounts || [])) {
const n = a.payment?.next;
if (n) return n;
}
return null;
}
function orgAllActions(org) {
const out = [];
for (const a of (org.accounts || [])) for (const x of (a.actions || [])) out.push({ acct: a, text: x });
return out;
}
function orgAllDocs(org) {
const out = [];
for (const a of (org.accounts || [])) for (const d of (a.docs || [])) out.push({ acct: a, ...d });
return out;
}
function orgAllEvents(org) {
const out = [];
for (const a of (org.accounts || [])) for (const e of (a.events || [])) out.push({ acct: a, ...e });
out.sort((x, y) => (x.date < y.date ? 1 : -1));
return out;
}
// ─── Brand ───────────────────────────────────────────────────────────────────
function Wordmark({ size = 28, tone = "ink" }) {
return (
);
}
// ─── Ambient gradient backdrop (login) ───────────────────────────────────────
function AmbientCanvas({ enabled }) {
const ref = useRef(null);
useEffect(() => {
if (!enabled || !ref.current) return;
const canvas = ref.current;
const ctx = canvas.getContext("2d");
let raf, t = 0;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.scale(dpr, dpr);
}
resize();
window.addEventListener("resize", resize);
function tick() {
t += 0.0035;
const w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
const blobs = [
{ x: w * (0.3 + 0.12 * Math.sin(t)), y: h * (0.35 + 0.1 * Math.cos(t * 0.7)), r: w * 0.55, c: "oklch(0.82 0.06 90 / 0.55)" },
{ x: w * (0.7 + 0.14 * Math.cos(t * 0.5)), y: h * (0.65 + 0.12 * Math.sin(t * 0.6)), r: w * 0.5, c: "oklch(0.72 0.05 140 / 0.5)" },
{ x: w * (0.55 + 0.1 * Math.sin(t * 1.2)), y: h * (0.2 + 0.1 * Math.cos(t * 0.9)), r: w * 0.45, c: "oklch(0.88 0.04 60 / 0.55)" },
];
for (const b of blobs) {
const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
g.addColorStop(0, b.c);
g.addColorStop(1, "oklch(0.97 0.008 85 / 0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
}
raf = requestAnimationFrame(tick);
}
tick();
return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", resize); };
}, [enabled]);
return ;
}
// ─── UI primitives ───────────────────────────────────────────────────────────
function StatusBadge({ source }) {
// Confirmation state, to match the wizard: AI guesses stay "Unconfirmed" until a
// human confirms/edits them (source flips to MANUAL); everything else is "Active".
const unconfirmed = source === "AI_INGESTION";
const label = unconfirmed ? "Unconfirmed" : "Active";
return {label};
}
function ArrowIcon() {
return ;
}
function DocIcon() {
return ;
}
function CheckTinyIcon() {
return ;
}
// ─── OrgCard helpers ─────────────────────────────────────────────────────────
// Domain for the favicon lookup, derived from whatever URL the org carries.
function orgDomain(org) {
const url = org.website_url || org.signin || org.website || "";
if (!url) return "";
try {
const u = new URL(url.startsWith("http") ? url : `https://${url}`);
return u.hostname.replace(/^www\./, "");
} catch {
return "";
}
}
// First 1–2 letters, for the monogram behind a missing favicon.
function orgMonogram(name) {
const n = (name || "").trim();
if (!n) return "?";
const parts = n.split(/\s+/);
if (parts.length >= 2 && parts[0] && parts[1]) return (parts[0][0] + parts[1][0]).toUpperCase();
return n.slice(0, 2).toUpperCase();
}
// Map the org's relationship status → status-pill variant + label.
function orgStatusPill(org) {
const s = org.relationship_status || "unknown";
if (s === "lapsing" || orgHasAttention(org)) return { variant: "attention", label: "Attention" };
if (s === "active") return { variant: "active", label: "Active" };
if (s === "churned") return { variant: "expired", label: "Expired" };
return { variant: "neutral", label: s.charAt(0).toUpperCase() + s.slice(1) };
}
// One-line payment note for the footer (mirrors the mock's "payment" slot).
function orgPaymentNote(org) {
const pay = orgPaymentLine(org);
if (!pay) return "No recurring payment";
const next = orgNextCharge(org);
return next ? `${pay} · next ${next}` : pay;
}
// ─── OrgDetailsModal — docs + actions popup, opened from a card ──────────────
function OrgDetailsModal({ org, docs, actions, onClose }) {
const closeRef = React.useRef(onClose);
closeRef.current = onClose;
React.useEffect(() => {
function onKey(e) { if (e.key === "Escape") closeRef.current && closeRef.current(); }
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, []);
// Local-only "done" state — not persisted to the backend, matches the old card.
const [doneActions, setDoneActions] = React.useState({});
const toggle = (i) => setDoneActions((prev) => ({ ...prev, [i]: !prev[i] }));
return (
{ if (e.target === e.currentTarget) onClose(); }}>
{org.organisation_name}
Documents{docs.length > 0 && {docs.length}}
{docs.length === 0 ? (
No documents on file.
) : (
{docs.map((d, i) => (
-
{d.name}
{d.date && {d.date}}
))}
)}
Actions required{actions.length > 0 && {actions.length}}
{actions.length === 0 ? (
No actions required.
) : (
)}
);
}
// ─── OrgCard — logo-forward card (shared by every category grid) ─────────────
function OrgCard({ org }) {
const [showDetails, setShowDetails] = React.useState(false);
const [favOk, setFavOk] = React.useState(true);
const domain = orgDomain(org);
const fav = domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=128` : "";
const pill = orgStatusPill(org);
const docs = orgAllDocs(org);
const actions = orgAllActions(org);
const resCount = org.accounts.length;
const hasDetails = docs.length > 0 || actions.length > 0;
const desc = org.relationship_summary
|| `${resCount} ${resCount === 1 ? "resource" : "resources"} held with ${org.organisation_name}.`;
return (
<>
{orgMonogram(org.organisation_name)}
{fav && favOk && (

setFavOk(false)} />
)}
{CATEGORY_LABELS[org.category] || org.category}
{org.organisation_name}
{pill.label}
{desc}
{orgPaymentNote(org)}
{hasDetails && (
)}
{resCount} {resCount === 1 ? "resource" : "resources"}
{showDetails && (
setShowDetails(false)} />
)}
>
);
}
// ─── LoginScreen ──────────────────────────────────────────────────────────────
// Two-step landing: marketing hero + "Get Started" first; Clerk's SignIn only
// mounts once the user opts in. After auth, Clerk redirects to "/" and
// checkAuth() takes the signed-in user straight to the dashboard. Connecting an
// inbox (Nylas) is a separate action, handled on the dashboard.
function LoginScreen({ tweaks }) {
const [showAuth, setShowAuth] = React.useState(false);
const signInRef = React.useRef(null);
React.useEffect(() => {
if (showAuth && signInRef.current && window.__clerk) {
window.__clerk.mountSignIn(signInRef.current, {
signInForceRedirectUrl: "/",
signUpForceRedirectUrl: "/",
});
const el = signInRef.current;
return () => window.__clerk.unmountSignIn(el);
}
}, [showAuth]);
// Auth view: a distinct, centered page — same branding (wordmark + ambient
// background), Clerk's SignIn in the middle. Not the split landing.
if (showAuth) {
return (
);
}
// Landing: marketing hero on the left, Get Started on the right.
return (
A considered handover, for the moments that matter.
Every account.
Every renewal.
One clear record.
Connect your inbox once. We organise subscriptions, financial accounts and digital
identities into a single, deterministic handover document — ready to print, share,
or settle.
Your handover, in minutes.
Create an account or sign in to begin.
);
}
// ─── Category icons (sidebar) ────────────────────────────────────────────────
const CATEGORY_ICON = {
physical_assets: (s) => ,
personal_valuables: (s) => ,
digital_devices: (s) => ,
financial_accounts: (s) => ,
investment_business: (s) => ,
financial_protection: (s) => ,
employment_benefits: (s) => ,
digital_accounts: (s) => ,
government_legal: (s) => ,
cards_memberships: (s) => ,
subscriptions: (s) => ,
};
// Per-TYPE icon for the category type-summary tiles. A few type-specific glyphs,
// otherwise reuse the category icons by keyword, with a cube fallback.
const _ICON_ART = (s) => ;
const _ICON_WATCH = (s) => ;
const _ICON_CUBE = (s) => ;
const _ICON_HOUSE = (s) => ;
const _ICON_TOOL = (s) => ;
const _ICON_PHONE = (s) => ;
const _ICON_PLAY = (s) => ;
function typeIcon(name, s = 18) {
const n = (name || "").toLowerCase();
if (n.includes("art")) return _ICON_ART(s);
if (n.includes("watch")) return _ICON_WATCH(s);
if (n.includes("jewel")) return CATEGORY_ICON.personal_valuables(s);
// These two must run before the broader account/subscription/share/insurance/car
// checks below, which are loose enough to shadow several employment_benefits and
// digital_accounts leaf-type names (e.g. "Death In Service Benefit" contains
// "benefit", "Gaming Account" contains "account").
if (/(employer|benefit|rsu|restricted stock|redundancy|notice pay|share option|deferred bonus|death in service|company car|car allowance)/.test(n)) return CATEGORY_ICON.employment_benefits(s);
if (/(email|cloud|password|vpn|domain|social media|gaming account|software development|other digital)/.test(n)) return CATEGORY_ICON.digital_accounts(s);
if (/(car|vehicle|van|truck|motor|boat|aircraft)/.test(n)) return CATEGORY_ICON.physical_assets(s);
if (/(house|land|property)/.test(n)) return _ICON_HOUSE(s);
if (/(tractor|generator|power tools|workshop|machinery|equipment)/.test(n)) return _ICON_TOOL(s);
if (/(laptop|computer|phone|tablet|device)/.test(n)) return CATEGORY_ICON.digital_devices(s);
if (n.includes("card")) return CATEGORY_ICON.cards_memberships(s);
if (/(account|mortgage|savings|bank|benefit)/.test(n)) return CATEGORY_ICON.financial_accounts(s);
if (/(insurance|protection)/.test(n)) return CATEGORY_ICON.financial_protection(s);
if (/(invest|broker|share|crypto)/.test(n)) return CATEGORY_ICON.investment_business(s);
if (/(passport|licence|license|visa|legal|government)/.test(n)) return CATEGORY_ICON.government_legal(s);
if (/(subscription|streaming|plan)/.test(n)) return CATEGORY_ICON.subscriptions(s);
if (/(member|loyalty|club)/.test(n)) return CATEGORY_ICON.cards_memberships(s);
return _ICON_CUBE(s);
}
// Sub-grids shown on a category's landing page, for the categories whose
// resource types split into genuine, evenly-sized concepts. Categories not
// listed here (e.g. Personal Valuables, only 3 types with no real split)
// fall back to the flat type-tile grid.
const ASSET_GROUPS = [
{ key: "vehicles", label: "Vehicles", icon: s => CATEGORY_ICON.physical_assets(s), types: ["Car", "Motorcycle", "Van", "Truck", "Boat", "Aircraft"] },
{ key: "property", label: "Property", icon: _ICON_HOUSE, types: ["House", "Land"] },
{ key: "machinery", label: "Machinery / Equipment", icon: _ICON_TOOL, types: ["Tractor", "Generator", "Power Tools", "Workshop Equipment"] },
];
const CATEGORY_GROUPS = {
physical_assets: ASSET_GROUPS,
digital_devices: [
{ key: "mobile", label: "Mobile", icon: s => _ICON_PHONE(s), types: ["Smartphone", "Tablet"] },
{ key: "computers", label: "Computers", icon: s => CATEGORY_ICON.digital_devices(s), types: ["Laptop", "Desktop Computer"] },
],
financial_accounts: [
{ key: "banking", label: "Everyday Banking", icon: s => CATEGORY_ICON.financial_accounts(s), types: ["Current Account", "Savings Account"] },
{ key: "credit", label: "Credit & Lending", icon: s => CATEGORY_ICON.cards_memberships(s), types: ["Credit Card Account", "Mortgage Account"] },
],
financial_protection: [
{ key: "personal", label: "Personal Insurance", icon: s => CATEGORY_ICON.financial_protection(s), types: ["Life Insurance Policy", "Health Insurance Policy"] },
{ key: "property", label: "Property & Vehicle", icon: s => _ICON_HOUSE(s), types: ["Vehicle Insurance Policy", "Home Insurance Policy"] },
],
subscriptions: [
{ key: "entertainment", label: "Entertainment", icon: s => _ICON_PLAY(s), types: ["Video Streaming Subscription", "Music Streaming Subscription"] },
{ key: "utilities", label: "Utilities & Services", icon: s => CATEGORY_ICON.subscriptions(s), types: ["Software Subscription", "Mobile Phone Plan"] },
],
};
// ─── Sidebar (Dashboard only) ────────────────────────────────────────────────
function Sidebar({ activeCategory, onSelect, collapsed, onToggleCollapsed, counts, categoryFamily }) {
const item = (key, label) => {
const Icon = CATEGORY_ICON[key];
const active = activeCategory === key;
const n = counts?.[key] || 0;
return (
);
};
return (
);
}
// ─── ReportTab ────────────────────────────────────────────────────────────────
function ReportTab({ report, user, onOpenOrgReport }) {
if (!window.SlimReportDoc) return ;
return (
<>
>
);
}
// ─── DashboardView (router) ───────────────────────────────────────────────────
function DashboardView({ report, activeCategory, onOpenOrgReport, onSelectCategory }) {
if (activeCategory) {
return ;
}
return ;
}
// ─── Actions & Documents panels ──────────────────────────────────────────────
// Category → accent colour for the Actions panel. The spec names three; every
// other category falls back to the neutral "Other" grey.
const ACTION_ACCENTS = {
subscriptions: "#8B5CF6",
financial_accounts: "#2F9E5B",
};
function actionAccent(category) {
return ACTION_ACCENTS[category] || "#6E6A57";
}
// Actions are stored as plain strings with the start date baked in as
// "… [Initiated 12 Jun 2026]" (see org_summaries/mapper.py). Pull it back out
// so the row can show the date on its own line.
function splitInitiated(text) {
const s = String(text || "");
const m = s.match(/\s*\[Initiated ([^\]]+)\]\s*$/);
if (m) return { title: s.slice(0, m.index).trim(), initiated: m[1].trim() };
return { title: s.trim(), initiated: null };
}
function buildActionRows(orgs) {
const out = [];
for (const o of orgs) {
for (const a of (o.accounts || [])) {
for (const t of (a.actions || [])) {
const { title, initiated } = splitInitiated(t);
if (title) out.push({ org: o, acct: a, title, initiated });
}
}
}
return out;
}
// File tile — infer format from the document name's extension. The data has no
// size and no reliable file type, so anything but PDF/DOC gets a neutral tile.
function docFileKind(name) {
const m = String(name || "").match(/\.([a-z0-9]+)$/i);
const ext = m ? m[1].toLowerCase() : "";
if (ext === "pdf") return { label: "PDF", variant: "pdf", fg: "#C0392B" };
if (ext === "doc" || ext === "docx") return { label: "DOC", variant: "doc", fg: "#2A5CB8" };
return { label: ext ? ext.toUpperCase().slice(0, 4) : "FILE", variant: "file", fg: "#6E6A57" };
}
function buildDocRows(orgs) {
const out = [];
for (const o of orgs) {
for (const a of (o.accounts || [])) {
for (const d of (a.docs || [])) {
out.push({ org: o, acct: a, name: d.name, date: d.date, type: d.kind });
}
}
}
return out;
}
// Shared panel shell — cream card with a serif title, mono subtitle + count pill.
function Panel({ title, subtitle, count, noun, children }) {
return (
{title}
{subtitle}
{count} {count === 1 ? noun : noun + "s"}
{children}
);
}
function ActionStatusPill({ source }) {
// Mirrors StatusBadge: AI guesses stay "Unconfirmed" until a human confirms.
const unconfirmed = source === "AI_INGESTION";
return (
{unconfirmed ? "Unconfirmed" : "Active"}
);
}
function ActionsPanel({ rows, onOpenOrgReport }) {
return (
{rows.length === 0 ? (
Nothing outstanding right now.
) : (
{rows.map((r, i) => {
const accent = actionAccent(r.org.category);
const href = r.org.website_url || r.org.signin || r.org.website;
return (
-
{(CATEGORY_LABELS[r.org.category] || r.org.category).toUpperCase()}
/
{r.org.organisation_name}
/
{r.acct.name}
{r.title}
{r.initiated &&
Initiated {r.initiated}
}
{href ? (
Open Account
) : (
)}
);
})}
)}
);
}
function DocumentsPanel({ rows, onOpenOrgReport }) {
return (
{rows.length === 0 ? (
No documents yet.
) : (
{rows.map((r, i) => {
const fk = docFileKind(r.name);
const cat = CATEGORY_LABELS[r.org.category] || r.org.category;
return (
-
{fk.label}
{r.name}
{cat.toUpperCase()}
{r.type && <>·{r.type}>}
{r.date && <>·{r.date}>}
);
})}
)}
);
}
// ── Display helpers for LLM-generated text that may be too long for cards ──
function firstSentence(s, fallbackMax = 160) {
if (!s) return "";
const m = String(s).match(/^(.{20,}?[.!?])(\s|$)/);
if (m) return truncateText(m[1], fallbackMax);
return truncateText(s, fallbackMax);
}
function truncateText(s, max = 100) {
if (!s) return "";
const t = String(s);
if (t.length <= max) return t;
return t.slice(0, max - 1).trimEnd() + "…";
}
function DashboardSummaryView({ report, onOpenOrgReport, onSelectCategory }) {
const orgs = report.orgs || [];
const actionRows = useMemo(() => buildActionRows(orgs), [orgs]);
const docRows = useMemo(() => buildDocRows(orgs), [orgs]);
const totals = useMemo(() => {
let activities = 0, actions = 0, relationships = 0;
for (const o of orgs) {
for (const a of (o.accounts || [])) {
relationships += 1;
activities += (a.events || []).length;
actions += (a.actions || []).length;
}
}
return { activities, actions, relationships };
}, [orgs]);
const byCat = useMemo(() => {
const out = {};
for (const c of CATEGORY_ORDER) {
const list = report.orgs_by_category[c] || [];
out[c] = list.reduce((n, o) => n + (o.accounts?.length || 0), 0);
}
return out;
}, [report]);
const visibleCats = CATEGORY_ORDER.filter(c => (byCat[c] || 0) > 0);
return (
By category
{visibleCats.length} categories
{visibleCats.map(c => {
const fam = (report.orgs_by_category[c][0] || {}).family || "unknown";
return (
-
);
})}
);
}
function KpiTile({ n, label, sub }) {
return (
{n}
{label}
{sub &&
{sub}
}
);
}
function DashboardCategoryView({ report, activeCategory, onOpenOrgReport }) {
const list = (report.orgs_by_category && report.orgs_by_category[activeCategory]) || [];
const [openGroup, setOpenGroup] = React.useState(null);
const [openType, setOpenType] = React.useState(null);
const groups = CATEGORY_GROUPS[activeCategory]; // categories with a genuine 2-way type split get a group sub-grid; others fall back to the flat list
// Tally the resource types held in this category, from each org's accounts.
const typeCounts = {};
for (const o of list) for (const a of (o.accounts || [])) {
const t = a.resource_type || "Other";
typeCounts[t] = (typeCounts[t] || 0) + 1;
}
// Show every type this category can hold, even with 0 records, plus any
// unexpected types that turned up in the data. Counts overlaid from typeCounts.
const knownTypes = (report.category_types && report.category_types[activeCategory]) || [];
const extraTypes = Object.keys(typeCounts).filter(t => !knownTypes.includes(t));
const typeEntries = [...knownTypes, ...extraTypes].map(t => [t, typeCounts[t] || 0]);
const totalRecords = typeEntries.reduce((s, [, n]) => s + n, 0);
const label = CATEGORY_LABELS[activeCategory];
const group = groups && openGroup ? groups.find(g => g.key === openGroup) : null;
// Drill-in view: one resource type, showing its cards. Reached by tapping a tile.
if (openType) {
const shown = list.filter(o => (o.accounts || []).some(a => (a.resource_type || "Other") === openType));
const backLabel = group ? group.label : label;
return (
{openType}
{shown.length === 0 &&
Nothing here yet.
}
{shown.map(o => (
))}
);
}
// Grouped category landing: sub-grid of the category's groups.
if (groups && !openGroup) {
return (
{label}
{groups.length} categories · {totalRecords} record{totalRecords === 1 ? "" : "s"}
Pick a category to explore what we found.
{groups.map(g => {
const n = g.types.reduce((sum, t) => sum + (typeCounts[t] || 0), 0);
return (
);
})}
);
}
// Landing view (or, for grouped categories, a group's type list): tap a type to open its records.
const shownEntries = group ? typeEntries.filter(([t]) => group.types.includes(t)) : typeEntries;
const shownTotal = group ? shownEntries.reduce((sum, [, n]) => sum + n, 0) : totalRecords;
const heading = group ? group.label : label;
return (
{group &&
}
{heading}
{shownEntries.length} type{shownEntries.length === 1 ? "" : "s"} · {shownTotal} record{shownTotal === 1 ? "" : "s"}
Tap a type to see what we found.
{shownEntries.length === 0 ? (
Nothing in {heading.toLowerCase()} yet.
) : (
{shownEntries.map(([t, n]) => (
))}
)}
);
}
// ─── OrgReportPicker ─────────────────────────────────────────────────────────
function OrgReportPicker({ orgs, onPick }) {
const [open, setOpen] = React.useState(false);
const [q, setQ] = React.useState("");
const ref = React.useRef(null);
React.useEffect(() => {
function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const grouped = React.useMemo(() => {
const byCat = {};
for (const o of orgs) (byCat[o.category] ||= []).push(o);
const out = [];
for (const c of CATEGORY_ORDER) if (byCat[c]) out.push([c, byCat[c]]);
if (byCat.unknown) out.push(["unknown", byCat.unknown]);
return out;
}, [orgs]);
const needle = q.trim().toLowerCase();
const matches = (o) => !needle || o.organisation_name.toLowerCase().includes(needle);
return (
{open && (
setQ(e.target.value)}
autoFocus
/>
{grouped.map(([cat, list]) => {
const shown = list.filter(matches);
if (!shown.length) return null;
return (
{CATEGORY_LABELS[cat] || cat}
{shown.map(o => (
))}
);
})}
)}
);
}
// ─── ShareMenu ───────────────────────────────────────────────────────────────
function ShareMenu() {
const [open, setOpen] = React.useState(false);
const [emailMode, setEmailMode] = React.useState(false);
const [to, setTo] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState(null); // { ok: bool, text }
const ref = React.useRef(null);
React.useEffect(() => {
function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) close(); }
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
function close() { setOpen(false); setEmailMode(false); setMsg(null); }
async function handleSend(e) {
e?.preventDefault();
const recipient = to.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)) {
setMsg({ ok: false, text: "Enter a valid email address." });
return;
}
setBusy(true); setMsg(null);
try {
await shareReport(recipient);
setMsg({ ok: true, text: `Sent to ${recipient}.` });
setTo("");
} catch (err) {
setMsg({ ok: false, text: err.message || "Could not send." });
} finally {
setBusy(false);
}
}
return (
{open && (
{!emailMode ? (
<>
>
) : (
)}
)}
);
}
// ─── SenderFilter ────────────────────────────────────────────────────────────
function SenderFilter({ filter, onFilterChange }) {
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState("");
const [err, setErr] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const senders = filter.senders || [];
const selected = filter.selected || null;
const pillLabel = selected ? selected : "All senders";
async function handleAdd(e) {
e?.preventDefault();
const email = draft.trim();
if (!email) return;
setBusy(true); setErr(null);
try {
const next = await postSender(email);
onFilterChange(next);
setDraft("");
} catch (e) {
setErr(e.message || "Could not add sender");
} finally {
setBusy(false);
}
}
async function handleRemove(email) {
setBusy(true); setErr(null);
try {
await deleteSender(email);
const nextSelected = selected === email ? null : selected;
if (selected === email) await putSelectedSender(null);
onFilterChange({ senders: senders.filter(s => s !== email), selected: nextSelected });
} catch (e) {
setErr(e.message || "Could not remove sender");
} finally {
setBusy(false);
}
}
async function handleSelect(value) {
const email = value === "__all__" ? null : value;
setBusy(true); setErr(null);
try {
const next = await putSelectedSender(email);
onFilterChange(next);
} catch (e) {
setErr(e.message || "Could not change selection");
} finally {
setBusy(false);
}
}
return (
{open && (
{senders.length === 0 ? (
No saved senders yet.
) : (
{senders.map(s => (
-
{s}
))}
)}
Pick one to limit Sync to that sender, or “All senders” for no filter.
{err &&
{err}
}
)}
);
}
// ─── Nylas Connect (mailbox identity) ────────────────────────────────────────
// @nylas/connect is ESM-only; index.html loads it as a module and sets
// window.NylasConnect. We init a single client lazily and reuse it.
let nylasConnect = null;
let nylasCallbackHandled = false;
function defaultNylasRedirectUri() {
return `${window.location.origin}/auth/callback`;
}
function hasNylasCallbackParams() {
const params = new URLSearchParams(window.location.search);
return window.location.pathname === "/auth/callback" &&
(params.has("code") || params.has("error"));
}
function waitForNylasConnect() {
if (window.NylasConnect) return Promise.resolve();
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
window.removeEventListener("nylasconnect:ready", onReady);
reject(new Error("Nylas Connect SDK not loaded"));
}, 10000);
function onReady() {
window.clearTimeout(timeout);
resolve();
}
window.addEventListener("nylasconnect:ready", onReady, { once: true });
});
}
async function getNylasConnect() {
if (nylasConnect) return nylasConnect;
await waitForNylasConnect();
const res = await fetch("/auth/config");
const { nylas_client_id, nylas_redirect_uri } = await res.json();
if (!nylas_client_id) throw new Error("Nylas client id not configured");
nylasConnect = new window.NylasConnect({
clientId: nylas_client_id,
redirectUri: nylas_redirect_uri || defaultNylasRedirectUri(),
apiUrl: "https://api.eu.nylas.com", // SDK defaults to US; our Nylas app is EU
// DIAGNOSTIC: idp_claims disabled to test the Google 45002 (grant creation
// failed) bug. This Clerk JWT is injected during Nylas token exchange; if
// window.__clerk is absent in the popup/callback context it returns undefined
// and breaks the exchange. Returning null skips idp_claims entirely.
// If Google connects with this off, the Clerk token was the cause.
identityProviderToken: async () => null,
// Google needs explicit scopes or it won't grant mail access (Microsoft works
// on the SDK defaults). Matches the Nylas connector's configured scopes.
defaultScopes: {
google: [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/gmail.modify",
],
},
});
return nylasConnect;
}
async function handleNylasCallbackIfPresent() {
if (nylasCallbackHandled || !hasNylasCallbackParams()) return false;
nylasCallbackHandled = true;
const client = await getNylasConnect();
await client.callback(window.location.href);
return true;
}
const InboxIcon = (s) => (
);
const _ICON_CHEVRON = (s) => (
);
function HomeSection({ eyebrow, sub, emptyText, items, onOpenOrgReport }) {
return (
{eyebrow}
{sub}
{items.length === 0 ? (
{emptyText}
) : (
{items.map((it, i) => (
onOpenOrgReport(it.org)}>
{it.label}
{it.title}
{it.hint}
{_ICON_CHEVRON(16)}
))}
)}
);
}
// Reuses the same action rows + priority heuristic as the Dashboard's Actions
// panel (buildActionRows / actionPriority) — HIGH-worded actions ("overdue",
// "expir…", "urgent"…) surface as Today, everything else as This month.
function HomeTab({ user, report, onOpenOrgReport }) {
const [now, setNow] = React.useState(new Date());
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const timeStr = now.toLocaleTimeString("en-GB", { hour12: false });
const dateStr = now.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" });
const actionRows = useMemo(() => buildActionRows(report.orgs || []), [report]);
const toHomeItem = (r) => ({
label: CATEGORY_LABELS[r.org.category] || r.org.category,
title: r.title,
hint: r.initiated ? `Initiated ${r.initiated}` : r.org.organisation_name,
org: r.org,
});
const todayItems = actionRows.filter(r => actionPriority(r.title) === "HIGH").map(toHomeItem);
const monthItems = actionRows.filter(r => actionPriority(r.title) !== "HIGH").map(toHomeItem);
return (
{timeStr}
{dateStr}
);
}
// ─── IngestionTab ─────────────────────────────────────────────────────────────
// All mailbox + ingestion controls live here, segregated from the report.
// Live ingest progress. Scanning = count up (no total yet, Nylas gives none until
// paged); processing = accurate "N of total" bar where total is locked once.
function IngestProgress({ progress }) {
const { phase, processed, total } = progress || {};
const isProcessing = phase === "processing" && total > 0;
if (isProcessing) {
const pct = Math.min(100, Math.round((processed / total) * 100));
return (
Found {total.toLocaleString()} emails. Processed {processed.toLocaleString()} of {total.toLocaleString()}.
);
}
return (
Scanning inbox… {(processed || 0).toLocaleString()} emails found
);
}
function IngestionTab({ filter, onFilterChange, onIngest, ingestState, ingestProgress, user, resourcesApi }) {
const [accounts, setAccounts] = React.useState(null); // null = loading
const [connecting, setConnecting] = React.useState(false);
const [error, setError] = React.useState(null);
const [removed, setRemoved] = React.useState(null); // last removed email, for the toast
const [aiResources, setAiResources] = React.useState([]); // AI_INGESTION resources awaiting confirmation
const [selected, setSelected] = React.useState(() => new Set()); // ids ticked for bulk confirm/remove
const [editId, setEditId] = React.useState(null); // ai resource being inline-renamed
const [editName, setEditName] = React.useState("");
const [bulkBusy, setBulkBusy] = React.useState(false);
const refresh = React.useCallback(async () => {
try { setAccounts(await fetchAccounts()); }
catch (err) { setError(err.message); setAccounts([]); }
}, []);
React.useEffect(() => { refresh(); }, [refresh]);
const refreshAi = React.useCallback(async () => {
try {
const d = await resourcesApi.load();
setAiResources((d.items || []).filter(it => it.source === "AI_INGESTION"));
} catch (err) { /* leave list as-is on error */ }
}, [resourcesApi]);
React.useEffect(() => { refreshAi(); }, [refreshAi]);
function toggleOne(id) {
setSelected(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
function toggleAll() {
setSelected(s => s.size === aiResources.length ? new Set() : new Set(aiResources.map(r => r.id)));
}
async function bulkConfirm() {
setBulkBusy(true);
try { for (const id of selected) await resourcesApi.confirm(id); setSelected(new Set()); await refreshAi(); }
catch (err) { setError(err.message); } finally { setBulkBusy(false); }
}
async function bulkRemove() {
if (!window.confirm(`Remove ${selected.size} resource(s)? This can't be undone.`)) return;
setBulkBusy(true);
try { for (const id of selected) await resourcesApi.remove(id); setSelected(new Set()); await refreshAi(); }
catch (err) { setError(err.message); } finally { setBulkBusy(false); }
}
function startEditAi(it) { setEditId(it.id); setEditName(it.name || ""); }
async function saveEditAi(it) {
setBulkBusy(true);
try {
await resourcesApi.edit(it.id, { resource_type_id: it.resource_type_id, name: editName.trim(), fields: it.details || {} });
setEditId(null); await refreshAi();
} catch (err) { setError(err.message); } finally { setBulkBusy(false); }
}
async function handleConnect() {
setError(null); setConnecting(true);
try {
const client = await getNylasConnect();
// connect() reuses any cached session and skips the popup, so you can't add
// a second mailbox (or re-pick after deleting one). logout() clears the
// cached session first, forcing a fresh account picker. Safe: the SDK's
// local session isn't used after connect — the grant lives in our DB.
try { await client.logout(); } catch (e) {}
const result = await client.connect({ method: "popup" });
const grantId = result?.grantId;
const email = result?.grantInfo?.email || result?.email;
if (!grantId) throw new Error("Connection cancelled");
await postAccount(grantId, email);
await refresh();
} catch (err) {
console.error("Nylas connect failed:", err);
setError(err.message || "Could not connect mailbox");
} finally { setConnecting(false); }
}
async function handleRemove(id, email) {
if (!window.confirm(`Remove ${email || "this mailbox"}? This permanently deletes its synced email data — your handover won't include it again unless you reconnect and re-ingest.`)) return;
setError(null); setRemoved(null);
try {
await deleteAccount(id);
// No need to clear the SDK session here — handleConnect calls logout()
// before every connect(), so a reconnect always forces a fresh picker.
await refresh();
setRemoved(email);
}
catch (err) { setError(err.message || "Could not remove mailbox"); }
}
const busy = ingestState === "loading";
if (accounts === null) {
return ;
}
// Welcome — no mailbox yet
if (accounts.length === 0) {
const firstName = (user?.name || "").trim().split(/\s+/)[0] || "";
return (
AI Mailbox Ingestion
Welcome{firstName ? " " + firstName : ""}.
Would you like to connect your mailbox? We read it once and turn renewals,
accounts and subscriptions into one clear handover.
{removed &&
Removed mailbox ({removed}).
}
{error &&
{error}
}
);
}
// Connected — guided steps
return (
AI Mailbox Ingestion
Your mailbox, organised.
-
1
Connected mailboxes
{accounts.map(a => (
-
{InboxIcon(18)}
{a.email_address}
))}
{removed &&
Removed mailbox ({removed}).
}
{error &&
{error}
}
-
2
Ignore any organisations?
Narrow ingestion to specific senders, or leave it open to all.
-
3
Run ingestion
Pull your emails and build the handover.
{busy && ingestProgress &&
}
-
4
Confirm your AI-discovered resources
Review what the AI found. Tick the ones to confirm or remove, edit a name inline, or describe changes below.
{aiResources.length === 0 ? (
Nothing to confirm yet — run an ingestion above.
) : (
{aiResources.map(it => (
toggleOne(it.id)} />
{editId === it.id
?
setEditName(e.target.value)} />
:
{it.name}
}
{editId === it.id
?
:
}
))}
)}
);
}
// ─── Ask AI (global button + popup) ──────────────────────────────────────────
function AskAiButton({ onClick }) {
return (
);
}
function AskAiModal({ onClose }) {
const [text, setText] = React.useState("");
const [recording, setRecording] = React.useState(false);
const [seconds, setSeconds] = React.useState(0);
const [submitted, setSubmitted] = React.useState(false);
// recording timer — visual only, no real microphone access (coming-soon shell)
React.useEffect(() => {
if (!recording) return;
const id = setInterval(() => setSeconds(s => s + 1), 1000);
return () => clearInterval(id);
}, [recording]);
// Escape closes the popup
React.useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const toggleRec = () => {
if (recording) { setRecording(false); return; }
setSeconds(0); setRecording(true);
};
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const canSubmit = text.trim().length > 0 || seconds > 0;
return (
{ if (e.target === e.currentTarget) onClose(); }}>
Ask AI
What would you like to change?
{submitted ? (
✓
Thanks — this is coming soon.
) : (
Speak or type what you’d like to change.
{recording
? `Recording… ${fmt(seconds)}`
: seconds > 0
? `Recorded ${fmt(seconds)} — tap to re-record`
: "Tap to record"}
)}
);
}
// ─── AuthedShell ──────────────────────────────────────────────────────────────
// Time-of-day greeting for the top bar. Falls back to no name if Clerk has none.
function _greeting(name) {
const h = new Date().getHours();
const part = h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
return name ? `${part}, ${name}` : part;
}
function AuthedShell({ user, report, tab, onTabChange, onLogout, onIngest, ingestState, ingestProgress, filter, onFilterChange, onOpenOrgReport, wizardData, onWizardSave, onResourcesChanged }) {
const initials = (user.email || "").split("@")[0].split(".").map(s => s[0]).join("").slice(0, 2).toUpperCase();
const [activeCategory, setActiveCategory] = React.useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
const [askAiOpen, setAskAiOpen] = React.useState(false);
const [mapItems, setMapItems] = React.useState(null); // /user-resources for the Map tab
const userBtnRef = React.useRef(null);
// Lazy-load the resource map data the first time the Map tab is opened.
React.useEffect(() => {
if (tab !== "map" || mapItems !== null) return;
let alive = true;
fetchUserResources()
.then((d) => { if (alive) setMapItems(d.user_resources || []); })
.catch(() => { if (alive) setMapItems([]); });
return () => { alive = false; };
}, [tab, mapItems]);
React.useEffect(() => {
if (userBtnRef.current && window.__clerk && window.__clerk.isSignedIn) {
window.__clerk.mountUserButton(userBtnRef.current);
const el = userBtnRef.current;
return () => window.__clerk.unmountUserButton(el);
}
}, []);
return (
{askAiOpen &&
setAskAiOpen(false)} />}
{_greeting(user.name)}
setAskAiOpen(true)} />
{tab === "home" ? (
) : tab === "report" ? (
) : tab === "ingestion" ? (
) : tab === "wizard" ? (
wizardData && window.WizardHub
?
:
) : tab === "map" ? (
(mapItems !== null && window.ResourceMap)
?
:
) : tab === "people" ? (
wizardData && window.PeopleTab
?
:
) : (
<>
setSidebarCollapsed(v => !v)}
counts={Object.fromEntries(Object.entries(report.orgs_by_category || {}).map(([k, v]) => [k, v.length]))}
categoryFamily={report.category_family} />
>
)}
);
}
// ─── Tweaks / Palettes ────────────────────────────────────────────────────────
const PALETTES = {
vivid: { accent: "#E25A4C", accentInk: "#fafafa", swatch: "#E25A4C", label: "Vivid (default)" },
citrus: { accent: "#D97706", accentInk: "#fafafa", swatch: "#D97706", label: "Citrus" },
electric:{ accent: "#2563EB", accentInk: "#fafafa", swatch: "#2563EB", label: "Electric" },
moss: { accent: "#4f5a3d", accentInk: "#fafafa", swatch: "#5a6647", label: "Moss (calm)" },
ink: { accent: "#161616", accentInk: "#faf7f2", swatch: "#161616", label: "Ink (monochrome)" },
};
function AppTweaks({ tweaks, setTweak }) {
return (
setTweak("palette", v)}
options={Object.entries(PALETTES).map(([k, p]) => ({ value: k, label: p.label }))}
/>
setTweak("familyColors", v)}
/>
setTweak("ambient", v)}
/>
setTweak("displayFont", v)}
options={[
{ value: "Instrument Serif", label: "Instrument Serif" },
{ value: "Newsreader", label: "Newsreader" },
{ value: "Geist", label: "Sans (Geist)" },
]}
/>
);
}
// ─── Root App ─────────────────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"palette": "vivid",
"ambient": true,
"displayFont": "Instrument Serif",
"familyColors": true
}/*EDITMODE-END*/;
function App() {
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
// Possible values: "checking" | "login" | "loading" | "ready" | "error"
const [appState, setAppState] = useState("checking");
const [tab, setTab] = useState("home");
const [user, setUser] = useState(null);
const [report, setReport] = useState(null);
const [ingestState, setIngestState] = useState(null); // null | "loading" | "done" | "error"
const [ingestProgress, setIngestProgress] = useState(null); // { phase, processed, total } | null
const [error, setError] = useState(null);
const [transitioning, setTransitioning] = useState(false);
const [filter, setFilter] = useState({ senders: [], selected: null });
const [orgReport, setOrgReport] = React.useState(null); // org object or null
const [wizardData, setWizardData] = useState(null); // GET /wizard payload
const [showDetails, setShowDetails] = useState(false); // blocking personal-details capture, before the wizard
const [showWizard, setShowWizard] = useState(false); // first-login modal over the app
const [showGaps, setShowGaps] = useState(false); // post-ingest "fill AI gaps" popup
const [showIngestDone, setShowIngestDone] = useState(false); // post-ingest "all done" popup (when there are no gaps)
// Apply palette/font tweaks
useEffect(() => {
const p = PALETTES[tweaks.palette] || PALETTES.vivid;
document.documentElement.style.setProperty("--accent", p.accent);
document.documentElement.style.setProperty("--accent-ink", p.accentInk);
document.documentElement.style.setProperty("--display-font", `"${tweaks.displayFont}"`);
document.documentElement.dataset.familyColors = tweaks.familyColors ? "on" : "off";
}, [tweaks.palette, tweaks.displayFont, tweaks.familyColors]);
// Re-entrancy guard + current-state ref (listener closure would otherwise see
// stale `appState` / re-fire checkAuth while one is already in flight).
const checkingRef = useRef(false);
const appStateRef = useRef(appState);
appStateRef.current = appState;
// On mount: check auth + listen for Clerk auth-state flips.
// The mounted SignIn component's forceRedirectUrl is a fragile fallback in an
// SPA; this listener reliably moves us to the dashboard the moment Clerk signs
// the user in, without a hard page reload.
useEffect(() => {
let cancelled = false;
let unsub = null;
(async () => {
try {
if (await handleNylasCallbackIfPresent()) return;
if (cancelled) return;
checkAuth();
await initClerk();
if (!clerk) return;
unsub = clerk.addListener(() => {
// Only react to a fresh sign-in while we're sitting on the login screen;
// ignore while checking/loading/ready so we never double-load.
if (clerk.isSignedIn && appStateRef.current === "login" && !checkingRef.current) {
checkAuth();
}
});
} catch (err) {
if (cancelled) return;
setError(err.message || "Authentication callback failed");
setAppState("error");
}
})();
return () => {
cancelled = true;
if (unsub) unsub();
};
}, []);
async function checkAuth() {
if (checkingRef.current) return; // already in flight
checkingRef.current = true;
try {
await initClerk();
if (clerk && !clerk.isSignedIn) {
setAppState("login"); // show Clerk sign-in
return;
}
const me = await fetchMe();
if (!me.person_id) { // not signed in (status: "not signed in")
setAppState("login");
return;
}
// Signed in → straight to the dashboard. An inbox connection is optional
// and handled separately by the "Sync" action (handleIngest), not here.
// /auth/status has no email; pull it from Clerk for the avatar initials.
const email = clerk?.user?.primaryEmailAddress?.emailAddress || "";
// Clerk name is only a fallback. The report's full name + address are the
// source of truth from the DB-backed wizard profile, loaded just below.
const cu = clerk?.user;
const clerkName = cu?.firstName || cu?.fullName || (email ? email.split("@")[0] : "") || "";
// Load the wizard (profile) first so the user object carries the real full
// name + address; then pop the one-time wizard modal if not done.
let profile = {};
try {
const w = await fetchWizard();
setWizardData(w);
profile = w.profile || {};
// Personal details come first and block. Only pop the discovery wizard
// once we already have the core details (otherwise it waits — see the
// details modal's onSubmit, which chains into the wizard).
if (needsPersonalDetails(profile)) setShowDetails(true);
else if (!w.completed) setShowWizard(true);
} catch (err) {
console.error("wizard load failed:", err); // fail loud; don't block the app
}
// Profile keys: first_name/surname (name) and address_line1/town/county/postcode (address).
const profileName = [profile.first_name, profile.surname].filter(Boolean).join(" ");
const address = [profile.address_line1, profile.town, profile.county, profile.postcode]
.filter(Boolean).join(", ");
setUser({ ...me, email, name: profileName || clerkName, address });
await loadData(me);
resumeIngestIfRunning(); // fire-and-forget: re-attach to an in-flight ingest from a prior visit
} catch (err) {
if (err.status === 401) {
setAppState("login");
} else {
setError(err.message);
setAppState("error");
}
} finally {
checkingRef.current = false;
}
}
async function loadData(me) {
setAppState("loading");
try {
const [resourcesResp, filterResp] = await Promise.all([
fetchOrgSummaries(),
fetchFilters().catch(() => ({ senders: [], selected: null })),
]);
const transformed = transformApiData(resourcesResp);
setReport(transformed);
setFilter(filterResp);
setTab("home");
setAppState("ready");
} catch (err) {
setError(err.message);
setAppState("error");
}
}
// Reload the dashboard/report data. One source of truth for "the underlying
// resources changed — pull them again" (ingest finished, gaps filled, manual add).
async function refreshReport() {
try { setReport(transformApiData(await fetchOrgSummaries())); } catch (_) {}
}
// Ingest finished for real — refresh the dashboard and surface any AI gaps.
// Shared by the foreground poll and the on-load resume check, so the completion
// popup only ever fires when the worker is genuinely done.
async function finishIngest() {
await refreshReport();
setIngestProgress(null);
setIngestState("done");
setTimeout(() => setIngestState(null), 2500);
// Always surface a completion popup when the ingest finishes. If the AI left
// gaps, show the actionable gaps modal; otherwise show a simple "done"
// confirmation so the user always gets a clear signal it finished.
try {
const gaps = await fetchGaps();
if (gaps.missing_details.length > 0 || gaps.coverage.length > 0) setShowGaps(true);
else setShowIngestDone(true);
} catch (_) { setShowIngestDone(true); } // gaps fetch failed → still confirm completion
}
// Poll the worker's job status until it stops running, then act on the result.
// SAQ reports "complete" when done, or drops the record → "idle"; both mean done.
// We poll for the full 30-min job lifetime so we never give up early and pop the
// "done" modal while the worker is still running (the bug with large inboxes).
async function pollIngestToCompletion() {
const RUNNING = new Set(["queued", "active", "new", "aborting"]);
let status = "queued";
let seenRunning = false; // did we ever observe the job actually running?
let idleStreak = 0;
for (let i = 0; i < 900; i++) { // 900 × 2s = 30 min, matches the SAQ job timeout
await new Promise(r => setTimeout(r, 2000));
let resp;
try { resp = await fetchIngestStatus(); status = resp.status; } catch (_) { continue; }
if (RUNNING.has(status)) {
seenRunning = true;
// Surface scanning/processing counts for the UI bar.
setIngestProgress({ phase: resp.phase, processed: resp.processed, total: resp.total });
continue; // job is working — keep waiting
}
// A status of "idle" means SAQ has no job record for us. Right after enqueue
// that just means the job isn't visible yet — NOT that it finished. Only believe
// an "idle" we never saw run after a short grace period (else a fast-failing job
// that gets swept looks like instant success → premature popup).
if (status === "idle" && !seenRunning && ++idleStreak < 5) continue; // ~10s grace
break; // complete | failed | no_user | settled idle
}
if (status === "failed" || status === "no_user") { setIngestProgress(null); setIngestState("error"); return; }
if (RUNNING.has(status)) { setIngestProgress(null); setIngestState(null); return; } // 30-min cap, still running
if (status === "idle" && !seenRunning) { setIngestProgress(null); setIngestState("error"); return; } // job vanished without ever running = failed + swept
await finishIngest(); // complete, or idle after we saw it run → genuinely done
}
async function handleIngest() {
setIngestState("loading");
try {
await triggerIngest(); // 401/errors throw → caught below
await pollIngestToCompletion();
} catch (err) {
setIngestState("error");
}
}
// On load, re-attach to an ingest that's still running from a previous visit, so
// the completion popup fires even if the page was reloaded or left mid-ingest. If
// it already finished, the dashboard load already has fresh data — no popup, to
// avoid re-popping the gaps modal on every visit.
async function resumeIngestIfRunning() {
try {
const { status } = await fetchIngestStatus();
if (["queued", "active", "new", "aborting"].includes(status)) {
setIngestState("loading");
await pollIngestToCompletion();
}
} catch (_) {}
}
async function closeGaps() {
setShowGaps(false);
// The user may have filled gaps — refresh so newly-completed resources show.
await refreshReport();
}
async function handleTabChange(t) {
// The Wizard and People tabs both edit saved answers — always open with fresh data.
if (t === "wizard" || t === "people") {
try { setWizardData(await fetchWizard()); } catch (_) {}
}
setTab(t);
}
async function handleWizardSave(answers) {
const w = await postWizardAnswers(answers);
setWizardData(w);
}
function handleLogout() {
setTransitioning(true);
// Sign Clerk out first, then clear the Nylas cookie via /auth/logout
const go = () => { window.location.href = "/auth/logout"; };
if (window.__clerk) window.__clerk.signOut().then(go, go);
else setTimeout(go, 220);
}
if (appState === "checking") {
return (
);
}
if (appState === "error") {
return (
Something went wrong
{error}
);
}
if (appState === "loading") {
return (
);
}
return (
<>
{appState === "login" &&
}
{appState === "ready" && (
)}
{showDetails && window.PersonalDetailsSection && wizardData && (
{
const w = await postWizardAnswers(answers);
setWizardData(w);
setShowDetails(false);
// Chain straight into the discovery wizard if it's still pending.
if (!w.completed) setShowWizard(true);
}}
/>
)}
{showWizard && window.DiscoveryWizard && wizardData && (
{
setShowWizard(false);
if (path === "email") setTab("ingestion");
else await handleTabChange("wizard");
}}
/>
)}
{showGaps && window.GapsSection && (
{ if (e.target === e.currentTarget) closeGaps(); }}>
)}
{showIngestDone && (
{ if (e.target === e.currentTarget) setShowIngestDone(false); }}>
✓ Your handover is ready
We’ve finished reading your mailbox and updated your handover.
)}
{orgReport && window.OrgReportModal && (
setOrgReport(null)} />
)}
>
);
}
if (typeof window !== "undefined") window.Wordmark = Wordmark;
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render();