// 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 ── */} {/* ── 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
    {entities.map(e => )}
)} {/* 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 ( ); })}
    ))}
    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;