Duriin-API/public/admin/assets/js/intel-signals.js

254 lines
8.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
// 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 `
<div class="signal-card" onclick="openSignalModal(${s.company_id})">
<div class="signal-card-head">
<div class="signal-card-main">
<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>
${summary ? `<p class="signal-card-summary">${escapeHtml(summary)}</p>` : ""}
<div class="signal-card-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>
${evDate ? `<div class="signal-card-ts">Latest event ${escapeHtml(evDate)}</div>` : ""}
</div>
`;
}).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 = [
`<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);
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()]);
});