← back to Dw Staged Active Viewer
Snapshot mode for Kamatera deploy: SNAPSHOT_ONLY serves static JSON dumped from Mac2-canonical dw_unified + hourly refresh/rsync
1611bbd4b69f52f2ee4128c3bbb96557ea0d9a23 · 2026-06-23 12:20:55 -0700 · Steve
Files touched
M .gitignoreM public/index.htmlA scripts/build-snapshot.jsA scripts/refresh-and-push.shM server.js
Diff
commit 1611bbd4b69f52f2ee4128c3bbb96557ea0d9a23
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 23 12:20:55 2026 -0700
Snapshot mode for Kamatera deploy: SNAPSHOT_ONLY serves static JSON dumped from Mac2-canonical dw_unified + hourly refresh/rsync
---
.gitignore | 2 +
public/index.html | 5 +-
scripts/build-snapshot.js | 66 ++++++++++++++++++++++++
scripts/refresh-and-push.sh | 27 ++++++++++
server.js | 119 ++++++++++++++++++++++++++++++--------------
5 files changed, 181 insertions(+), 38 deletions(-)
diff --git a/.gitignore b/.gitignore
index 4a0b3ea..1a66059 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ tmp/
dist/
build/
.next/
+data/staged-snapshot.json
+*.bak
diff --git a/public/index.html b/public/index.html
index 1a5b700..e0bacb7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -246,9 +246,12 @@ async function loadSummary(){
const r = await fetch('/api/summary'); const j = await r.json();
if (j.error){ totalsEl.textContent = 'ERROR: '+j.error; return; }
const tres = (j.vendors.find(v=>v.subLines)?.subLines||[]).find(s=>/tres tintas/i.test(s.name));
+ const stamp = j.snapshot
+ ? `<span style="font-size:11px;color:#ffd479" title="served from Mac2 snapshot (canonical dedup_skip), rsynced hourly">📸 snapshot · updated ${new Date(j.snapshotAt||j.generatedAt).toLocaleString()}</span>`
+ : `<span style="font-size:11px">${new Date(j.generatedAt).toLocaleString()}</span>`;
totalsEl.innerHTML = `<b>${j.grandTotal.toLocaleString()}</b> SKUs staged for active across <b>${j.vendors.length}</b> READY vendors`
+ (tres?` · incl Tres Tintas <b>${tres.count.toLocaleString()}</b> (under Newwall)`:'')
- + ` · <span style="font-size:11px">${new Date(j.generatedAt).toLocaleString()}</span>`;
+ + ` · ${stamp}`;
barEl.innerHTML = `<span class="vchip ${state.vendor===''?'active':''}" data-v="">All<span class="n">${j.grandTotal.toLocaleString()}</span></span>`
+ j.vendors.map(v=>`<span class="vchip ${state.vendor===v.vendor?'active':''}" data-v="${esc(v.vendor)}">${esc(v.vendor)}<span class="n">${v.count.toLocaleString()}</span></span>`).join('');
barEl.querySelectorAll('.vchip').forEach(el=>el.onclick=()=>{
diff --git a/scripts/build-snapshot.js b/scripts/build-snapshot.js
new file mode 100755
index 0000000..a8589f3
--- /dev/null
+++ b/scripts/build-snapshot.js
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+/**
+ * build-snapshot.js (Mac2-only, READ-ONLY)
+ * -----------------------------------------
+ * Dumps the FULL staged-for-active payload from the MAC2-LOCAL dw_unified mirror
+ * (the canonical source for dedup_skip + cadence gate) into data/staged-snapshot.json.
+ *
+ * The snapshot is what the Kamatera viewer serves (SNAPSHOT_ONLY=1), so the live
+ * site never queries Kamatera-canonical dw_unified (which LACKS the dedup_skip flags
+ * → would show ~1,500 phantom Tres Tintas). We dump:
+ * - summary → /api/summary payload
+ * - calendar → /api/calendar payload (projection computed at build time)
+ * - rows → ALL staged SKU rows (every vendor, full backlog; client pages over them)
+ *
+ * HARD read-only: only the shared server.js SELECT helpers run here. No writes anywhere.
+ */
+const fs = require('fs');
+const path = require('path');
+const lib = require('../server.js'); // exports the read-only builders when required as a module
+
+const OUT_DIR = path.join(__dirname, '..', 'data');
+const OUT = path.join(OUT_DIR, 'staged-snapshot.json');
+
+function main() {
+ const t0 = Date.now();
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+
+ console.error('[snapshot] building summary…');
+ const summary = lib.buildSummary();
+
+ console.error('[snapshot] building calendar projection…');
+ const calendar = lib.buildCalendarPayload();
+
+ console.error('[snapshot] dumping ALL staged SKU rows per vendor…');
+ const ready = lib.readyVendors();
+ const rows = [];
+ for (const { vendor, cfg } of ready) {
+ let offset = 0;
+ const CHUNK = 2000;
+ for (;;) {
+ const batch = lib.vendorRows(vendor, cfg, CHUNK, offset);
+ rows.push(...batch);
+ if (batch.length < CHUNK) break;
+ offset += batch.length;
+ }
+ console.error(`[snapshot] ${vendor}: ${rows.filter(r => r.vendor === vendor).length} rows`);
+ }
+ // Global newest-first order so the blended ("All vendors") client paging is stable.
+ rows.sort((a, b) => String(b.staged_at || '').localeCompare(String(a.staged_at || '')));
+
+ const snapshot = {
+ schema: 1,
+ generatedAt: new Date().toISOString(),
+ source: 'mac2-local dw_unified (canonical dedup_skip)',
+ summary,
+ calendar,
+ rowCount: rows.length,
+ rows,
+ };
+
+ fs.writeFileSync(OUT, JSON.stringify(snapshot));
+ const kb = Math.round(fs.statSync(OUT).size / 1024);
+ console.error(`[snapshot] wrote ${OUT} (${rows.length} rows, ${kb} KB) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+}
+
+main();
diff --git a/scripts/refresh-and-push.sh b/scripts/refresh-and-push.sh
new file mode 100755
index 0000000..0e9ef28
--- /dev/null
+++ b/scripts/refresh-and-push.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# refresh-and-push.sh (Mac2-only)
+# Regenerates the staged snapshot from Mac2-local dw_unified, then rsyncs it to the
+# Kamatera-hosted viewer so https://staged.designerwallcoverings.com stays current.
+# Runs hourly via launchd (com.steve.dw-staged-active-snapshot), aligned ~with the cadence.
+# READ-ONLY against the DB; the only write is the static JSON + the rsync to Kamatera.
+set -euo pipefail
+
+PROJ="$HOME/Projects/dw-staged-active-viewer"
+REMOTE_HOST="my-server"
+REMOTE_PATH="/root/public-projects/staged-viewer/data/staged-snapshot.json"
+LOG="$PROJ/tmp/refresh.log"
+mkdir -p "$PROJ/tmp"
+
+# Use a login shell PATH so node + psql resolve under launchd.
+export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
+
+{
+ echo "=== $(date '+%Y-%m-%d %H:%M:%S') refresh start ==="
+ cd "$PROJ"
+ node scripts/build-snapshot.js
+ # atomic rsync to a temp name then mv on the remote so the live reader never sees a half file
+ rsync -az --timeout=60 "$PROJ/data/staged-snapshot.json" \
+ "$REMOTE_HOST:${REMOTE_PATH}.tmp"
+ ssh "$REMOTE_HOST" "mv -f '${REMOTE_PATH}.tmp' '${REMOTE_PATH}'"
+ echo "=== $(date '+%Y-%m-%d %H:%M:%S') refresh + push OK ==="
+} >> "$LOG" 2>&1
diff --git a/server.js b/server.js
index c131634..4b59239 100644
--- a/server.js
+++ b/server.js
@@ -23,6 +23,25 @@ const { execFileSync } = require('child_process');
const PORT = process.env.PORT || 9846;
const DB = 'dw_unified';
+// SNAPSHOT_ONLY=1 → serve from data/staged-snapshot.json instead of querying psql.
+// This is the mode the Kamatera-hosted copy runs in: the snapshot is generated on
+// Mac2 (where the canonical dedup_skip flags live) and rsynced up hourly. The SAME
+// server.js runs in both places; only this flag differs.
+const SNAPSHOT_ONLY = process.env.SNAPSHOT_ONLY === '1';
+const SNAPSHOT_FILE = path.join(__dirname, 'data', 'staged-snapshot.json');
+
+let _snap = null, _snapMtime = 0;
+function loadSnapshot() {
+ // Reload if the file changed on disk (hourly rsync overwrites it).
+ let st;
+ try { st = fs.statSync(SNAPSHOT_FILE); } catch { throw new Error('snapshot file missing: ' + SNAPSHOT_FILE); }
+ if (!_snap || st.mtimeMs !== _snapMtime) {
+ _snap = JSON.parse(fs.readFileSync(SNAPSHOT_FILE, 'utf8'));
+ _snapMtime = st.mtimeMs;
+ }
+ return _snap;
+}
+
// Reuse the cadence's verified vendor config + denylist verbatim so the staged set
// is identical to what the cadence will actually create.
const VENDORS = require(path.join(
@@ -275,6 +294,45 @@ function buildProjection(opts) {
};
}
+// ---- shared payload builders (used by both live psql and snapshot-generator) ----
+function buildSummary() {
+ const counts = vendorCounts();
+ const grand = counts.reduce((s, r) => s + r.count, 0);
+ const out = counts.map(r => {
+ const cfg = VENDORS[r.vendor];
+ const subs = subLines(r.vendor, cfg);
+ return { ...r, mapPriced: !!cfg.sellExpr, soldBy: cfg.soldBy, subLines: subs };
+ });
+ return { grandTotal: grand, vendors: out, generatedAt: new Date().toISOString() };
+}
+
+function buildCalendarPayload(searchParams) {
+ const params = searchParams || new URLSearchParams();
+ const now = new Date();
+ const next = new Date(now);
+ next.setMinutes(CAD.FIRE_MINUTE, 0, 0);
+ if (next.getTime() <= now.getTime()) next.setHours(next.getHours() + 1);
+ const todayFirst = new Date(now); todayFirst.setHours(0, CAD.FIRE_MINUTE, 0, 0);
+ const capClearHour = parseInt(params.get('capClearHour') || '12', 10);
+ const capClear = new Date(now); capClear.setHours(capClearHour, CAD.FIRE_MINUTE, 0, 0);
+ const blockedUntilMs = Math.max(capClear.getTime(), now.getTime());
+ const proj = buildProjection({ startEpochMs: todayFirst.getTime(), blockedUntilMs });
+ return {
+ generatedAt: now.toISOString(),
+ now: now.getTime(),
+ nextFireMs: next.getTime(),
+ capClearMs: capClear.getTime(),
+ timezoneOffsetMin: now.getTimezoneOffset(),
+ ...proj,
+ };
+}
+
+// When required as a module (build-snapshot.js), export the read-only builders and stop.
+if (require.main !== module) {
+ module.exports = { buildSummary, buildCalendarPayload, readyVendors, vendorRows, VENDORS };
+ return;
+}
+
// ---- HTTP ----
function send(res, code, body, type = 'application/json') {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
@@ -289,14 +347,11 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/summary') {
try {
- const counts = vendorCounts();
- const grand = counts.reduce((s, r) => s + r.count, 0);
- const out = counts.map(r => {
- const cfg = VENDORS[r.vendor];
- const subs = subLines(r.vendor, cfg);
- return { ...r, mapPriced: !!cfg.sellExpr, soldBy: cfg.soldBy, subLines: subs };
- });
- return send(res, 200, JSON.stringify({ grandTotal: grand, vendors: out, generatedAt: new Date().toISOString() }));
+ if (SNAPSHOT_ONLY) {
+ const snap = loadSnapshot();
+ return send(res, 200, JSON.stringify({ ...snap.summary, snapshot: true, snapshotAt: snap.generatedAt }));
+ }
+ return send(res, 200, JSON.stringify(buildSummary()));
} catch (e) {
return send(res, 500, JSON.stringify({ error: String(e.message).slice(0, 300) }));
}
@@ -304,35 +359,11 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/calendar') {
try {
- const now = new Date();
- // Next cadence fire = the next future HH:40 (or this hour's :40 if still upcoming).
- const next = new Date(now);
- next.setMinutes(CAD.FIRE_MINUTE, 0, 0);
- if (next.getTime() <= now.getTime()) next.setHours(next.getHours() + 1);
- // The PROJECTION timeline starts at the FIRST slot of TODAY (00:40 local) so the TODAY view
- // can show every one of the 24 slots (past blocked ones included), and forward days line up.
- const todayFirst = new Date(now); todayFirst.setHours(0, CAD.FIRE_MINUTE, 0, 0);
-
- // Variant-cap block window: creation is blocked until the cap clears (~12:40 PM PT today).
- // We treat any slot whose fire-time is in the past OR before the cap-clear time as "blocked"
- // (0 created). Override the clear time with ?capClearHour= (local hour) if needed.
- const capClearHour = parseInt(u.searchParams.get('capClearHour') || '12', 10);
- const capClear = new Date(now); capClear.setHours(capClearHour, CAD.FIRE_MINUTE, 0, 0);
- // Past slots (already fired before "now") also create 0 in the projection.
- const blockedUntilMs = Math.max(capClear.getTime(), now.getTime());
-
- const proj = buildProjection({ startEpochMs: todayFirst.getTime(), blockedUntilMs });
-
- // helper to know if a slot is in the past relative to now
- const out = {
- generatedAt: now.toISOString(),
- now: now.getTime(),
- nextFireMs: next.getTime(),
- capClearMs: capClear.getTime(),
- timezoneOffsetMin: now.getTimezoneOffset(),
- ...proj,
- };
- return send(res, 200, JSON.stringify(out));
+ if (SNAPSHOT_ONLY) {
+ const snap = loadSnapshot();
+ return send(res, 200, JSON.stringify({ ...snap.calendar, snapshot: true, snapshotAt: snap.generatedAt }));
+ }
+ return send(res, 200, JSON.stringify(buildCalendarPayload(u.searchParams)));
} catch (e) {
return send(res, 500, JSON.stringify({ error: String(e.message).slice(0, 300) }));
}
@@ -343,6 +374,20 @@ const server = http.createServer((req, res) => {
const vendor = u.searchParams.get('vendor') || '';
const limit = Math.min(parseInt(u.searchParams.get('limit') || '200', 10) || 200, 500);
const offset = Math.max(parseInt(u.searchParams.get('offset') || '0', 10) || 0, 0);
+
+ // ---- snapshot path: page/filter the in-memory rows exactly like the DB path ----
+ if (SNAPSHOT_ONLY) {
+ const snap = loadSnapshot();
+ let rows = snap.rows;
+ if (vendor) {
+ rows = rows.filter(r => r.vendor === vendor);
+ if (!rows.length) return send(res, 400, JSON.stringify({ error: `unknown/non-ready vendor: ${vendor}` }));
+ }
+ // rows are already globally newest-first in the snapshot; vendor-filtered keeps that order.
+ const page = rows.slice(offset, offset + limit);
+ return send(res, 200, JSON.stringify({ vendor: vendor || null, limit, offset, count: page.length, rows: page, snapshot: true, snapshotAt: snap.generatedAt }));
+ }
+
const ready = readyVendors();
const targets = vendor ? ready.filter(v => v.vendor === vendor) : ready;
if (vendor && !targets.length) return send(res, 400, JSON.stringify({ error: `unknown/non-ready vendor: ${vendor}` }));
← b272a86 Add Calendar tab (today/week/month) projecting cadence drain
·
back to Dw Staged Active Viewer
·
auto-save: 2026-06-23T12:21:34 (1 files) — server.js c0d8341 →