dashboard: skip DOM rebuild on unchanged poll, publish-time staleness, fix boundary countdowns

poll() now compares raw payload text and re-renders only what a changed
file can affect, preserving selection, disclosure, and scroll state. Hero
freshness reflects publish time (STALE warning past 15 min while market
open), and countdowns resolve their wrap interval from data-interval
instead of reverse-looking shared TF boundaries in snapshot.timeframes.
This commit is contained in:
Harwinder Sekhon
2026-08-09 18:15:31 -04:00
parent 7e8ed754ff
commit 887aaed6f2
+69 -25
View File
@@ -630,6 +630,7 @@ const IND_SHORT = {ema_trend:"EMA", rsi:"RSI", macd:"MACD", bollinger:"BB",
const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"}; const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"}; const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
const TF_ORDER = ["5min","15min","1h","4h"]; const TF_ORDER = ["5min","15min","1h","4h"];
const TF_SECONDS = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400};
/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache /* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache
answers unchanged files with 304, so most polls transfer no body. */ answers unchanged files with 304, so most polls transfer no body. */
const POLL_MS = 5000; const POLL_MS = 5000;
@@ -783,7 +784,8 @@ function renderCards() {
const foot = `<div class="tf-foot"> const foot = `<div class="tf-foot">
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span> <span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
<span class="cd">closes in <span class="cd">closes in
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span></div>`; <span class="num" data-close="${entry.next_close_epoch || ""}"
data-interval="${TF_SECONDS[tf]}">--:--</span></span></div>`;
if (!r) { if (!r) {
return `${open}<div class="tf-head"><span class="tf-name">${TF_LABEL[tf]}</span> return `${open}<div class="tf-head"><span class="tf-name">${TF_LABEL[tf]}</span>
<span class="spacer"></span><span class="open-hint">details ›</span></div> <span class="spacer"></span><span class="open-hint">details ›</span></div>
@@ -1021,7 +1023,8 @@ function renderDetail() {
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf; $("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—"; $("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in $("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span>`; <span class="num" data-close="${entry.next_close_epoch || ""}"
data-interval="${TF_SECONDS[detailTf]}">--:--</span>`;
$("detail-body").innerHTML = detailHTML(detailTf); $("detail-body").innerHTML = detailHTML(detailTf);
dlg.querySelectorAll("details[data-dkey]").forEach(d => { dlg.querySelectorAll("details[data-dkey]").forEach(d => {
@@ -1160,8 +1163,12 @@ function renderWire() {
}; };
const head = items.slice(0, 12).map(item).join(""); const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12); const rest = items.slice(12);
/* preserve the disclosure across poll re-renders — otherwise an expanded
list snaps shut within one poll cycle (renderDetail does the same via
data-dkey; there is only one of these, so a class lookup suffices) */
const moreOpen = $("wire").querySelector("details.more")?.open ?? false;
$("wire").innerHTML = head + (rest.length $("wire").innerHTML = head + (rest.length
? `<details class="more" style="padding-top:6px"><summary>${rest.length} more headlines…</summary> ? `<details class="more" style="padding-top:6px"${moreOpen ? " open" : ""}><summary>${rest.length} more headlines…</summary>
<div class="wire">${rest.map(item).join("")}</div></details>` : ""); <div class="wire">${rest.map(item).join("")}</div></details>` : "");
} }
@@ -1342,43 +1349,62 @@ wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
async function fetchJSON(name) { async function fetchJSON(name) {
/* no-cache = revalidate with the server every time (If-Modified-Since); /* no-cache = revalidate with the server every time (If-Modified-Since);
unchanged files come back 304 with no body, so aggressive polling unchanged files come back 304 with no body, so aggressive polling
stays nearly free. */ stays nearly free. Returns raw TEXT, not parsed JSON: a 304 is
transparent to fetch (the browser answers with the cached body and a
200), so poll() detects "unchanged" by comparing the payload itself
and skips the DOM rebuild entirely. */
const res = await fetch(name, {cache: "no-cache"}); const res = await fetch(name, {cache: "no-cache"});
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`); if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch /* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
is stamped at publish time, minutes stale between candle closes. */ is stamped at publish time, minutes stale between candle closes. */
const dateHdr = res.headers.get("Date"); const dateHdr = res.headers.get("Date");
const data = await res.json(); const text = await res.text();
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null}; return {text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
} }
let refetchTimer = null; let refetchTimer = null;
let lastSnapText = null, lastNewsText = null; // change detection for the render-skip below
async function poll() { async function poll() {
try { try {
const [snap, nw] = await Promise.all([ const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"), fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
]); ]);
snapshot = snap.data; const snapChanged = snap.text !== lastSnapText;
news = (nw && nw.data) || news; const newsChanged = nw != null && nw.text !== lastNewsText;
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000; 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(); lastPollOk = new Date();
$("banner-fail").style.display = "none"; $("banner-fail").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire(); /* re-render only what new data can actually change — an unchanged
renderDetail(); // keep an open detail view in sync with the new scan payload keeps the existing DOM (selection, disclosure state, scroll)
renderNewsDlg(); // same for an open news/calendar dialog intact; countdowns and the freshness line tick from it regardless.
Trade-off: "Xm ago" ages in the wire/calendar only advance when the
payload changes. */
if (snapChanged) {
renderHero(); renderCards();
renderDetail(); // keep an open detail view in sync with the new scan
}
if (newsChanged) {
renderCalendar(); renderWire();
renderNewsDlg(); // same for an open news/calendar dialog
}
if (snapChanged || newsChanged) renderContext(); // blurb reads news, risk chip reads snapshot
tick(); // countdowns appear immediately, not after the first 1 s tick tick(); // countdowns appear immediately, not after the first 1 s tick
/* one-shot refetch shortly after the next expected publish, so a /* one-shot refetch shortly after the next expected publish, so a
fast scan appears ~2 s after the publisher wakes instead of fast scan appears ~2 s after the publisher wakes instead of
waiting out the poll interval (the regular POLL_MS loop covers waiting out the poll interval (the regular POLL_MS loop covers
slower scans) */ slower scans) */
const nextScan = Math.min(...TF_ORDER.map(tf => if (snapChanged) {
snapshot.timeframes[tf]?.next_scan_epoch || Infinity)); const nextScan = Math.min(...TF_ORDER.map(tf =>
if (isFinite(nextScan)) { snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
clearTimeout(refetchTimer); if (isFinite(nextScan)) {
const delay = (nextScan - nowServer() + 2) * 1000; clearTimeout(refetchTimer);
if (delay > 0) refetchTimer = setTimeout(poll, delay); const delay = (nextScan - nowServer() + 2) * 1000;
if (delay > 0) refetchTimer = setTimeout(poll, delay);
}
} }
} catch (err) { } catch (err) {
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : ""; const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
@@ -1388,28 +1414,46 @@ async function poll() {
} }
} }
/* 1 s countdown ticker (server-clock anchored) */ /* 1 s countdown ticker (server-clock anchored). The wrap interval comes
from data-interval stamped at render time — reverse-looking the epoch up
in snapshot.timeframes mis-resolves shared boundaries (M5/M15/H1 all
close on the hour) to the first matching TF in TF_ORDER. */
function tick() { function tick() {
if (!snapshot) return; if (!snapshot) return;
const now = nowServer(); const now = nowServer();
document.querySelectorAll("[data-close]").forEach(el => { document.querySelectorAll("[data-close]").forEach(el => {
let t = parseFloat(el.dataset.close); const t = parseFloat(el.dataset.close);
if (!t) return; if (!t) return;
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
let secs = t - now; let secs = t - now;
if (secs < 0) { // boundary passed while waiting for next publish if (secs < 0) { // boundary passed while waiting for next publish
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300; const interval = parseFloat(el.dataset.interval);
secs = ((secs % interval) + interval) % interval; /* one-shot countdowns (calendar releases) carry no data-interval:
clamp at zero until the next poll re-renders them as "released …" */
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
} }
el.textContent = fmtCountdown(secs); el.textContent = fmtCountdown(secs);
}); });
const m5 = snapshot.timeframes["5min"]?.next_close_epoch; const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
if (m5) { if (m5) {
let secs = m5 - now; let secs = m5 - now;
if (secs < 0) secs = ((secs % 300) + 300) % 300; if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
$("hero-cd").textContent = fmtCountdown(secs); $("hero-cd").textContent = fmtCountdown(secs);
} }
if (lastPollOk) $("hero-updated").textContent = `updated ${fmtAge(lastPollOk.toISOString())}`; /* publish-time freshness, not poll health — a dead publisher behind a
healthy Apache would otherwise read as "updated just now" forever */
const pubEpoch = snapshot.server_time_epoch;
if (pubEpoch) {
const age = now - pubEpoch;
const el = $("hero-updated");
/* M5 publishes every 5 min while the market is open, so anything older
than 15 min means the pipeline is down, not between scans. The
weekend case is already covered by the market-closed banner. */
const stale = !snapshot.market_closed && age > 900;
el.textContent = (stale ? "STALE — published " : "published ") +
fmtAge(new Date(pubEpoch * 1000).toISOString());
el.style.color = stale ? "var(--amber)" : "";
el.style.fontWeight = stale ? "600" : "";
}
} }
setInterval(tick, 1000); setInterval(tick, 1000);