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 = `
`;
+ --:--`;
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);