dashboard: redesign signal workspace and refresh flow

This commit is contained in:
Harwinder Sekhon
2026-08-18 14:54:47 -04:00
parent 1f632eaa6a
commit 71eb703355
6 changed files with 492 additions and 300 deletions
+245 -121
View File
@@ -19,7 +19,10 @@ const ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRO
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 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",
@@ -34,6 +37,10 @@ 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;
@@ -43,6 +50,8 @@ const aiPending = new Set();
let tfModal = null;
let newsModal = null;
let tfModalOpen = false;
let newsModalOpen = false;
const $ = id => document.getElementById(id);
@@ -57,6 +66,19 @@ const sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell"
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));
@@ -101,13 +123,13 @@ function renderHero() {
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 `<span class="rail-chip badge border ${badgeCls(cls)}" data-tf="${tf}" tabindex="0"
role="button" aria-haspopup="dialog"
title="${esc(sigLabel(sig))} (score ${esc(score)}) — click for full ${TF_LABEL[tf]} detail">
return `<button type="button" class="rail-chip" data-select-tf="${tf}"
aria-current="${tf === selectedTf ? "true" : "false"}"
title="${esc(sigLabel(sig))} (score ${esc(score)}) — select ${TF_LABEL[tf]} workspace">
<span class="tf-name">${TF_LABEL[tf]}</span>
<span class="${textColor} fw-bold">${arrow}</span>
<span>${esc(score)}</span>
</span>`;
</button>`;
}).join("");
const closed = snapshot.market_closed;
@@ -151,9 +173,12 @@ function renderContext() {
/* ── 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 `<div class="scorebar">
<span class="lbl">${label}</span>
<div class="gauge-container" data-label="${label}" data-score="${v}"></div>
<div class="gauge-container" aria-hidden="true">
<i class="fill ${v >= 0 ? "pos" : "neg"}" style="--score-width:${width}%"></i>
</div>
<span class="val num ${v > 0 ? "text-success" : v < 0 ? "text-danger" : ""}">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
</div>`;
}
@@ -171,61 +196,105 @@ function setupCard(ts) {
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
</div>
<table class="table table-sm table-borderless mb-0">
<tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
<div class="table-responsive"><table class="table table-sm table-borderless mb-0">
<tbody><tr><td class="text-secondary"><i class="bi bi-box-arrow-in-right me-1"></i>Entry</td><td class="num">${fmt$(ts.entry)}</td>
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP1</td><td class="num text-success">${fmt$(ts.take_profit_1)}</td></tr>
<tr><td class="text-secondary"><i class="bi bi-shield-x me-1 text-danger"></i>Stop</td><td class="num text-danger">${fmt$(ts.stop_loss)}</td>
<td class="text-secondary ps-4"><i class="bi bi-flag-fill me-1 text-success"></i>TP2</td><td class="num text-success">${fmt$(ts.take_profit_2)}</td></tr>
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
</table>
<tr><td class="text-secondary"><i class="bi bi-signpost-split me-1"></i>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr></tbody>
</table></div>
</div>
</div>`;
}
function workspacePanel(tf) {
const entry = snapshot?.timeframes?.[tf] || {};
const r = entry.report;
if (!r) {
return `<article class="card workspace-card border-signal sig-neutral" id="workspace-panel" aria-labelledby="workspace-panel-heading">
<div class="card-body py-4 text-center text-secondary">
<h3 class="tf-name mb-2" id="workspace-panel-heading">${TF_LABEL[tf]}</h3>
${entry.status === "error"
? `<span class="badge text-bg-danger">SCAN UNAVAILABLE</span><div class="small mt-2">Showing again when the next scan succeeds.</div>`
: "Warming up — no completed scan for this timeframe yet."}
</div>
</article>`;
}
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 = `<div class="row row-cols-2 row-cols-md-4 g-2 mt-1">
${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" : "")}
</div>`;
const setup = r.trade_setup
? setupCard(r.trade_setup)
: `<div class="text-secondary small">No setup published. The signal has not cleared its setup gate.</div>`;
return `<article class="card workspace-card border-signal sig-${cls}" id="workspace-panel" aria-labelledby="workspace-panel-heading">
<div class="card-body p-3 p-lg-4">
<div class="row g-4">
<div class="col-lg-7 workspace-meta">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
<h3 class="tf-name mb-0" id="workspace-panel-heading">${TF_LABEL[tf]} signal</h3>
<span class="badge ${badgeCls(cls)}">${esc(sigLabel(r.signal))}</span>
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}">quality ${esc(r.quality)}</span>` : ""}
${entry.status === "error" ? `<span class="badge text-bg-danger">STALE</span>` : ""}
</div>
<div class="workspace-signal ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" data-open-detail="${tf}">
Full analysis <i class="bi bi-arrow-up-right ms-1"></i>
</button>
</div>
${scoreBar("score", pa ? pa.composite_score : r.score)}
<div class="small text-secondary mt-2">${esc(meta)}</div>
${facts}
<div class="workspace-reasons mt-3 pt-3 border-top">${listBlock(r) || "<div class=\"small text-secondary\">No supporting reasons recorded on this scan.</div>"}</div>
<div class="small text-secondary mt-3">${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} · closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></div>
</div>
<div class="col-lg-5">
<div class="small text-uppercase text-secondary fw-bold mb-2">Trade plan</div>
${setup}
</div>
</div>
</div>
</article>`;
}
function renderCards() {
if (!snapshot) return;
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
if (!TF_LABEL[selectedTf]) selectedTf = "5min";
const selector = TF_ORDER.map(tf => {
const entry = snapshot.timeframes[tf] || {};
const r = entry.report;
const scls = r ? sigClass(r.signal) : "neutral";
const open = `<article class="col-12 col-md-6 col-xl-3"><div class="card tf-card h-100 sig-${scls} border-signal" data-tf="${tf}" tabindex="0" role="button" aria-haspopup="dialog" aria-label="${TF_LABEL[tf]} timeframe — open full detail"><div class="card-body d-flex flex-column gap-2">`;
const foot = `<div class="mt-auto pt-2 border-top d-flex justify-content-between small text-secondary">
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
<span>closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></span>
</div>`;
if (!r) {
return `${open}
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="tf-name">${TF_LABEL[tf]}</span>
<span class="ms-auto small text-primary fw-semibold">details ›</span>
</div>
<div class="text-center text-secondary py-3">${entry.status === "error"
? `<span class="badge text-bg-danger" title="${esc(entry.error || "")}">SCAN ERROR</span>`
: "warming up…"}</div>
</div></div></article>`;
}
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}
<div class="d-flex align-items-center gap-2 flex-wrap">
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 `<button type="button" class="tf-select" data-select-tf="${tf}" aria-pressed="${tf === selectedTf}">
<span class="d-flex align-items-center justify-content-between gap-2">
<span class="tf-name">${TF_LABEL[tf]}</span>
<span class="badge ${badgeCls(scls)}">${sigLabel(r.signal)}</span>
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}" title="Signal quality grade">${r.quality}</span>` : ""}
<span class="ms-auto small text-primary fw-semibold">details ›</span>
</div>
${entry.status === "error" ? `<span class="badge text-bg-danger" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
${scoreBar("score", pa ? pa.composite_score : r.score)}
<div class="small text-secondary">${esc(meta)}</div>
${foot}
</div></div></article>`;
<span class="${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"} fw-bold">${arrow}</span>
</span>
<span class="small ${cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary"}">${esc(signal)}</span>
<span class="small num float-end">${esc(score)}</span>
</button>`;
}).join("");
$("tf-grid").innerHTML = `<div class="tf-selector">${selector}</div>${workspacePanel(selectedTf)}`;
tick();
}
/* ── timeframe detail dialog ──────────────────────────────────── */
const tile = (k, v, cls = "") =>
`<div class="col"><div class="card bg-body-tertiary h-100"><div class="card-body p-2">
`<div class="col"><div class="card d-stat bg-body-tertiary h-100"><div class="card-body p-2">
<span class="k">${esc(k)}</span>
<span class="v ${cls}">${v}</span>
</div></div></div>`;
@@ -266,9 +335,9 @@ function indTable(src) {
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
<td class="text-secondary small">${esc(i.description || "")}</td></tr>`;
}).join("");
return `<table class="table table-sm d-table"><thead><tr>
return `<div class="detail-table"><table class="table table-sm d-table"><thead><tr>
<th>Indicator</th><th>Signal</th><th class="text-end">Value</th><th class="text-end">Conf</th>
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table>`;
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
function ladder(levels, price) {
@@ -292,8 +361,8 @@ function ladder(levels, price) {
<td class="num text-end ${cls}">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
<td class="small ${cls}">${d == null ? "" : `<i class="bi ${icon}"></i>${d > 0 ? "resistance" : "support"}`}</td></tr>`;
}).join("");
return `<table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table>`;
return `<div class="detail-table"><table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table></div>`;
}
function analysisBlock(a, price) {
@@ -408,10 +477,10 @@ function detailHTML(tf) {
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
conf ${Math.round(r.panel_confidence || 0)}%<br>
${esc(r.panel_rationale || "")}</div>
${takes ? `<table class="table table-sm d-table"><tbody>${takes}</tbody></table>` : ""}`);
${takes ? `<div class="detail-table"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
}
out += sec("Scan detail", `<table class="table table-sm d-table"><tbody>
out += sec("Scan detail", `<div class="detail-table"><table class="table table-sm d-table"><tbody>
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
<tr><td class="ind-k">Scanned</td><td colspan="5">${esc(entry.scanned_at || "—")}
@@ -419,12 +488,12 @@ function detailHTML(tf) {
<tr><td class="ind-k">Status</td><td colspan="5">${esc(entry.status || "—")}${entry.error
? ` — <span class="text-danger">${esc(entry.error)}</span>
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
</tbody></table>`);
</tbody></table></div>`);
return out;
}
function renderDetail() {
if (!detailTf || !tfModal._isShown) return;
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]")]
@@ -454,27 +523,13 @@ function openDetail(tf) {
}
$("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 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");
@@ -498,12 +553,11 @@ function renderCalendar() {
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const surp = e.surprise
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" 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 ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}"
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
rows += `<tr class="${past ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}">
<td class="num text-nowrap">${esc(time)}</td>
<td class="text-secondary">${esc(e.currency)}</td>
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
<td>${esc(e.event)}</td>
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
<td class="num text-end">${esc(e.previous ?? "—")}</td>
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
@@ -559,12 +613,13 @@ function renderWire() {
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 `<div class="wire-item d-flex gap-2 align-items-baseline py-1 px-1"${open}>
<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>
</div>`;
const content = `<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>`;
if (!h.id) return `<div class="wire-item gap-2 align-items-baseline py-1 px-1">${content}</div>`;
return `<button type="button" class="wire-item gap-2 align-items-baseline py-1 px-1" data-news-id="${h.id}" aria-haspopup="dialog">
${content}
</button>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
@@ -673,7 +728,7 @@ function calDlgHTML() {
}
function renderNewsDlg() {
if (!ndState || !newsModal._isShown) return;
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();
@@ -704,21 +759,6 @@ function openCalDetail(key) {
}
$("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) {
@@ -729,15 +769,71 @@ async function fetchJSON(name) {
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 snapChanged = snap.text !== lastSnapText;
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 (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();
@@ -745,9 +841,12 @@ async function poll() {
if (snapChanged) {
renderHero();
renderCards();
if (window.renderGauges) window.renderGauges();
renderDetail();
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();
@@ -756,21 +855,19 @@ async function poll() {
}
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);
}
}
scheduleBoundaryRefresh();
} catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
const b = $("banner-fail");
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
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();
}
}
}
@@ -809,21 +906,48 @@ function tick() {
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";
// 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; });
$("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; });
wireOpeners($("tf-grid"));
wireOpeners($("hero-rail"));
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
$("hi-only")?.addEventListener("change", renderCalendar);
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();
setInterval(poll, POLL_MS);
scheduleFallbackRefresh();
setInterval(tick, 1000);
});