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 b005941c76
commit 4c34c4e23e
+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 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};
/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache
answers unchanged files with 304, so most polls transfer no body. */
const POLL_MS = 5000;
@@ -783,7 +784,8 @@ function renderCards() {
const foot = `<div class="tf-foot">
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
<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) {
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>
@@ -1021,7 +1023,8 @@ function renderDetail() {
$("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
<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);
dlg.querySelectorAll("details[data-dkey]").forEach(d => {
@@ -1160,8 +1163,12 @@ function renderWire() {
};
const head = items.slice(0, 12).map(item).join("");
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
? `<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>` : "");
}
@@ -1342,43 +1349,62 @@ wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
async function fetchJSON(name) {
/* no-cache = revalidate with the server every time (If-Modified-Since);
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"});
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
is stamped at publish time, minutes stale between candle closes. */
const dateHdr = res.headers.get("Date");
const data = await res.json();
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
const text = await res.text();
return {text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
}
let refetchTimer = null;
let lastSnapText = null, lastNewsText = null; // change detection for the render-skip below
async function poll() {
try {
const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
]);
snapshot = snap.data;
news = (nw && nw.data) || news;
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000;
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").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
renderDetail(); // keep an open detail view in sync with the new scan
renderNewsDlg(); // same for an open news/calendar dialog
/* re-render only what new data can actually change — an unchanged
payload keeps the existing DOM (selection, disclosure state, scroll)
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
/* one-shot refetch shortly after the next expected publish, so a
fast scan appears ~2 s after the publisher wakes instead of
waiting out the poll interval (the regular POLL_MS loop covers
slower scans) */
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);
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()}.` : "";
@@ -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() {
if (!snapshot) return;
const now = nowServer();
document.querySelectorAll("[data-close]").forEach(el => {
let t = parseFloat(el.dataset.close);
const t = parseFloat(el.dataset.close);
if (!t) return;
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
let secs = t - now;
if (secs < 0) { // boundary passed while waiting for next publish
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300;
secs = ((secs % interval) + interval) % interval;
const interval = parseFloat(el.dataset.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);
});
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
if (m5) {
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);
}
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);