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:
+212
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user