209 lines
7 KiB
JavaScript
209 lines
7 KiB
JavaScript
// 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
|
|
|
|
// 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();
|
|
|
|
|
|
async function loadSignals() {
|
|
let data;
|
|
try {
|
|
data = await api("/admin/api/intelligence/signals");
|
|
} catch (_) {
|
|
data = [];
|
|
}
|
|
|
|
signalsById.clear();
|
|
(data || []).forEach(s => signalsById.set(s.company_id, s));
|
|
|
|
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";
|
|
|
|
grid.innerHTML = data.map(s => {
|
|
const firstSentence = (s.summary || "").split(/\.\s+/)[0].replace(/\.$/, "") + ".";
|
|
const ts = s.generated_at ? s.generated_at.slice(0, 16).replace("T", " ") : "—";
|
|
|
|
return `
|
|
<div class="signal-card" onclick="openSignalModal(${s.company_id})">
|
|
<div class="signal-card-glance">
|
|
<div class="signal-card-header">
|
|
<div>
|
|
<div class="signal-company">${escapeHtml(s.company_name)}</div>
|
|
<div class="signal-ticker">${escapeHtml(s.ticker)}</div>
|
|
</div>
|
|
<span class="signal-badge ${s.signal}">${s.signal}</span>
|
|
</div>
|
|
<div class="signal-tags">
|
|
<span class="signal-tag">conf: ${escapeHtml(s.confidence)}</span>
|
|
<span class="signal-tag">risk: ${escapeHtml(s.risk_level)}</span>
|
|
<span class="signal-tag">${escapeHtml(s.timeframe)}</span>
|
|
</div>
|
|
<div class="signal-summary">${escapeHtml(firstSentence)}</div>
|
|
<div class="signal-ts">Generated ${ts}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
|
|
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 = [
|
|
`<span class="signal-tag">conf: ${escapeHtml(s.confidence)}</span>`,
|
|
`<span class="signal-tag">risk: ${escapeHtml(s.risk_level)}</span>`,
|
|
`<span class="signal-tag">${escapeHtml(s.timeframe)}</span>`,
|
|
].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 => `<li>${escapeHtml(d)}</li>`).join("");
|
|
} else {
|
|
driversWrap.style.display = "none";
|
|
}
|
|
|
|
if (risks.length) {
|
|
risksWrap.style.display = "";
|
|
document.getElementById("signal-modal-risks").innerHTML =
|
|
risks.map(r => `<li>${escapeHtml(r)}</li>`).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 =
|
|
`<p style="color:var(--muted-dark)">Loading…</p>`;
|
|
|
|
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 = `<p style="color:var(--muted-dark)">Failed to load references</p>`;
|
|
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 = `<p style="color:var(--muted-dark)">No linked events</p>`;
|
|
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 ? `<span class="signal-ref-source">${escapeHtml(a.source)}</span>` : "";
|
|
const pd = a.pub_date ? `<span class="signal-ref-date">${escapeHtml(a.pub_date.slice(0, 10))}</span>` : "";
|
|
return `
|
|
<li>
|
|
<a href="${escapeHtml(a.url)}" target="_blank" rel="noopener">${escapeHtml(a.title || a.url)}</a>
|
|
${src}${pd}
|
|
</li>
|
|
`;
|
|
}).join("");
|
|
|
|
return `
|
|
<div class="signal-ref-event">
|
|
<div class="signal-ref-event-title">
|
|
${escapeHtml(ev.title || `Event #${ev.id}`)}
|
|
${dateStr ? `<span class="signal-ref-date">${dateStr}</span>` : ""}
|
|
</div>
|
|
${articles ? `<ul class="signal-ref-articles">${articles}</ul>` : `<p style="color:var(--muted-dark);margin:4px 0 0">No articles linked</p>`}
|
|
</div>
|
|
`;
|
|
}).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);
|
|
|
|
const card = document.querySelector(`.signal-card[onclick*="(${companyId})"]`);
|
|
if (card) card.remove();
|
|
|
|
const grid = document.getElementById("intel-signals-grid");
|
|
if (!grid.children.length) {
|
|
document.getElementById("intel-signals-empty").style.display = "";
|
|
}
|
|
|
|
closeSignalModal();
|
|
} catch (_) {
|
|
toast("Failed to clear signal", true);
|
|
}
|
|
}
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
document.getElementById("signalCloseBtn").onclick = closeSignalModal;
|
|
document.getElementById("signalRegenBtn").onclick = regenerateSignal;
|
|
|
|
// 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()]);
|
|
});
|