[object Object]

← 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

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 →