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:
+61
-17
@@ -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,37 +1349,55 @@ 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
|
||||||
|
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
|
renderDetail(); // keep an open detail view in sync with the new scan
|
||||||
|
}
|
||||||
|
if (newsChanged) {
|
||||||
|
renderCalendar(); renderWire();
|
||||||
renderNewsDlg(); // same for an open news/calendar dialog
|
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) */
|
||||||
|
if (snapChanged) {
|
||||||
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||||
if (isFinite(nextScan)) {
|
if (isFinite(nextScan)) {
|
||||||
@@ -1380,6 +1405,7 @@ async function poll() {
|
|||||||
const delay = (nextScan - nowServer() + 2) * 1000;
|
const delay = (nextScan - nowServer() + 2) * 1000;
|
||||||
if (delay > 0) refetchTimer = setTimeout(poll, delay);
|
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()}.` : "";
|
||||||
const b = $("banner-fail");
|
const b = $("banner-fail");
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user