← back to Dw Cadence Next2
board: self-tracking re-pull + 'last re-pull' health line
dded6af77aba217081db72417b2c332d8d8632fc · 2026-07-22 11:55:14 -0700 · Steve Abrams
server.js now rebuilds data on boot, on a 15-min in-process timer, and on page
load when data is stale (>15m) — no external cron/launchd. Adds /rebuild-status
(job health) alongside /rebuild. index.html shows a color-coded 'last re-pull'
line (ok/stale/fail/running dot + age + item count + next-auto ETA), polls it
every 60s, and adds a manual 'Re-pull now' button.
Files touched
M public/index.htmlM server.js
Diff
commit dded6af77aba217081db72417b2c332d8d8632fc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 11:55:14 2026 -0700
board: self-tracking re-pull + 'last re-pull' health line
server.js now rebuilds data on boot, on a 15-min in-process timer, and on page
load when data is stale (>15m) — no external cron/launchd. Adds /rebuild-status
(job health) alongside /rebuild. index.html shows a color-coded 'last re-pull'
line (ok/stale/fail/running dot + age + item count + next-auto ETA), polls it
every 60s, and adds a manual 'Re-pull now' button.
---
public/index.html | 52 ++++++++++++++++++++++++++++++++++++++++-
server.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 114 insertions(+), 7 deletions(-)
diff --git a/public/index.html b/public/index.html
index 37ae43c..9a54aca 100644
--- a/public/index.html
+++ b/public/index.html
@@ -18,6 +18,14 @@
select, input[type=range], input[type=search] { font: inherit; }
select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 6px; background: #fff; }
input[type=search] { padding: 5px 10px; border: 1px solid #ccc; border-radius: 6px; min-width: 220px; }
+ .health { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 11.5px; color: #555; }
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #bbb; flex: none; }
+ .dot.ok { background: #16a34a; } .dot.stale { background: #d97706; } .dot.fail { background: #dc2626; }
+ .dot.run { background: #2563eb; animation: pulse 1s infinite; }
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
+ .repull { font: inherit; font-size: 11.5px; padding: 3px 10px; border: 1px solid #ccc; border-radius: 6px;
+ background: #fff; cursor: pointer; }
+ .repull:hover { border-color: #999; } .repull:disabled { opacity: .5; cursor: default; }
.daybar { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.daychip { padding: 5px 12px; border: 1px solid #d9d5cf; border-radius: 999px; background: #fbfaf8;
font-size: 12.5px; cursor: pointer; user-select: none; }
@@ -68,6 +76,11 @@
</label>
<label><input type="search" id="q" placeholder="filter vendor / title / sku…"></label>
</div>
+ <div class="health">
+ <span class="dot" id="hdot"></span>
+ <span id="htext">re-pull: checking…</span>
+ <button class="repull" id="repull">Re-pull now</button>
+ </div>
<div class="daybar" id="daybar"></div>
</header>
<main><div class="grid" id="grid"></div></main>
@@ -148,6 +161,40 @@ function buildDaybar(){
});
}
+function ago(iso){
+ if (!iso) return 'never';
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
+ if (s < 60) return Math.round(s)+'s ago';
+ if (s < 3600) return Math.round(s/60)+'m ago';
+ return Math.round(s/3600)+'h ago';
+}
+async function refreshHealth(){
+ let st;
+ try { st = await (await fetch('rebuild-status?cb='+Date.now())).json(); }
+ catch { $('#htext').textContent = 're-pull status unavailable'; $('#hdot').className='dot fail'; return; }
+ const dot = $('#hdot');
+ const staleMs = st.interval_ms || 900000;
+ if (st.rebuilding) dot.className = 'dot run';
+ else if (st.ok === false) dot.className = 'dot fail';
+ else if ((st.data_age_ms ?? Infinity) > staleMs) dot.className = 'dot stale';
+ else dot.className = 'dot ok';
+ const nextIn = Math.max(0, Math.round(((st.interval_ms||0) - (st.data_age_ms||0))/60000));
+ const parts = [];
+ parts.push(st.rebuilding ? 're-pulling now…'
+ : `last re-pull ${ago(st.last_success)}${st.ok===false?' (FAILED)':''}`);
+ if (st.total != null) parts.push(`${st.total} items`);
+ if (!st.rebuilding) parts.push(st.ok===false ? 'auto-retry next cycle' : `next auto in ~${nextIn}m`);
+ $('#htext').textContent = parts.join(' · ');
+ $('#htext').title = st.message || '';
+}
+async function repullNow(){
+ const btn = $('#repull'); btn.disabled = true; btn.textContent = 'Re-pulling…';
+ $('#hdot').className = 'dot run';
+ try { await fetch('rebuild?cb='+Date.now()); } catch {}
+ await boot(); // reload data + daybar
+ await refreshHealth();
+ btn.disabled = false; btn.textContent = 'Re-pull now';
+}
function applyDensity(){
const v = +$('#density').value;
document.documentElement.style.setProperty('--cols', v);
@@ -171,7 +218,10 @@ async function boot(){
buildDaybar();
render();
}
-boot();
+// one-time init: wire the manual button + start the "last re-pull" health poll
+$('#repull').onclick = repullNow;
+boot().then(refreshHealth);
+setInterval(refreshHealth, 60000);
</script>
</body>
</html>
diff --git a/server.js b/server.js
index 65fb78d..4e66aed 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,17 @@
'use strict';
/*
- * server.js — tiny zero-dependency static server for the "Going Live · Next 2
- * Days" cadence viewer. Basic-auth gated (admin / DW2024!). Serves public/ and
- * data/next2days.json. Rebuilds the data file on GET /rebuild.
+ * server.js — zero-dependency static server for the "Going Live · Next 2 Days"
+ * cadence viewer. Basic-auth gated (admin / DW2024!). Serves public/ and
+ * data/next2days.json.
+ *
+ * Self-tracking freshness (so the board follows the cadence as items drain):
+ * - rebuilds data on boot,
+ * - rebuilds on a 15-min in-process timer (no external cron / launchd job),
+ * - rebuilds on page load when the data is older than STALE_MS (async — serves
+ * the current file immediately; the next load shows the fresh build),
+ * - GET /rebuild forces one; GET /rebuild-status reports job health for the
+ * board's "last re-pull" line.
+ * A single `rebuilding` guard prevents overlapping runs.
*/
const http = require('http');
const fs = require('fs');
@@ -13,10 +22,45 @@ const PORT = parseInt(process.env.PORT || '0', 10); // 0 = OS-assigned free port
const USER = process.env.BASIC_USER || 'admin';
const PASS = process.env.BASIC_PASS || 'DW2024!';
const ROOT = __dirname;
+const DATA_FILE = path.join(ROOT, 'data', 'next2days.json');
+const BUILDER = path.join(ROOT, 'scripts', 'build-data.js');
+const INTERVAL_MS = 15 * 60 * 1000; // periodic re-pull
+const STALE_MS = 15 * 60 * 1000; // on-load rebuild threshold
const TYPES = { '.html':'text/html; charset=utf-8', '.js':'text/javascript', '.css':'text/css',
'.json':'application/json', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon' };
+// ── rebuild job state (exposed to the board) ─────────────────────────────────
+let rebuilding = false;
+const job = {
+ last_attempt: null, // ISO — when a rebuild last started
+ last_success: null, // ISO — when a rebuild last succeeded
+ ok: null, // bool — outcome of the most recent completed run
+ message: '', // one-line summary (builder stdout tail, or error)
+ total: null, // item count from the last successful build
+ interval_ms: INTERVAL_MS,
+};
+
+function currentTotal() {
+ try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')).meta.total; } catch { return null; }
+}
+function dataAgeMs() {
+ try { return Date.now() - fs.statSync(DATA_FILE).mtimeMs; } catch { return Infinity; }
+}
+function doRebuild(cb) {
+ if (rebuilding) { cb && cb(new Error('already rebuilding')); return; }
+ rebuilding = true;
+ job.last_attempt = new Date().toISOString();
+ execFile('node', [BUILDER], { timeout: 120000 }, (e, out, err) => {
+ rebuilding = false;
+ job.ok = !e;
+ job.message = ((out || '').trim().split('\n').pop() || (err || '').trim() || (e ? String(e) : '')).slice(0, 200);
+ if (!e) { job.last_success = new Date().toISOString(); job.total = currentTotal(); }
+ console.log(`[rebuild] ${job.ok ? 'ok' : 'FAIL'} — ${job.message}`);
+ cb && cb(e, out, err);
+ });
+}
+
function unauthorized(res){ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Cadence"' }); res.end('Authentication required'); }
const server = http.createServer((req, res) => {
@@ -27,12 +71,23 @@ const server = http.createServer((req, res) => {
if (u !== USER || p !== PASS) return unauthorized(res);
let url = req.url.split('?')[0];
+
+ if (url === '/rebuild-status') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ...job, rebuilding, data_age_ms: dataAgeMs() }));
+ }
if (url === '/rebuild') {
- return execFile('node', [path.join(ROOT, 'scripts', 'build-data.js')], (e, out, err) => {
- res.writeHead(e ? 500 : 200, { 'Content-Type': 'text/plain' });
- res.end((out || '') + (err || '') || (e ? String(e) : 'ok'));
+ return doRebuild((e) => {
+ res.writeHead(e && !rebuilding ? 500 : 200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ...job, rebuilding, data_age_ms: dataAgeMs() }));
});
}
+
+ // On-load stale guard: kick a background rebuild but DON'T block the page.
+ if ((url === '/' || url === '/index.html') && !rebuilding && dataAgeMs() > STALE_MS) {
+ doRebuild();
+ }
+
if (url === '/') url = '/public/index.html';
else if (url === '/next2days.json') url = '/data/next2days.json';
else if (!url.startsWith('/public/') && !url.startsWith('/data/')) url = '/public' + url;
@@ -50,4 +105,6 @@ server.listen(PORT, '127.0.0.1', () => {
const p = server.address().port;
fs.writeFileSync(path.join(ROOT, '.port'), String(p));
console.log(`dw-cadence-next2 listening on http://127.0.0.1:${p} (auth ${USER} / ${PASS})`);
+ doRebuild(); // refresh on boot
+ setInterval(doRebuild, INTERVAL_MS); // periodic re-pull (self-contained)
});
← 99345f1 initial: Going Live · Next 2 Days cadence viewer
·
back to Dw Cadence Next2
·
auto-save: 2026-07-22T12:13:58 (3 files) — data/next2days.js 24a45ff →