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);