"use strict";
/* Gold Desk — XAU/USD dashboard frontend
*
* Reads snapshot.json + news.json published by dashboard_publisher.py and
* renders a responsive, keyboard-accessible dashboard. Uses Bootstrap 5 for
* layout/components/modals and a small custom CSS file for app-specific
* visuals (score bar, signal colours, mono fonts).
*
* This file is intentionally self-contained: it mirrors a few constants from
* the Python signal pipeline so the HTML does not depend on telegram_bot.
*/
const IND_ORDER = ["ema_trend", "rsi", "macd", "bollinger", "adx", "roc", "keltner", "divergence", "candlestick"];
const IND_SHORT = {
ema_trend: "EMA", rsi: "RSI", macd: "MACD", bollinger: "BB",
adx: "ADX", roc: "ROC", keltner: "KC", divergence: "Div", candlestick: "Cndl"
};
const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRONG_SELL: "⇊" };
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
const TF_ORDER = ["5min", "15min", "1h", "4h"];
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
const FALLBACK_POLL_MS = 60000;
const RETRY_MS = 15000;
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
const TIMEFRAME_PREF = "goldDeskSelectedTimeframe";
const LEVEL_LABEL = {
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
bb_upper: "Bollinger upper", bb_mid: "Bollinger mid", bb_lower: "Bollinger lower",
kc_upper: "Keltner upper", kc_mid: "Keltner mid", kc_lower: "Keltner lower",
swing_high_1: "Swing high 1", swing_high_2: "Swing high 2", swing_high_3: "Swing high 3",
swing_low_1: "Swing low 1", swing_low_2: "Swing low 2", swing_low_3: "Swing low 3"
};
let snapshot = null, news = null;
let clockOffset = 0;
let lastPollOk = null;
let lastSnapText = null, lastNewsText = null;
let refetchTimer = null;
let fallbackTimer = null;
let pollInFlight = false;
let pollQueued = false;
let selectedTf = "5min";
let detailTf = null;
let ndState = null;
const aiMemo = new Map();
const aiErr = new Map();
const aiPending = new Set();
let tfModal = null;
let newsModal = null;
let tfModalOpen = false;
let newsModalOpen = false;
const $ = id => document.getElementById(id);
/* ── helpers ──────────────────────────────────────────────────── */
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
const nowServer = () => Date.now() / 1000 + clockOffset;
function localGet(key) {
try { return window.localStorage.getItem(key); } catch { return null; }
}
function localSet(key, value) {
try { window.localStorage.setItem(key, value); } catch { /* storage is optional */ }
}
function announce(message) {
const live = $("dashboard-live");
if (live) live.textContent = message;
}
function fmtCountdown(secs) {
if (secs == null || !isFinite(secs)) return "--:--";
secs = Math.max(0, Math.floor(secs));
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
function fmtAge(iso) {
if (!iso) return "";
const secs = nowServer() - Date.parse(iso) / 1000;
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86400)}d ago`;
}
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
const badgeCls = s => s === "buy" ? "bg-success-subtle text-success-emphasis border-success-subtle"
: s === "sell" ? "bg-danger-subtle text-danger-emphasis border-danger-subtle"
: "bg-secondary-subtle text-secondary-emphasis border-secondary-subtle";
/* ── theme ────────────────────────────────────────────────────── */
// Dark theme is forced site-wide (owner decision 2026-08-16): dark
// background, pure-white text, red/green data accents.
document.documentElement.setAttribute("data-bs-theme", "dark");
/* ── hero ─────────────────────────────────────────────────────── */
function renderHero() {
if (!snapshot) return;
let price = null;
for (const tf of TF_ORDER) {
const p = snapshot.timeframes[tf]?.report?.current_price;
if (p) { price = p; break; }
}
$("hero-price").textContent = price ? fmt$(price) : "—";
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
const r = snapshot.timeframes[tf]?.report;
const sig = r?.signal || null;
const cls = sigClass(sig);
const arrow = sig ? (ARROW[sig] || "→") : "·";
const score = r?.score != null ? r.score.toFixed(0) : "–";
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
return `
${TF_LABEL[tf]}
${arrow}
${esc(score)}
`;
}).join("");
const closed = snapshot.market_closed;
$("hero-status").innerHTML =
` ${closed ? "CLOSED" : "OPEN"}`;
$("banner-closed").classList.toggle("d-none", !closed);
}
/* ── market context strip ─────────────────────────────────────── */
function renderContext() {
if (!news) return;
$("context").classList.remove("d-none");
$("ctx-blurb").innerHTML = news.llm_summary
? `Market context: ${esc(news.llm_summary)}`
: `Market context: no summary available `;
let chips = "";
for (const cur of ["XAU", "USD"]) {
const v = news.sentiment?.[cur];
if (v == null) continue;
const cls = v > 0.05 ? "text-bg-success" : v < -0.05 ? "text-bg-danger" : "text-bg-secondary";
chips += `
${cur} news ${v >= 0 ? "+" : ""}${v.toFixed(2)} `;
}
let risk = "clear", riskDesc = "No significant events";
if (snapshot) {
for (const tf of TF_ORDER) {
const r = snapshot.timeframes[tf]?.report;
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
if (r?.event_description) riskDesc = r.event_description;
}
}
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
const riskCls = risk === "clear" ? "text-bg-success"
: risk === "danger" ? "text-bg-danger"
: "text-bg-warning";
chips += `event risk: ${risk.toUpperCase()} `;
$("ctx-chips").innerHTML = chips;
}
/* ── timeframe cards ──────────────────────────────────────────── */
function scoreBar(label, score) {
const v = Math.max(-100, Math.min(100, score ?? 0));
const width = (Math.abs(v) / 2).toFixed(1);
return `
${label}
${v >= 0 ? "+" : ""}${v.toFixed(1)}
`;
}
function setupCard(ts) {
if (!ts) return "";
const dir = ts.direction === "BUY" ? "buy" : "sell";
const dirIcon = ts.direction === "BUY" ? "bi-arrow-up-circle-fill" : "bi-arrow-down-circle-fill";
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
return `
${ts.direction}
${esc(ts.strategy || "")}
R:R ${(+ts.risk_reward).toFixed(1)}
Entry${fmt$(ts.entry)}
TP1${fmt$(ts.take_profit_1)}
Stop${fmt$(ts.stop_loss)}
TP2${fmt$(ts.take_profit_2)}
Trail${esc(trail)}
`;
}
function workspacePanel(tf) {
const entry = snapshot?.timeframes?.[tf] || {};
const r = entry.report;
if (!r) {
return `
${TF_LABEL[tf]}
${entry.status === "error"
? `
SCAN UNAVAILABLE Showing again when the next scan succeeds.
`
: "Warming up — no completed scan for this timeframe yet."}
`;
}
const cls = sigClass(r.signal);
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
const qHi = r.quality === "A+" || r.quality === "A";
const pa = r.primary_analysis;
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " volatility",
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
const facts = `
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
${tile("Quality", esc(r.quality || "—"), qHi ? "text-primary" : "")}
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()), r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
`;
const setup = r.trade_setup
? setupCard(r.trade_setup)
: `No setup published. The signal has not cleared its setup gate.
`;
return `
`;
}
function renderCards() {
if (!snapshot) return;
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
const selector = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const cls = r ? sigClass(r.signal) : "neutral";
const arrow = r ? (ARROW[r.signal] || "→") : "·";
const score = r?.score != null ? signed(r.score, 0) : "—";
const signal = r ? sigLabel(r.signal) : entry.status === "error" ? "UNAVAILABLE" : "WARMING";
return `
${TF_LABEL[tf]}
${arrow}
${esc(signal)}
${esc(score)}
`;
}).join("");
$("tf-grid").innerHTML = `${selector}
${workspacePanel(selectedTf)}`;
tick();
}
/* ── timeframe detail dialog ──────────────────────────────────── */
const tile = (k, v, cls = "") =>
``;
const SEC_ICON = {
"At a glance": "bi-speedometer2",
"Trade setup": "bi-bullseye",
"Why this signal": "bi-lightbulb",
"Multi-timeframe confirmation": "bi-layers",
"News context": "bi-newspaper",
"AI panel (advisory)": "bi-robot",
"Scan detail": "bi-clock-history",
"Score": "bi-graph-up",
"From the source": "bi-quote",
"AI gold-impact summary": "bi-stars",
"Timing": "bi-alarm",
"Numbers": "bi-calculator"
};
const sec = (title, html) => {
if (!html) return "";
const icon = SEC_ICON[title];
return `${icon ? ` ` : ""}${esc(title)} ${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;
const cls = d == null ? "" : d > 0 ? "text-danger" : "text-success";
const icon = d == null ? "" : d > 0 ? "bi-arrow-up-short" : "bi-arrow-down-short";
return `
${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 || !tfModalOpen) 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());
/* ── calendar ─────────────────────────────────────────────────── */
function renderCalendar() {
const body = $("cal-body");
if (!news) return;
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
$("calendar-note").textContent = `times local (${zone}) · USD events${news.generated_at ? " · updated " + fmtAge(news.generated_at) : ""}`;
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
`;
box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Only headlines from the last 4 hours that lean one way are published (neutral ones are removed before scoring).`;
}
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 gold or dollar headlines in the last 4 hours.
`;
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 content = `${scoreTxt}
${esc(h.headline)}
${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""} `;
if (!h.id) return `${content}
`;
return `
${content}
`;
};
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 || !newsModalOpen) 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());
/* ── 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 };
}
function tfFingerprint(entry) {
if (!entry) return "";
return JSON.stringify({
status: entry.status,
scanned_at: entry.scanned_at,
error: entry.error,
error_at: entry.error_at,
report: entry.report
});
}
function scheduleRefresh(delay) {
clearTimeout(refetchTimer);
refetchTimer = setTimeout(() => {
refetchTimer = null;
poll();
}, Math.max(0, delay));
}
function scheduleFallbackRefresh() {
clearTimeout(fallbackTimer);
fallbackTimer = setTimeout(() => {
fallbackTimer = null;
poll();
scheduleFallbackRefresh();
}, FALLBACK_POLL_MS);
}
function scheduleBoundaryRefresh() {
if (!snapshot) return;
const nextScan = Math.min(...TF_ORDER.map(tf =>
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
if (!isFinite(nextScan)) return;
const delay = (nextScan - nowServer() + 2) * 1000;
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
}
async function poll() {
if (pollInFlight) {
pollQueued = true;
return;
}
pollInFlight = true;
try {
const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null)
]);
const priorSnapshot = snapshot;
let snapChanged = false;
let selectedChanged = false;
let detailChanged = false;
if (snap.text !== lastSnapText) {
const incoming = JSON.parse(snap.text);
if (!priorSnapshot || incoming.server_time_epoch >= priorSnapshot.server_time_epoch) {
snapChanged = true;
selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !==
tfFingerprint(incoming.timeframes?.[selectedTf]);
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
tfFingerprint(incoming.timeframes?.[detailTf]);
snapshot = incoming;
lastSnapText = snap.text;
}
}
const newsChanged = nw != null && nw.text !== lastNewsText;
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();
if (selectedChanged) renderCards();
if (detailChanged) renderDetail();
if (selectedChanged) {
const signal = snapshot.timeframes[selectedTf]?.report?.signal;
if (signal) announce(`${TF_LABEL[selectedTf]} signal updated: ${sigLabel(signal)}.`);
}
}
if (newsChanged) {
renderCalendar();
renderWire();
renderNewsDlg();
}
if (snapChanged || newsChanged) renderContext();
tick();
scheduleBoundaryRefresh();
} catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
const b = $("banner-fail");
b.textContent = `Dashboard data is temporarily unavailable.${last} Retrying…`;
b.classList.remove("d-none");
scheduleRefresh(RETRY_MS);
} finally {
pollInFlight = false;
if (pollQueued) {
pollQueued = false;
poll();
}
}
}
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"));
const storedTf = localGet(TIMEFRAME_PREF);
if (TF_LABEL[storedTf]) selectedTf = storedTf;
$("hi-only").checked = localGet(HIGH_IMPACT_PREF) === "true";
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); });
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); });
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
const selectTf = tf => {
if (!TF_LABEL[tf]) return;
const changed = selectedTf !== tf;
selectedTf = tf;
localSet(TIMEFRAME_PREF, tf);
renderHero();
renderCards();
if (changed) announce(`${TF_LABEL[tf]} workspace selected.`);
};
$("hero-rail")?.addEventListener("click", e => {
const button = e.target.closest("[data-select-tf]");
if (button) selectTf(button.dataset.selectTf);
});
$("tf-grid")?.addEventListener("click", e => {
const detail = e.target.closest("[data-open-detail]");
if (detail) return openDetail(detail.dataset.openDetail);
const button = e.target.closest("[data-select-tf]");
if (button) selectTf(button.dataset.selectTf);
});
$("wire")?.addEventListener("click", e => {
const button = e.target.closest("[data-news-id]");
if (button) openNewsDetail(button.dataset.newsId);
});
$("cal-body")?.addEventListener("click", e => {
const button = e.target.closest("[data-ev-key]");
if (button) openCalDetail(button.dataset.evKey);
});
$("hi-only")?.addEventListener("change", () => {
localSet(HIGH_IMPACT_PREF, String($("hi-only").checked));
renderCalendar();
});
poll();
scheduleFallbackRefresh();
setInterval(tick, 1000);
});