dashboard: clickable news — detail dialogs + on-demand AI summary endpoint

- news_engine: RSS parser captures link/description/publisher (three new
  optional NewsSentiment fields); _clean_html_text/_safe_link helpers
- news.json: headlines carry id = sha1(headline) + enrichment fields
- static/news_summary.php (NEW): on-demand DeepSeek gold-impact summary,
  whitelist-gated against news.json ids, cache-forever in ai/, fail-closed
  file-based daily cap (150/day), key at /etc/gold-dashboard/deepseek.key;
  installed to the docroot by install_static
- dashboard.html: newswire + calendar detail dialogs (score tiles, source
  description, article link, AI summary with loading/error states);
  delegated on stable containers, dialog keyed by id across polls
- tests: +6 (357 total); docs: README-dashboard §6 one-time setup
  (key file, apache-owned ai/, SELinux httpd_can_network_connect), CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aq1jZ6jBsBdEC2xT3u46SA
This commit is contained in:
Harwinder Sekhon
2026-08-07 09:08:17 -04:00
parent a42ef16113
commit 15a4ed7831
7 changed files with 745 additions and 33 deletions
+212 -11
View File
@@ -322,8 +322,8 @@ details.more .reasons { margin-top: 4px; }
.rail-chip { cursor: pointer; }
.rail-chip:hover, .rail-chip:focus-visible { border-color: var(--accent); }
/* ── Timeframe detail dialog ──────────────────────────────────── */
#tf-detail {
/* ── Detail dialogs (timeframe + news) ────────────────────────── */
#tf-detail, #news-detail {
/* the global `* { margin: 0 }` reset wipes the UA stylesheet's `margin: auto`,
which is what centres a modal dialog — restore it explicitly. */
position: fixed; inset: 0; margin: auto;
@@ -333,9 +333,10 @@ details.more .reasons { margin-top: 4px; }
background: var(--card); color: var(--ink);
font-family: var(--sans); font-size: 16px;
}
#tf-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
#tf-detail[open] { display: flex; flex-direction: column; }
#detail-head {
#news-detail { width: min(680px, 94vw); }
#tf-detail::backdrop, #news-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
#tf-detail[open], #news-detail[open] { display: flex; flex-direction: column; }
#detail-head, #nd-head {
flex: 0 0 auto;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 14px 18px; background: var(--card2);
@@ -347,13 +348,17 @@ details.more .reasons { margin-top: 4px; }
}
#detail-head .d-price { font-family: var(--mono); font-size: 19px; font-weight: 600; }
#detail-head .d-sub { font-size: 13px; color: var(--muted); }
#detail-close {
#detail-close, #nd-close {
margin-left: auto; font: inherit; font-size: 15px; line-height: 1;
padding: 7px 13px; border-radius: 6px; cursor: pointer;
background: var(--card); color: var(--ink2); border: 1px solid var(--line-strong);
}
#detail-close:hover { border-color: var(--accent); color: var(--accent-bright); }
#detail-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
#detail-close:hover, #nd-close:hover { border-color: var(--accent); color: var(--accent-bright); }
#detail-body, #nd-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
#nd-title {
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted); font-weight: 600;
}
.d-sec { margin-top: 20px; }
.d-sec > h3 {
@@ -435,6 +440,12 @@ details.more .reasons { margin-top: 4px; }
.d-note b { color: var(--ink); }
.sub-h { font-size: 12px; color: var(--muted); font-weight: 600; margin: 11px 0 5px; }
/* news detail dialog */
.nd-headline { font-size: 18px; font-weight: 650; line-height: 1.35; margin-top: 14px; }
.nd-meta { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
a.d-action { display: block; text-decoration: none; color: var(--accent-bright); }
a.d-action:hover { border-color: var(--accent); }
/* ── News section ─────────────────────────────────────────────── */
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
@@ -467,6 +478,8 @@ details.more .reasons { margin-top: 4px; }
}
#cal-table tr.past { opacity: 0.55; }
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--accent); padding-left: 7px; }
#cal-table tr[data-ev-key] { cursor: pointer; }
#cal-table tr[data-ev-key]:hover td { background: var(--accent-wash); }
#cal-table .t { white-space: nowrap; color: var(--ink2); }
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
#cal-table .ev { color: var(--ink); }
@@ -514,6 +527,8 @@ details.more .reasons { margin-top: 4px; }
padding: 7px 0; border-bottom: 1px solid var(--line);
}
.wire-item:last-child { border-bottom: none; }
.wire-item[data-news-id] { cursor: pointer; padding: 7px 6px; margin: 0 -6px; border-radius: 6px; }
.wire-item[data-news-id]:hover { background: var(--accent-wash); }
.wire-score {
font-family: var(--mono); font-size: 13px; font-weight: 700;
padding: 2px 7px; border-radius: 5px; white-space: nowrap;
@@ -569,6 +584,14 @@ footer {
<div id="detail-body"></div>
</dialog>
<dialog id="news-detail" aria-labelledby="nd-title">
<div id="nd-head">
<span id="nd-title">News detail</span>
<button id="nd-close" type="button" aria-label="Close news detail">Close ✕</button>
</div>
<div id="nd-body"></div>
</dialog>
<div class="eyebrow">Fundamentals</div>
<div id="news-wrap">
<section class="panel">
@@ -1056,7 +1079,8 @@ function renderCalendar() {
? `<span class="surp ${e.surprise}" title="actual ${e.surprise === "positive" ? "above" :
e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${
e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
rows += `<tr class="${past ? "past" : ""}${nextUp ? " next-up" : ""}">
rows += `<tr class="${past ? "past" : ""}${nextUp ? " next-up" : ""}"
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
<td class="t num">${esc(time)}</td>
<td class="cur">${esc(e.currency)}</td>
<td><span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med" :
@@ -1109,11 +1133,14 @@ function renderWire() {
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
return `<div class="wire-item">
/* data-news-id only when the payload carries an id — after a daemon
restart the new page briefly meets a pre-upgrade news.json */
const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
return `<div class="wire-item"${open}>
<span class="wire-score ${cls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${
h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="wire-h">${esc(h.headline)}</span>
<span class="wire-meta">${esc(host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
<span class="wire-meta">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
@@ -1122,6 +1149,179 @@ function renderWire() {
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
}
/* ── news / calendar detail dialog ────────────────────────────── */
const ndlg = $("news-detail");
let ndState = null; // {kind:"wire", id, snap} | {kind:"cal", key, snap}
const aiMemo = new Map(); // headline id -> {summary, generated_at} (successes only)
const aiErr = new Map(); // headline id -> error code (cleared on reopen → retry)
const aiPending = new Set();
/* stable across polls — the array index is not (new headlines shift it) */
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
const AI_ERR_MSG = {
unknown_id: "This headline has left the live feed — no summary available.",
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
no_key: "AI summaries are not configured on this server.",
};
async function fetchAiSummary(id) {
aiPending.add(id);
try {
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
const data = await res.json().catch(() => null);
if (res.ok && data?.summary) aiMemo.set(id, data);
else aiErr.set(id, data?.error || `http_${res.status}`);
} catch {
aiErr.set(id, "network");
} finally {
aiPending.delete(id);
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
}
}
function scoreTiles(h) {
const cls = v => v > 0.05 ? "buy" : v < -0.05 ? "sell" : "";
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
const xau = h.score_by_currency?.XAU;
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
}
let out = `<div class="d-stats">${tiles}</div>`;
if (h.keywords?.length) {
out += `<div class="nd-meta">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
}
out += `<div class="nd-meta">Scores range −1 (bearish) to +1 (bullish); the gold
score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `<div class="d-note">${esc(m.summary)}</div>` + (m.generated_at
? `<div class="nd-meta">generated ${fmtAge(m.generated_at)}</div>` : "");
}
if (aiPending.has(id)) return `<div class="d-note">Generating gold-impact summary…</div>`;
if (aiErr.has(id)) {
return `<div class="d-note">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
}
return `<div class="d-note">Generating gold-impact summary…</div>`;
}
function wireDlgHTML() {
const fresh = findHeadline(ndState.id);
const h = fresh || ndState.snap;
if (!h) return "";
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
const when = h.timestamp
? `${new Date(h.timestamp).toLocaleString(undefined,
{dateStyle: "medium", timeStyle: "short"})} · ${fmtAge(h.timestamp)}` : "";
let out = `<div class="nd-headline">${esc(h.headline)}</div>
<div class="nd-meta">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
if (!fresh) out += `<div class="nd-meta">No longer in the live feed — showing saved copy.</div>`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `<div class="d-note">${esc(h.description)}</div>`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `<a class="d-action" href="${esc(h.link)}" target="_blank"
rel="noopener noreferrer">Read full article ↗</a>`;
}
return out;
}
function calDlgHTML() {
const fresh = findEvent(ndState.key);
const e = fresh || ndState.snap;
if (!e) return "";
const t = Date.parse(e.time) / 1000;
const when = new Date(e.time).toLocaleString(undefined, {dateStyle: "full", timeStyle: "short"});
const impact = `<span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med"
: e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
let out = `<div class="nd-headline">${esc(e.event)}</div>
<div class="nd-meta">${esc(e.currency)} · ${when}</div>`;
/* live countdown only for FUTURE events — tick()'s modulo wrap on a
passed epoch would show a bogus restarting timer for released ones */
const timing = t > nowServer()
? `releases in <span class="num" data-close="${t}">--:--</span>`
: `released ${fmtAge(e.time)}`;
out += sec("Timing", `<div class="d-note">${impact} impact · ${timing}</div>`);
const surpTxt = {positive: "actual above forecast", negative: "actual below forecast",
inline: "actual matches forecast"}[e.surprise];
out += sec("Numbers", `<div class="d-stats">${
tile("Forecast", esc(e.forecast ?? "—")) +
tile("Previous", esc(e.previous ?? "—")) +
tile("Actual", esc(e.actual ?? "—"),
e.surprise === "positive" ? "buy" : e.surprise === "negative" ? "sell" : "")
}</div>` + (surpTxt
? `<div class="nd-meta">${surpTxt} (numeric only — not gold direction)</div>` : ""));
return out;
}
function renderNewsDlg() {
if (!ndState || !ndlg.open) return;
const scroll = $("nd-body").scrollTop;
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
$("nd-body").scrollTop = scroll;
tick();
}
function openNewsDetail(id) {
const h = findHeadline(id);
if (!h) return;
ndState = {kind: "wire", id, snap: h};
aiErr.delete(id); // failures retry on reopen; successes are memoised
if (!ndlg.open) ndlg.showModal();
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
function openCalDetail(key) {
const e = findEvent(key);
if (!e) return;
ndState = {kind: "cal", key, snap: e};
if (!ndlg.open) ndlg.showModal();
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
$("nd-close").addEventListener("click", () => ndlg.close());
ndlg.addEventListener("close", () => { ndState = null; });
ndlg.addEventListener("click", e => {
if (e.target !== ndlg) return;
const b = ndlg.getBoundingClientRect();
if (e.clientX < b.left || e.clientX > b.right || e.clientY < b.top || e.clientY > b.bottom) ndlg.close();
});
/* Delegated on the stable containers (renderWire/renderCalendar replace
innerHTML every poll). Deliberately NOT wireOpeners(): its details/
summary guard would swallow the 28 wire items nested in the
"more headlines" <details>. A click on that toggle itself finds no
[data-news-id] ancestor and falls through to the native behaviour. */
function wireNewsOpeners(host, attr, open) {
host.addEventListener("click", e => {
const el = e.target.closest(`[${attr}]`);
if (el) open(el.getAttribute(attr));
});
host.addEventListener("keydown", e => {
if (e.key !== "Enter" && e.key !== " ") return;
const el = e.target.closest?.(`[${attr}]`);
if (!el || e.target !== el) return; // let inner controls keep their own keys
e.preventDefault();
open(el.getAttribute(attr));
});
}
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
/* ── polling ──────────────────────────────────────────────────── */
async function fetchJSON(name) {
/* no-cache = revalidate with the server every time (If-Modified-Since);
@@ -1150,6 +1350,7 @@ async function poll() {
$("banner-fail").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
renderDetail(); // keep an open detail view in sync with the new scan
renderNewsDlg(); // same for an open news/calendar dialog
tick(); // countdowns appear immediately, not after the first 1 s tick
/* one-shot refetch shortly after the next expected publish, so a