// intelligence → signals (trade signal cards + detail popover) // cards are glance-only; clicking opens a modal with the full breakdown. // depends on: app.js, intel-shared.js // full dataset kept in memory so filters/sort never re-fetch let allSignals = []; // local lookup of signal rows keyed by company_id — so the modal can // render without re-fetching. references are still lazy-loaded per open. const signalsById = new Map(); function renderSignals(data) { const grid = document.getElementById("intel-signals-grid"); const empty = document.getElementById("intel-signals-empty"); if (!data || data.length === 0) { grid.innerHTML = ""; empty.style.display = ""; return; } empty.style.display = "none"; // glance tile — header, a short summary preview, the three meta tags // (conf/risk/timeframe), and the most recent source-event date. drivers, // risks, refs etc live in the dialog. grid.innerHTML = data.map(s => { const summary = (s.summary || "").trim(); const evDate = s.latest_event_date ? s.latest_event_date.slice(0, 10) : ""; return `
${escapeHtml(s.company_name)}
${escapeHtml(s.ticker)}
${s.signal}
${summary ? `

${escapeHtml(summary)}

` : ""}
conf: ${escapeHtml(s.confidence)} risk: ${escapeHtml(s.risk_level)} ${escapeHtml(s.timeframe)}
${evDate ? `
Latest event ${escapeHtml(evDate)}
` : ""}
`; }).join(""); } function applyFilters() { const search = (document.getElementById("sf-search").value || "").trim().toLowerCase(); const signal = document.getElementById("sf-signal").value; const confidence = document.getElementById("sf-confidence").value; const risk = document.getElementById("sf-risk").value; const sort = document.getElementById("sf-sort").value; let results = allSignals.filter(s => { if (search && !s.company_name.toLowerCase().includes(search) && !s.ticker.toLowerCase().includes(search)) return false; if (signal && s.signal !== signal) return false; if (confidence && (s.confidence || "").toLowerCase() !== confidence) return false; if (risk && (s.risk_level || "").toLowerCase() !== risk) return false; return true; }); results.sort((a, b) => { if (sort === "event_desc") { const da = a.latest_event_date || ""; const db = b.latest_event_date || ""; return da < db ? 1 : da > db ? -1 : 0; } if (sort === "company_asc") return a.company_name.localeCompare(b.company_name); if (sort === "company_desc") return b.company_name.localeCompare(a.company_name); // generated_desc (default) const ga = a.generated_at || ""; const gb = b.generated_at || ""; return ga < gb ? 1 : ga > gb ? -1 : 0; }); renderSignals(results); } async function loadSignals() { let data; try { data = await api("/admin/api/intelligence/signals"); } catch (_) { data = []; } allSignals = data || []; signalsById.clear(); allSignals.forEach(s => signalsById.set(s.company_id, s)); applyFilters(); } let currentSignal = null; function openSignalModal(companyId) { const s = signalsById.get(companyId); if (!s) return; currentSignal = s; document.getElementById("signal-modal-company").textContent = s.company_name || ""; document.getElementById("signal-modal-ticker").textContent = s.ticker || ""; const badge = document.getElementById("signal-modal-badge"); badge.className = `signal-badge ${s.signal}`; badge.textContent = s.signal; document.getElementById("signal-modal-tags").innerHTML = [ `conf: ${escapeHtml(s.confidence)}`, `risk: ${escapeHtml(s.risk_level)}`, `${escapeHtml(s.timeframe)}`, ].join(""); document.getElementById("signal-modal-summary").textContent = s.summary || "—"; let drivers = []; let risks = []; let predIds = []; try { drivers = JSON.parse(s.key_drivers || "[]"); } catch (_) {} try { risks = JSON.parse(s.risk_factors || "[]"); } catch (_) {} try { predIds = JSON.parse(s.supporting_prediction_ids || "[]"); } catch (_) {} const driversWrap = document.getElementById("signal-modal-drivers-wrap"); const risksWrap = document.getElementById("signal-modal-risks-wrap"); if (drivers.length) { driversWrap.style.display = ""; document.getElementById("signal-modal-drivers").innerHTML = drivers.map(d => `
  • ${escapeHtml(d)}
  • `).join(""); } else { driversWrap.style.display = "none"; } if (risks.length) { risksWrap.style.display = ""; document.getElementById("signal-modal-risks").innerHTML = risks.map(r => `
  • ${escapeHtml(r)}
  • `).join(""); } else { risksWrap.style.display = "none"; } document.getElementById("signal-modal-datausage").textContent = `${predIds.length} predictions · 90d window`; document.getElementById("signal-modal-ts").textContent = s.generated_at ? s.generated_at.slice(0, 16).replace("T", " ") : "—"; // reset references panel and lazy-load document.getElementById("signal-modal-refs").innerHTML = `

    Loading…

    `; document.getElementById("signalOverlay").classList.add("open"); loadSignalReferences(companyId); } function closeSignalModal() { document.getElementById("signalOverlay").classList.remove("open"); currentSignal = null; } async function loadSignalReferences(companyId) { const container = document.getElementById("signal-modal-refs"); let data; try { data = await api(`/admin/api/intelligence/signals/${companyId}/references`); } catch (_) { container.innerHTML = `

    Failed to load references

    `; return; } // guard against stale responses if the user closed/switched modals mid-request if (!currentSignal || currentSignal.company_id !== companyId) return; const events = data?.events || []; if (!events.length) { container.innerHTML = `

    No linked events

    `; return; } container.innerHTML = events.map(ev => { const dateStr = (ev.event_date || ev.created_at || "").slice(0, 10); const articles = (ev.articles || []).map(a => { const src = a.source ? `${escapeHtml(a.source)}` : ""; const pd = a.pub_date ? `${escapeHtml(a.pub_date.slice(0, 10))}` : ""; return `
  • ${escapeHtml(a.title || a.url)} ${src}${pd}
  • `; }).join(""); return `
    ${escapeHtml(ev.title || `Event #${ev.id}`)} ${dateStr ? `${dateStr}` : ""}
    ${articles ? `` : `

    No articles linked

    `}
    `; }).join(""); } async function regenerateSignal() { if (!currentSignal) return; const companyId = currentSignal.company_id; try { await api(`/admin/api/intelligence/signals/${companyId}`, { method: "DELETE" }); toast("Signal cleared — worker will regenerate on next cycle"); signalsById.delete(companyId); allSignals = allSignals.filter(s => s.company_id !== companyId); closeSignalModal(); applyFilters(); } catch (_) { toast("Failed to clear signal", true); } } document.addEventListener("DOMContentLoaded", () => { document.getElementById("signalCloseBtn").onclick = closeSignalModal; document.getElementById("signalRegenBtn").onclick = regenerateSignal; document.getElementById("sf-search").addEventListener("input", applyFilters); document.getElementById("sf-signal").addEventListener("change", applyFilters); document.getElementById("sf-confidence").addEventListener("change", applyFilters); document.getElementById("sf-risk").addEventListener("change", applyFilters); document.getElementById("sf-sort").addEventListener("change", applyFilters); // fire both in parallel — signals api returns [] when intelligence // db is unavailable, so no need to gate on the stats call Promise.all([loadIntelStatsRow(), loadSignals()]); });