From 887aaed6f2750a8e74d45f5ac8e5a6501d81c2da Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Sun, 9 Aug 2026 18:15:31 -0400 Subject: [PATCH] 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. --- static/dashboard.html | 94 +++++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 25 deletions(-) diff --git a/static/dashboard.html b/static/dashboard.html index e479673..1307113 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -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 = `
${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""} closes in - --:--
`; + --:--`; if (!r) { return `${open}
${TF_LABEL[tf]} details ›
@@ -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 - --:--`; + --:--`; $("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 - ? `
${rest.length} more headlines… + ? `
${rest.length} more headlines…
${rest.map(item).join("")}
` : ""); } @@ -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);