diff --git a/dashboard_publisher.py b/dashboard_publisher.py
index d6f7541..f31adec 100644
--- a/dashboard_publisher.py
+++ b/dashboard_publisher.py
@@ -82,7 +82,7 @@ SUMMARY_PHP_FILE = "news_summary.php"
# Repo files copied into the out dir at daemon start (git pull + restart
# updates them). The PHP endpoint reads news.json in place, so it lives
# next to the page.
-STATIC_FILES = [PAGE_FILE, SUMMARY_PHP_FILE]
+STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE]
# ══════════════════════════════════════════════════════════════════
diff --git a/static/dashboard.css b/static/dashboard.css
new file mode 100644
index 0000000..81995be
--- /dev/null
+++ b/static/dashboard.css
@@ -0,0 +1,226 @@
+/* Gold Desk — XAU/USD dashboard styles
+ *
+ * Builds on Bootstrap 5.3 color modes (data-bs-theme). Only app-specific
+ * tokens and components live here; layout, typography, tables, badges,
+ * cards, alerts, modals, etc. come from Bootstrap.
+ */
+
+:root {
+ --xau-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --xau-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace;
+}
+
+html, body {
+ font-family: var(--xau-font-sans);
+ letter-spacing: -0.005em;
+ -webkit-font-smoothing: antialiased;
+}
+
+.num, .xau-price, .xau-symbol {
+ font-family: var(--xau-font-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.xau-symbol {
+ font-size: 0.95rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: var(--bs-primary);
+}
+
+.xau-price {
+ line-height: 1;
+ color: var(--bs-primary-text-emphasis);
+}
+
+.live-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--bs-success);
+ margin-right: 5px;
+ animation: pulse 2.4s ease-in-out infinite;
+}
+
+.live-dot.closed {
+ background: var(--bs-warning);
+ animation: none;
+}
+
+@keyframes pulse {
+ 50% { opacity: 0.35; }
+}
+
+/* signal-coloured accents */
+.sig-buy { --xau-signal: var(--bs-success); }
+.sig-sell { --xau-signal: var(--bs-danger); }
+.sig-neutral { --xau-signal: var(--bs-secondary); }
+
+.border-signal {
+ border-left: 3px solid var(--xau-signal, var(--bs-secondary)) !important;
+}
+
+.text-signal {
+ color: var(--xau-signal, var(--bs-secondary)) !important;
+}
+
+/* timeframe cards */
+.tf-card {
+ cursor: pointer;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.tf-card:hover, .tf-card:focus-visible {
+ border-color: var(--bs-primary);
+ box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.15);
+}
+
+.tf-name {
+ font-family: var(--xau-font-mono);
+ font-weight: 700;
+ font-size: 1.1rem;
+ color: var(--bs-primary);
+ letter-spacing: 0.05em;
+}
+
+/* score bar: zero-centered diverging with midpoint tick */
+.scorebar {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.scorebar .track {
+ position: relative;
+ flex: 1;
+ height: 9px;
+ border-radius: 5px;
+ background: var(--bs-secondary-bg);
+ overflow: hidden;
+}
+
+.scorebar .track::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--bs-secondary);
+ transform: translateX(-1px);
+}
+
+.scorebar .fill {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ border-radius: 4px;
+ transition: width 0.5s ease;
+}
+
+.scorebar .fill.pos {
+ left: 50%;
+ background: var(--bs-success);
+}
+
+.scorebar .fill.neg {
+ right: 50%;
+ background: var(--bs-danger);
+}
+
+.scorebar .val {
+ width: 58px;
+ text-align: right;
+ font-size: 0.95rem;
+ font-weight: 500;
+}
+
+.scorebar .lbl {
+ font-size: 0.8rem;
+ color: var(--bs-secondary);
+ width: 44px;
+}
+
+/* rail chips in hero */
+.rail-chip {
+ cursor: pointer;
+ font-family: var(--xau-font-mono);
+ font-size: 0.9rem;
+}
+
+.rail-chip:hover {
+ border-color: var(--bs-primary) !important;
+}
+
+/* detail dialog sections */
+.d-stat .k {
+ display: block;
+ font-size: 0.7rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--bs-secondary);
+ font-weight: 600;
+}
+
+.d-stat .v {
+ font-size: 1.15rem;
+ font-weight: 600;
+}
+
+.d-table {
+ font-size: 0.9rem;
+}
+
+.d-table .ind-k {
+ font-family: var(--xau-font-mono);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.wbar {
+ display: block;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--bs-secondary-bg);
+ margin-top: 3px;
+}
+
+.wbar > i {
+ display: block;
+ height: 100%;
+ border-radius: 2px;
+ background: var(--bs-primary);
+}
+
+/* news */
+.wire-item {
+ cursor: pointer;
+ border-radius: var(--bs-border-radius);
+}
+
+.wire-item:hover {
+ background: rgba(var(--bs-primary-rgb), 0.08);
+}
+
+.wire-score {
+ font-family: var(--xau-font-mono);
+ font-size: 0.85rem;
+ font-weight: 700;
+ padding: 2px 7px;
+ border-radius: 5px;
+ white-space: nowrap;
+}
+
+/* calendar highlight for next upcoming event */
+.cal-next-up td:first-child {
+ box-shadow: inset 4px 0 0 var(--bs-primary);
+ padding-left: 0.75rem !important;
+}
+
+/* reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ .live-dot { animation: none; }
+ .scorebar .fill { transition: none; }
+ * { transition: none !important; }
+}
diff --git a/static/dashboard.html b/static/dashboard.html
index 1307113..debf646 100644
--- a/static/dashboard.html
+++ b/static/dashboard.html
@@ -1,1465 +1,146 @@
-
+
-
Market closed (weekend) — showing Friday's last state. Calendar and news stay live.
-
-
-
-
-
Timeframes — select one for full detail
-
-
-
-
-
-
-
Fundamentals
-
-
-
-
Economic calendar
-
times local · USD events
-
+
`;
+ }).join("");
+}
+
+/* ── timeframe detail dialog ──────────────────────────────────── */
+const tile = (k, v, cls = "") =>
+ `
`;
+
+const sec = (title, html) => html ? `
` : "";
+
+function indTable(src) {
+ const rows = IND_ORDER.map(name => {
+ const i = src?.[name];
+ const k = `
${IND_SHORT[name]} | `;
+ if (!i) return `
${k}| — absent |
+ — | — | — |
+ no signal this scan |
`;
+ const w = i.weight == null ? null : +i.weight;
+ return `
${k}
+ | ${esc(sigLabel(i.signal))} |
+ ${i.value != null ? (+i.value).toFixed(2) : "—"} |
+ ${i.confidence != null ? Math.round(i.confidence) : "—"} |
+ ${w != null ? w.toFixed(1) : "—"}${w != null
+ ? `` : ""} |
+ ${esc(i.description || "")} |
`;
+ }).join("");
+ return `
+ | Indicator | Signal | Value | Conf |
+ Weight | Reading |
${rows}
`;
+}
+
+function ladder(levels, price) {
+ if (!levels) return "";
+ const atr = levels.atr;
+ const rows = Object.keys(LEVEL_LABEL)
+ .filter(k => levels[k] != null)
+ .map(k => ({ label: LEVEL_LABEL[k], v: +levels[k] }));
+ if (!rows.length) return "";
+ if (price != null) rows.push({ here: true, v: +price });
+ rows.sort((a, b) => b.v - a.v);
+ const body = rows.map(row => {
+ if (row.here) return `
| current price |
+ ${fmt$(row.v)} | — | spot |
`;
+ const d = price != null ? row.v - price : null;
+ return `
0 ? "text-danger" : "text-success"}">
+ | ${esc(row.label)} |
+ ${fmt$(row.v)} |
+ ${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")} |
+ ${d == null ? "" : d > 0 ? "resistance" : "support"} |
`;
+ }).join("");
+ return `
| Level | Price |
+ vs. spot | Role |
${body}
`;
+}
+
+function analysisBlock(a, price) {
+ if (!a) return "";
+ const pats = a.pattern_signals?.length
+ ? `
${a.pattern_signals.map(p =>
+ `${esc(p)}`).join("")}
` : "";
+ const atr = a.atr_context
+ ? `
${esc(a.atr_context.description ||
+ "ATR " + (+a.atr_context.value).toFixed(2))}
` : "";
+ const lv = ladder(a.key_levels, price);
+ return indTable(a.indicators) + atr + pats +
+ (lv ? `
Key levels
${lv}` : "");
+}
+
+function listBlock(r) {
+ const group = (title, arr, cls, icon) => arr?.length
+ ? `
${title} (${arr.length})
+
${arr.map(t => `
${esc(t)}
`).join("")}
` : "";
+ return group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
+ + group("Bullish", r.bullish_reasons, "text-success", "bi-arrow-up-circle-fill")
+ + group("Bearish", r.bearish_reasons, "text-danger", "bi-arrow-down-circle-fill");
+}
+
+function detailHTML(tf) {
+ const entry = snapshot?.timeframes?.[tf] || {};
+ const r = entry.report;
+ if (!r) return `
${entry.status === "error"
+ ? `Scan error — ${esc(entry.error || "unknown")}`
+ : "Warming up — no completed scan for this timeframe yet."}
`;
+
+ const pa = r.primary_analysis;
+ const price = r.current_price;
+ const cls = sigClass(r.signal);
+ const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
+ const borderCls = cls === "buy" ? "border-start border-success border-4"
+ : cls === "sell" ? "border-start border-danger border-4"
+ : "border-start border-secondary border-4";
+
+ let out = `
+
${esc(r.action || sigLabel(r.signal))}
+ ${r.news_description || r.event_description
+ ? `
${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}
`
+ : ""}
+
`;
+
+ const ns = r.news_sentiment;
+ out += sec("At a glance", `
+ ${tile("Signal", esc(sigLabel(r.signal)), textCls)}
+ ${tile("Score", signed(r.score, 1), textCls)}
+ ${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
+ ${tile("Quality", esc(r.quality || "—"))}
+ ${tile("Regime", esc(r.market_regime || "—"))}
+ ${tile("Volatility", esc(r.volatility_regime || "—"))}
+ ${tile("Event risk", esc((r.event_risk || "—").toUpperCase()),
+ r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
+ ${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
+ ns > 0.05 ? "text-success" : ns < -0.05 ? "text-danger" : "")}
+ ${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
+ ${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal) === "buy" ? "text-success" : sigClass(r.primary_signal) === "sell" ? "text-danger" : "")}
+ ${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "text-warning" : "")}
+
`);
+
+ out += sec("Trade setup", r.trade_setup
+ ? setupCard(r.trade_setup)
+ : `
No setup published for this timeframe — the engine only emits entry/stop/target once a signal clears its quality gate.
`);
+
+ out += sec("Why this signal", listBlock(r) || `
No reasons recorded on this scan.
`);
+
+ out += sec(`${TF_LABEL[tf]} indicators (primary)`,
+ analysisBlock(pa, price) || indTable(r.indicators) +
+ (r.key_levels ? `
Key levels
${ladder(r.key_levels, price)}` : ""));
+
+ const mtf = Object.entries(r.mtf_signals || {});
+ if (mtf.length) {
+ const chips = mtf.map(([k, sig]) => {
+ const s = r.mtf_scores?.[k];
+ const sc = sigClass(sig);
+ return `
${esc(TF_LABEL[k] || k)}
+ ${esc(sigLabel(sig))}
+ ${s != null ? signed(s, 1) : ""}`;
+ }).join("");
+ const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
+ const lbl = TF_LABEL[a.timeframe] || a.timeframe;
+ const sc = sigClass(a.composite_signal);
+ return `
+ ${esc(lbl)}
+ ${esc(sigLabel(a.composite_signal))}
+ ${signed(a.composite_score, 1)} · conf ${a.confidence != null ? Math.round(a.confidence) : "—"} · ${esc(a.market_regime || "")}
+ ${analysisBlock(a, price)} `;
+ }).join("");
+ out += sec("Multi-timeframe confirmation",
+ `
${chips}
+ ${r.mtf_agreement ? "✓ aligned" : "✗ mixed"}
${confs}`);
+ }
+
+ if (r.news_summary || r.news_description) {
+ out += sec("News context", `
+ ${r.news_description ? `${esc(r.news_description)}${ns != null ? ` (${signed(ns, 2)})` : ""}
` : ""}
+ ${esc(r.news_summary || "")}
`);
+ }
+
+ if (r.panel_verdict) {
+ const vcls = r.panel_verdict === "AGREE" ? "text-success"
+ : r.panel_verdict === "OPPOSE" ? "text-danger" : "text-secondary";
+ const takes = (r.panel_takes || []).map(t =>
+ `
| ${esc(t.persona)} |
+ ${esc(t.stance)} |
+ ${t.confidence != null ? Math.round(t.confidence) : "—"} |
+ ${esc(t.note || "")} |
`).join("");
+ out += sec("AI panel (advisory)", `
+ ${esc(r.panel_verdict)} ·
+ conf ${Math.round(r.panel_confidence || 0)}%
+ ${esc(r.panel_rationale || "")}
+ ${takes ? `
` : ""}`);
+ }
+
+ out += sec("Scan detail", `
+ | Instrument | ${esc(r.display_name || r.symbol || "—")} |
+ | Candle | ${esc(r.timestamp || "—")} |
+ | Scanned | ${esc(entry.scanned_at || "—")}
+ (${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""}) |
+ | Status | ${esc(entry.status || "—")}${entry.error
+ ? ` — ${esc(entry.error)}
+ ${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""} |
+
`);
+ return out;
+}
+
+function renderDetail() {
+ if (!detailTf || !tfModal._isShown) return;
+ const entry = snapshot?.timeframes?.[detailTf] || {};
+ const r = entry.report;
+ const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
+ .map(d => d.dataset.dkey));
+ const scroll = $("detail-body").scrollTop;
+
+ $("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
+ $("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
+ $("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
+
--:--`;
+ $("detail-body").innerHTML = detailHTML(detailTf);
+
+ document.querySelectorAll("#detail-body details[data-dkey]").forEach(d => {
+ if (openKeys.has(d.dataset.dkey)) d.open = true;
+ });
+ $("detail-body").scrollTop = scroll;
+ tick();
+}
+
+function openDetail(tf) {
+ if (!TF_LABEL[tf]) return;
+ detailTf = tf;
+ tfModal.show();
+ renderDetail();
+ $("detail-body").scrollTop = 0;
+ $("detail-close").focus();
+}
+
+$("detail-close")?.addEventListener("click", () => tfModal.hide());
+document.getElementById("tf-detail")?.addEventListener("hidden.bs.modal", () => { detailTf = null; });
+
+function wireOpeners(host) {
+ host.addEventListener("click", e => {
+ if (e.target.closest("details, summary, a, input, button")) return;
+ const el = e.target.closest("[data-tf]");
+ if (el) openDetail(el.dataset.tf);
+ });
+ host.addEventListener("keydown", e => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ const el = e.target.closest?.("[data-tf]");
+ if (!el || e.target !== el) return;
+ e.preventDefault();
+ openDetail(el.dataset.tf);
+ });
+}
+
+/* ── calendar ─────────────────────────────────────────────────── */
+function renderCalendar() {
+ const body = $("cal-body");
+ if (!news) return;
+ const hiOnly = $("hi-only").checked;
+ let events = (news.events || []);
+ if (hiOnly) events = events.filter(e => e.impact === "high");
+ if (!events.length) {
+ body.innerHTML = `
| ${
+ news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."} |
`;
+ return;
+ }
+ const now = nowServer();
+ let nextUpMarked = false, lastDay = "", rows = "";
+ for (const e of events) {
+ const t = Date.parse(e.time) / 1000;
+ const d = new Date(t * 1000);
+ const day = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
+ if (day !== lastDay) {
+ rows += `
| ${esc(day)} |
`;
+ lastDay = day;
+ }
+ const past = t < now;
+ const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
+ const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ const surp = e.surprise
+ ? `
${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}` : "";
+ rows += `
+ | ${esc(time)} |
+ ${esc(e.currency)} |
+ ${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)} |
+ ${esc(e.event)} |
+ ${esc(e.forecast ?? "—")} |
+ ${esc(e.previous ?? "—")} |
+ ${esc(e.actual ?? "—")} ${surp} |
`;
+ }
+ body.innerHTML = rows;
+}
+
+/* ── newswire ─────────────────────────────────────────────────── */
+function renderWireTotal() {
+ const box = $("wire-total");
+ const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
+ if (!scored.length) { box.classList.add("d-none"); return; }
+ box.classList.remove("d-none");
+
+ const sum = scored.reduce((a, h) => a + h.score, 0);
+ const mean = sum / scored.length;
+ const up = scored.filter(h => h.score > 0.05).length;
+ const dn = scored.filter(h => h.score < -0.05).length;
+ const cls = mean > 0.05 ? "text-success" : mean < -0.05 ? "text-danger" : "text-secondary";
+ const dir = mean > 0.05 ? "BULLISH" : mean < -0.05 ? "BEARISH" : "NEUTRAL";
+ const wrapCls = mean > 0.05 ? "bg-success-subtle border-success-subtle"
+ : mean < -0.05 ? "bg-danger-subtle border-danger-subtle"
+ : "bg-body-tertiary";
+
+ box.className = `card mb-3 ${wrapCls}`;
+ box.innerHTML = `
+
+ Total news score
+ ${signed(mean, 2)}
+ ${dir}
+ ${scored.length} headlines · sum ${signed(sum, 1)}
+ ${up} bullish · ${dn} bearish · ${scored.length - up - dn} neutral
+
+
`;
+ box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Bullish/bearish counted at |score| > 0.05.`;
+}
+
+function renderWire() {
+ if (!news) return;
+ const items = (news.headlines || []).slice(0, 40);
+ $("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
+ renderWireTotal();
+ if (!items.length) {
+ $("wire").innerHTML = `
No recent gold/dollar headlines.
`;
+ return;
+ }
+ const item = h => {
+ const s = h.score;
+ const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
+ const scoreCls = cls === "pos" ? "bg-success-subtle text-success"
+ : cls === "neg" ? "bg-danger-subtle text-danger"
+ : "bg-body-tertiary text-secondary";
+ const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
+ let host = "";
+ try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
+ const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
+ return `
+ ${scoreTxt}
+ ${esc(h.headline)}
+ ${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}
+
`;
+ };
+ const head = items.slice(0, 12).map(item).join("");
+ const rest = items.slice(12);
+ const moreOpen = $("wire")?.querySelector("details.more")?.open ?? false;
+ $("wire").innerHTML = head + (rest.length
+ ? `
${rest.length} more headlines…
+ ${rest.map(item).join("")}
` : "");
+}
+
+/* ── news / calendar detail dialog ────────────────────────────── */
+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 ? "text-success" : v < -0.05 ? "text-danger" : "text-secondary";
+ 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 = `
${tiles}
`;
+ if (h.keywords?.length) {
+ out += `
Matched keywords: ${esc(h.keywords.join(", "))}
`;
+ }
+ out += `
Scores range −1 (bearish) to +1 (bullish); the gold score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).
`;
+ return out;
+}
+
+function aiSection(id) {
+ if (aiMemo.has(id)) {
+ const m = aiMemo.get(id);
+ return `
${esc(m.summary)}
` + (m.generated_at
+ ? `
generated ${fmtAge(m.generated_at)}
` : "");
+ }
+ if (aiPending.has(id)) return `
Generating gold-impact summary…
`;
+ if (aiErr.has(id)) {
+ return `
${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}
`;
+ }
+ return `
Generating gold-impact summary…
`;
+}
+
+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 = `
${esc(h.headline)}
+
${esc(h.publisher || host)}${when ? " · " + when : ""}
`;
+ if (!fresh) out += `
No longer in the live feed — showing saved copy.
`;
+ out += sec("Score", scoreTiles(h));
+ if (h.description) out += sec("From the source", `
${esc(h.description)}
`);
+ out += sec("AI gold-impact summary", aiSection(ndState.id));
+ if (h.link && /^https?:\/\//.test(h.link)) {
+ out += `
Read full article ↗`;
+ }
+ 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 = `
${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}`;
+ let out = `
${esc(e.event)}
+
${esc(e.currency)} · ${when}
`;
+ const timing = t > nowServer()
+ ? `releases in
--:--`
+ : `released ${fmtAge(e.time)}`;
+ out += sec("Timing", `
${impact} impact · ${timing}
`);
+ const surpTxt = { positive: "actual above forecast", negative: "actual below forecast", inline: "actual matches forecast" }[e.surprise];
+ out += sec("Numbers", `
${
+ tile("Forecast", esc(e.forecast ?? "—")) +
+ tile("Previous", esc(e.previous ?? "—")) +
+ tile("Actual", esc(e.actual ?? "—"), e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "")
+ }
` + (surpTxt
+ ? `
${surpTxt} (numeric only — not gold direction)
` : ""));
+ return out;
+}
+
+function renderNewsDlg() {
+ if (!ndState || !newsModal._isShown) 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);
+ newsModal.show();
+ 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 };
+ newsModal.show();
+ renderNewsDlg();
+ $("nd-body").scrollTop = 0;
+ $("nd-close").focus();
+}
+
+$("nd-close")?.addEventListener("click", () => newsModal.hide());
+document.getElementById("news-detail")?.addEventListener("hidden.bs.modal", () => { ndState = null; });
+
+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;
+ e.preventDefault();
+ open(el.getAttribute(attr));
+ });
+}
+
+/* ── polling ──────────────────────────────────────────────────── */
+async function fetchJSON(name) {
+ const res = await fetch(name, { cache: "no-cache" });
+ if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
+ const dateHdr = res.headers.get("Date");
+ const text = await res.text();
+ return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
+}
+
+async function poll() {
+ try {
+ const [snap, nw] = await Promise.all([
+ fetchJSON("snapshot.json"),
+ fetchJSON("news.json").catch(() => null)
+ ]);
+ const snapChanged = snap.text !== lastSnapText;
+ const newsChanged = nw != null && nw.text !== lastNewsText;
+ if (snapChanged) { snapshot = JSON.parse(snap.text); lastSnapText = snap.text; }
+ if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
+ clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
+ lastPollOk = new Date();
+ $("banner-fail").classList.add("d-none");
+
+ if (snapChanged) {
+ renderHero();
+ renderCards();
+ renderDetail();
+ }
+ if (newsChanged) {
+ renderCalendar();
+ renderWire();
+ renderNewsDlg();
+ }
+ if (snapChanged || newsChanged) renderContext();
+ tick();
+
+ if (snapChanged) {
+ const nextScan = Math.min(...TF_ORDER.map(tf =>
+ snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
+ if (isFinite(nextScan)) {
+ clearTimeout(refetchTimer);
+ const delay = (nextScan - nowServer() + 2) * 1000;
+ if (delay > 0) refetchTimer = setTimeout(poll, delay);
+ }
+ }
+ } catch (err) {
+ const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
+ const b = $("banner-fail");
+ b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
+ b.classList.remove("d-none");
+ }
+}
+
+function tick() {
+ if (!snapshot) return;
+ const now = nowServer();
+ document.querySelectorAll("[data-close]").forEach(el => {
+ const t = parseFloat(el.dataset.close);
+ if (!t) return;
+ let secs = t - now;
+ if (secs < 0) {
+ const interval = parseFloat(el.dataset.interval);
+ secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
+ }
+ el.textContent = fmtCountdown(secs);
+ });
+ const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
+ if (m5) {
+ let secs = m5 - now;
+ if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
+ $("hero-cd").textContent = fmtCountdown(secs);
+ }
+ const pubEpoch = snapshot.server_time_epoch;
+ if (pubEpoch) {
+ const age = now - pubEpoch;
+ const el = $("hero-updated");
+ const stale = !snapshot.market_closed && age > 900;
+ el.textContent = (stale ? "STALE — published " : "published ") +
+ fmtAge(new Date(pubEpoch * 1000).toISOString());
+ el.classList.toggle("text-warning", stale);
+ el.classList.toggle("fw-semibold", stale);
+ }
+}
+
+/* ── boot ─────────────────────────────────────────────────────── */
+document.addEventListener("DOMContentLoaded", () => {
+ tfModal = new bootstrap.Modal($("tf-detail"));
+ newsModal = new bootstrap.Modal($("news-detail"));
+
+ // track modal shown state for renderDetail / renderNewsDlg guards
+ $("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModal._isShown = true; });
+ $("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModal._isShown = false; detailTf = null; });
+ $("news-detail")?.addEventListener("shown.bs.modal", () => { newsModal._isShown = true; });
+ $("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModal._isShown = false; ndState = null; });
+
+ wireOpeners($("tf-grid"));
+ wireOpeners($("hero-rail"));
+ wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
+ wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
+
+ $("hi-only")?.addEventListener("change", renderCalendar);
+
+ poll();
+ setInterval(poll, POLL_MS);
+ setInterval(tick, 1000);
+});
diff --git a/test_all.py b/test_all.py
index 8d909f8..c64727f 100644
--- a/test_all.py
+++ b/test_all.py
@@ -5670,7 +5670,7 @@ def _():
pub = dpub.DashboardPublisher(out_dir=tmp)
pub.install_static()
assert sorted(os.listdir(tmp)) == \
- ["dashboard.html", "news_summary.php"] # both installed, no .tmp
+ ["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp
@test("dashboard: publish is atomic and leaves no temp file")