"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 POLL_MS = 5000; 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 detailTf = null; let ndState = null; const aiMemo = new Map(); const aiErr = new Map(); const aiPending = new Set(); let tfModal = null; let newsModal = null; 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 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)); 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 renderCards() { if (!snapshot) return; $("tf-grid").innerHTML = TF_ORDER.map(tf => { const entry = snapshot.timeframes[tf] || {}; const r = entry.report; const scls = r ? sigClass(r.signal) : "neutral"; const open = `
`; const foot = `
${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} closes in --:--
`; if (!r) { return `${open}
${TF_LABEL[tf]} details ›
${entry.status === "error" ? `SCAN ERROR` : "warming up…"}
`; } const pa = r.primary_analysis; const qHi = r.quality === "A+" || r.quality === "A"; const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol", r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · "); return `${open}
${TF_LABEL[tf]} ${sigLabel(r.signal)} ${r.quality ? `${r.quality}` : ""} details ›
${entry.status === "error" ? `STALE` : ""} ${scoreBar("score", pa ? pa.composite_score : r.score)}
${esc(meta)}
${foot} `; }).join(""); } /* ── timeframe detail dialog ──────────────────────────────────── */ const tile = (k, v, cls = "") => `
${esc(k)} ${v}
`; 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 `${rows}
IndicatorSignalValueConf WeightReading
`; } 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 `${body}
LevelPrice vs. spotRole
`; } 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 ? `${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(); if (window.renderGauges) window.renderGauges(); 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); });