// News Scan — merged Scan + Update view.
// dateMode "update": runs at most 24h back from last run. "custom": explicit date range.
// dateMode "custom": user-specified date range.
const { useState: useStateS, useEffect: useEffectS, useRef: useRefS, useMemo: useMemoS } = React;
function ScanView({ tweaks }) {
const COMPANIES = (window.DATA?.allScanEntities || window.DATA?.companies || [])
.slice().sort((a, b) => a.name.localeCompare(b.name));
const UNIVERSES = window.EXTRAS.universes || [];
const publicMode = window.DATA?.publicMode === true;
const scanEnabled = !publicMode && window.DATA?.uiScanEnabled === true;
const today = new Date().toISOString().slice(0, 10);
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
const [dateMode, setDateMode] = useStateS("update"); // update | custom
const [scope, setScope] = useStateS("entity"); // entity | universe | all
const [entity, setEntity] = useStateS(COMPANIES[0]);
const [universe, setUniverse] = useStateS(UNIVERSES[0] || null);
const [startDate, setStartDate] = useStateS(weekAgo);
const [endDate, setEndDate] = useStateS(today);
const [startTime, setStartTime] = useStateS("00:00");
const [endTime, setEndTime] = useStateS("23:59");
const [boundaryTime, setBoundaryTime] = useStateS("00:00");
const [updateEndDate, setUpdateEndDate] = useStateS(today); // optional end date for update mode
const [sources, setSources] = useStateS(["news_premium"]);
// Run state
const [mode, setMode] = useStateS("configure"); // configure | running | done
const [scanParams, setScanParams] = useStateS(null);
const [scanResults, setScanResults] = useStateS(null);
const [runError, setRunError] = useStateS(null);
const pollRef = useRefS(null);
function resetToConfig() {
setMode("configure");
setScanParams(null);
setScanResults(null);
setRunError(null);
}
// ── Poll ────────────────────────────────────────────────────────
useEffectS(() => {
if (mode !== "running" || !scanParams) return;
const poll = () => {
if (scanParams.batch_id) {
// Update mode: poll the exact batch submission — immune to pre-existing runs
fetch(`/api/v1/batch/parallel/${scanParams.batch_id}/status`)
.then(r => r.json())
.then(data => {
const entities = (data.runs || []).map(r => {
const company = COMPANIES.find(c => c.id === r.entity_id);
const dayStatus = r.status === "not_started" ? "pending" : r.status;
return {
entityId: r.entity_id,
entityName: company?.name || r.entity_id,
days: [{ date: scanParams.end_date, status: dayStatus, error: r.error_message }],
};
});
const completed = (data.succeeded || 0) + (data.failed || 0);
setScanResults({ entities, total: data.total, completed });
if ((data.running || 0) === 0 && (data.not_started || 0) === 0) {
clearInterval(pollRef.current);
setMode("done");
}
})
.catch(console.error);
} else {
// Custom scan mode: poll by date range
const ids = scanParams.entity_ids.join(",");
fetch(`/api/v1/scan/status?entity_ids=${ids}&start_date=${scanParams.start_date}&end_date=${scanParams.end_date}`)
.then(r => r.json())
.then(data => {
setScanResults(data);
if (data.completed >= data.total) { clearInterval(pollRef.current); setMode("done"); }
})
.catch(console.error);
}
};
poll();
pollRef.current = setInterval(poll, 3000);
return () => clearInterval(pollRef.current);
}, [mode, scanParams]);
// ── Daily update: start run (at most 24h back from updateEndDate) ──
function startUpdate() {
setRunError(null);
const endStr = updateEndDate || today;
function doStart(idsForPolling) {
const body = { window_mode: "update", categories: sources, generate_narrative: true };
if (scope === "entity") body.entity_ids = [entity?.id].filter(Boolean);
else if (scope === "universe") body.universe = universe?.id;
// scope === "all": no entity_ids — backend defaults to all DB entities
if (updateEndDate !== today) body.force_window_end = updateEndDate + "T23:59:59Z";
fetch("/api/v1/batch/run-parallel", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then(r => r.json())
.then(data => {
if (data.detail || data.error) { setRunError(data.detail || data.error); return; }
setScanParams({ entity_ids: idsForPolling, start_date: endStr, end_date: endStr, total_windows: data.total, batch_id: data.batch_id });
setScanResults(null);
setMode("running");
})
.catch(err => setRunError(String(err)));
}
if (scope === "entity") {
doStart([entity?.id].filter(Boolean));
} else {
// Silently fetch preview to get entity IDs for status polling
let url = `/api/v1/scan/preview?scope=${scope}`;
if (scope === "universe" && universe) url += `&universe=${universe.id}`;
fetch(url)
.then(r => r.json())
.then(data => {
if (data.error) { setRunError(data.error); return; }
doStart((data.entities || []).map(r => r.entity_id));
})
.catch(err => setRunError(String(err)));
}
}
// ── Custom: start scan ──────────────────────────────────────────
function startCustomScan() {
setRunError(null);
if (scope === "entity" && !entity) { setRunError("Select an entity first."); return; }
if (scope === "universe" && !universe) { setRunError("Select a universe first."); return; }
const body = {
start_date: startDate, end_date: endDate,
source_categories: sources,
...(startTime !== "00:00" && { start_time: startTime }),
...(endTime !== "23:59" && { end_time: endTime }),
...(boundaryTime !== "00:00" && { boundary_time: boundaryTime }),
};
if (scope === "entity") body.entity_id = entity.id;
if (scope === "universe") body.universe = universe.id;
fetch("/api/v1/scan", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then(r => r.json())
.then(data => {
if (data.detail || data.error) { setRunError(data.detail || data.error); return; }
// Core endpoint returns ScanResponse (single) or UniverseScanResponse (universe)
let entityIds, totalWindows;
if (data.scans) {
// UniverseScanResponse: { scans: [{scan_id, entity_id, windows_total, start, end}], total_entities, universe }
entityIds = data.scans.map(s => s.entity_id);
totalWindows = data.scans.reduce((sum, s) => sum + s.windows_total, 0);
} else {
// ScanResponse: { scan_id, entity_id, windows_total, start, end }
entityIds = [data.entity_id];
totalWindows = data.windows_total;
}
setScanParams({ entity_ids: entityIds, start_date: startDate, end_date: endDate, total_windows: totalWindows });
setScanResults(null);
setMode("running");
})
.catch(err => setRunError(String(err)));
}
// ── Aggregated stats ────────────────────────────────────────────
const agg = useMemoS(() => {
const out = { succeeded: 0, failed: 0, skipped: 0, pending: 0, running: 0, saved: 0, discarded: 0 };
for (const e of scanResults?.entities || []) {
for (const d of e.days || []) {
if (d.status === "succeeded") { out.succeeded++; out.saved += d.saved||0; out.discarded += d.discarded||0; }
else if (d.status === "failed") out.failed++;
else if (d.status === "skipped") out.skipped++;
else if (d.status === "running") out.running++;
else out.pending++;
}
}
return out;
}, [scanResults]);
// ── Day calendar data (custom mode) ────────────────────────────
const allDays = useMemoS(() => {
if (!scanResults?.entities?.length) return [];
const byDate = {};
for (const e of scanResults.entities) {
for (const d of e.days || []) {
if (!byDate[d.date]) byDate[d.date] = [];
byDate[d.date].push({ ...d, entityName: e.entityName });
}
}
return Object.keys(byDate).sort().map(date => {
const cells = byDate[date];
const st = cells.map(c => c.status);
const any = x => st.some(s => s === x);
let status = "pending";
if (any("running")) status = "running";
else if (any("pending")) status = "pending";
else if (any("failed")) status = "failed";
else if (st.every(s => s === "skipped")) status = "skipped";
else if (st.every(s => s === "succeeded" || s === "skipped")) status = "succeeded";
const saved = cells.reduce((s, c) => s + (c.status === "succeeded" ? (c.saved||0) : 0), 0);
const discarded = cells.reduce((s, c) => s + (c.status === "succeeded" ? (c.discarded||0) : 0), 0);
const errCell = cells.find(c => c.status === "failed");
return { date, status, saved, discarded, empty: status === "succeeded" && saved === 0, error: errCell?.error };
});
}, [scanResults]);
const entities = scanResults?.entities || [];
const totalDays = scanResults?.total || scanParams?.total_windows || 0;
const completed = scanResults?.completed || 0;
const pct = totalDays > 0 ? Math.min(100, (completed / totalDays) * 100) : 0;
const entityRowsDone = useMemoS(() => {
const terminal = d => ["succeeded","failed","skipped"].includes(d.status);
return entities.filter(e => e.days?.length > 0 && e.days.every(terminal)).length;
}, [scanResults]);
const scopeLabel = scope === "entity" ? (entity?.name || "—") : scope === "universe" ? (universe?.label || "—") : "All entities";
const isRunning = mode === "running" || mode === "done";
// ── Step numbering ──────────────────────────────────────────────
// update mode: 01 Date mode, 02 Scope, 03 Entity/Universe, last Sources
// custom mode: 01 Date mode, 02 Scope, 03 Entity/Universe, 04 Date range, last Sources
const entityStepNum = "03";
const dateStepNum = scope === "all" ? "03" : "04";
const srcStepNum = dateMode === "update"
? (scope === "all" ? "03" : "04")
: (scope === "all" ? "04" : "05");
return (
{/* ── Left: configure ── */}
{/* 01 — Date mode */}
01
Date range
{ setDateMode("update"); resetToConfig(); }}>
Update
Last 24h from last run
{ setDateMode("custom"); if (scope === "all") setScope("entity"); resetToConfig(); }}>
Custom range
Pick start and end dates
{dateMode === "update" && (
End date
{ setUpdateEndDate(e.target.value); resetToConfig(); }} />
)}
{/* 02 — Scope */}
02
Scope
{ setScope("entity"); resetToConfig(); }}>
Single entity
One company
{ setScope("universe"); resetToConfig(); }}>
Universe
All in universe
{dateMode === "update" && (
{ setScope("all"); resetToConfig(); }}>
All entities
All previously run companies
)}
{/* 03 — Entity picker */}
{scope === "entity" && (
{entityStepNum}
Entity
{ setEntity(COMPANIES.find(c => c.id === e.target.value)); resetToConfig(); }}>
{COMPANIES.map(c => {c.name} · {c.id} )}
)}
{/* 03 — Universe picker */}
{scope === "universe" && (
{entityStepNum}
Universe
{UNIVERSES.map(u => (
{ setUniverse(u); resetToConfig(); }}>
{u.label}
{u.count}
{u.description}
))}
)}
{/* Date picker — custom mode only */}
{dateMode === "custom" && (
)}
{/* Sources */}
{runError && (
{runError}
)}
{/* Cost estimate */}
{/* Action buttons */}
{mode === "configure" && dateMode === "update" && (
▶ Start update{publicMode ? " — contact support to enable" : !scanEnabled ? " (disabled)" : ""}
)}
{mode === "configure" && dateMode === "custom" && (
▶ Start scan{publicMode ? " — contact support to enable" : !scanEnabled ? " (disabled)" : ""}
)}
{isRunning && (
{ setMode("configure"); setScanParams(null); setScanResults(null); }}>
New scan
)}
{/* ── Right: results ── */}
{mode === "configure" && (
Configure on the left and click {dateMode === "update" ? "Start update" : "Start scan"}.
)}
{isRunning && (
<>
{mode === "running" ? "Scan in progress" : "Scan complete"}
{scopeLabel}
{mode === "running" && }
{scanParams?.start_date} → {scanParams?.end_date}
·
{completed}
/
{totalDays}
entity×day cells
{entities.length > 1 && (
Entities done ·
{entityRowsDone}
/
{entities.length}
)}
{scanParams?.start_date}
{pct.toFixed(0)}%
{scanParams?.end_date}
Day-cells
{completed}/ {totalDays}
{agg.succeeded} succeeded · {agg.failed} failed · {agg.skipped} skipped
{(agg.pending + agg.running) > 0 && ` · ${agg.pending + agg.running} pending/running`}
Bullets saved
{agg.saved}
Discarded
{agg.discarded}
{/* Update → entity list */}
{dateMode === "update" && entities.length > 0 && (
Entities
{entities.length} in scope
)}
{/* Custom → day calendar */}
{dateMode === "custom" && allDays.length > 0 && (
<>
Day-by-day
{allDays.length} calendar days
Most recent
last {Math.min(12, allDays.filter(d => d.status !== "pending").length)} days
{allDays.filter(d => d.status !== "pending").slice(-12).reverse().map(d => (
))}
>
)}
>
)}
);
}
// ── Per-entity live row ────────────────────────────────────────────
function UpdateEntityRow({ ent }) {
const days = ent.days || [];
const succeeded = days.filter(d => d.status === "succeeded").length;
const failed = days.filter(d => d.status === "failed").length;
const running = days.filter(d => d.status === "running").length;
const pending = days.filter(d => ["pending","skipped"].includes(d.status)).length;
let pill = "pending";
if (running > 0) pill = "running";
else if (days.length > 0 && days.every(d => ["succeeded","failed","skipped"].includes(d.status)))
pill = failed > 0 ? "failed" : "done";
return (
{ent.entityName}
{ent.entityTicker && · {ent.entityTicker} }
{succeeded > 0 && ✓ {succeeded} }
{failed > 0 && ✕ {failed} }
{running > 0 && ● {running} }
{pending > 0 && {pending} pending }
);
}
// ── Cost estimate (custom mode) ────────────────────────────────────
function ScanCostEstimate({ config }) {
const estimates = (window.RUN_DATA && typeof window.RUN_DATA.composeEstimates === "object")
? window.RUN_DATA.composeEstimates : {};
function parseCost(eid) {
const est = estimates[eid];
if (!est?.costDisplay) return null;
const n = parseFloat(String(est.costDisplay).replace(/[^0-9.]/g, ""));
return isNaN(n) ? null : n;
}
const days = useMemoS(() => {
if (!config.startDate || !config.endDate) return 1;
const ms = new Date(config.endDate) - new Date(config.startDate);
return Math.max(1, Math.round(ms / 86400000) + 1);
}, [config.startDate, config.endDate]);
const estimate = useMemoS(() => {
if (config.scope === "entity" && config.entity) {
const c = parseCost(config.entity.id);
return c != null ? { totalCost: c * days, costPerDay: c, nEntities: 1, coveredEntities: 1, label: config.entity.name } : null;
}
if (config.scope === "universe" && config.universe) {
const ids = Array.isArray(config.universe.entity_ids) ? config.universe.entity_ids : [];
let totalPerDay = 0, covered = 0;
for (const eid of ids) { const c = parseCost(eid); if (c != null) { totalPerDay += c; covered++; } }
return covered > 0 ? { totalCost: totalPerDay * days, costPerDay: totalPerDay, nEntities: ids.length, coveredEntities: covered, label: config.universe.label } : null;
}
return null;
}, [config, days]);
if (!estimate) return null;
const fmt = v => v < 0.01 ? "< $0.01" : `$${v.toFixed(2)}`;
return (
{days > 1 ? `Estimated cost for ${days} days` : "Est. cost"}
{days > 1 ? fmt(estimate.totalCost) : `${fmt(estimate.costPerDay)}/day`}
{(days > 1 || estimate.nEntities > 1) && (
{days > 1 && `${fmt(estimate.costPerDay)}/day`}
{days > 1 && estimate.nEntities > 1 && " · "}
{estimate.nEntities > 1 && `${estimate.nEntities} entities`}
)}
);
}
// ── Day calendar (custom mode) ────────────────────────────────────
function DayCalendar({ days, cursorIdx }) {
const weeks = [];
let week = [];
days.forEach(d => {
const date = new Date(d.date + "T00:00:00");
const wd = date.getDay();
if (week.length === 0 && wd !== 1) { const pad = (wd + 6) % 7; for (let p = 0; p < pad; p++) week.push(null); }
week.push(d);
if (wd === 0) { weeks.push(week); week = []; }
});
if (week.length) weeks.push(week);
return (
{["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].map(d =>
{d}
)}
{weeks.map((w, i) => (
{Array.from({ length: 7 }).map((_, j) => {
const cell = w[j];
if (!cell) return
;
const date = new Date(cell.date + "T00:00:00");
const dayNum = date.getDate();
const showMonth = dayNum <= 7;
const cls = `scan-cal-cell scan-cal-${cell.status}${cell.empty ? " scan-cal-no-news" : ""}`;
const tip = `${cell.date} · ${cell.status}${cell.saved !== undefined ? ` · ${cell.saved} saved` : ""}`;
return (
{String(dayNum).padStart(2, "0")}
{showMonth && {date.toLocaleDateString("en-US", { month: "short" })} }
{cell.status === "succeeded" && !cell.empty && {cell.saved} }
{cell.status === "succeeded" && cell.empty && — }
{cell.status === "running" && }
{cell.status === "failed" && ✕ }
{cell.status === "skipped" && · }
);
})}
))}
Bullets found
No news
Running
Failed
Skipped
Pending
);
}
// ── Day row (custom mode) ─────────────────────────────────────────
function ScanDayRow({ day }) {
const date = new Date(day.date + "T00:00:00");
return (
{String(date.getDate()).padStart(2, "0")}
{date.toLocaleDateString("en-US", { month: "short" })}
{date.toLocaleDateString("en-US", { weekday: "short" })}
{day.status === "succeeded" && !day.empty && (
{day.saved} bullets saved · {day.discarded} discarded
)}
{day.status === "succeeded" && day.empty &&
No material developments
}
{day.status === "skipped" &&
Skipped
}
{day.status === "failed" && <>
Failed
{day.error}
>}
{day.status === "running" &&
Running…
}
{day.status === "succeeded" && !day.empty && ✓ {day.saved} }
{day.status === "succeeded" && day.empty && — empty }
{day.status === "skipped" && skipped }
{day.status === "failed" && ✕ failed }
{day.status === "running" && ● live }
);
}
window.ScanView = ScanView;