[object Object]

← back to Muralsource Internal

contrarian FIX-FIRST: purge dead astek files (scraper/monthly-refresh/2 launchd plists), fix .deploy.conf (safe path, cannot overwrite live astek), honest package.json; replace lying 15-min refresh countdown with truthful static-snapshot-age pill (no cron on this line)

87ab73056a7bb23b5e88e808307af67da24f7a52 · 2026-07-26 19:02:16 -0700 · Steve Abrams

Files touched

Diff

commit 87ab73056a7bb23b5e88e808307af67da24f7a52
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 19:02:16 2026 -0700

    contrarian FIX-FIRST: purge dead astek files (scraper/monthly-refresh/2 launchd plists), fix .deploy.conf (safe path, cannot overwrite live astek), honest package.json; replace lying 15-min refresh countdown with truthful static-snapshot-age pill (no cron on this line)
---
 .deploy.conf                                  |  14 +-
 launchd/com.steve.astek-data-refresh.plist    |  17 --
 launchd/com.steve.astek-monthly-refresh.plist |  23 ---
 package.json                                  |   6 +-
 public/index.html                             |  45 +++--
 scripts/monthly-refresh.sh                    |  41 ----
 scripts/scrape-astek.js                       | 263 --------------------------
 server.js                                     |  10 +-
 8 files changed, 39 insertions(+), 380 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
index 2755777..85ecfca 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -1,6 +1,8 @@
-# astek-landing deploy config (GATED — do not run without Steve's approval)
-PROJECT_NAME="astek-landing"
-DEPLOY_PATH="/root/Projects/astek-landing"
-# Live Kamatera port is 9960 (pm2 id 272, deploy 08fa699). 9944 was the local dev port.
-HEALTH_URL="http://localhost:9960/healthz"
-PORT="9960"
+# muralsource-internal deploy config (GATED — do not run without Steve's approval)
+# LOCAL-ONLY for now: internal line viewer on Mac2 pm2 :9947. No Kamatera target
+# provisioned yet — DEPLOY_PATH is a NEW path so a stray /deploy can NEVER overwrite
+# the live astek site (the clone-origin). Provision the Kamatera port before deploying.
+PROJECT_NAME="muralsource-internal"
+DEPLOY_PATH="/root/public-projects/muralsource-internal"
+HEALTH_URL="http://localhost:9947/healthz"
+PORT="9947"
diff --git a/launchd/com.steve.astek-data-refresh.plist b/launchd/com.steve.astek-data-refresh.plist
deleted file mode 100644
index dae3868..0000000
--- a/launchd/com.steve.astek-data-refresh.plist
+++ /dev/null
@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0"><dict>
-  <key>Label</key><string>com.steve.astek-data-refresh</string>
-  <key>ProgramArguments</key><array><string>/opt/homebrew/bin/node</string><string>/Users/macstudio3/Projects/astek-landing/scripts/build-products-json.js</string></array>
-  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/astek-landing/scripts</string>
-  <key>StartCalendarInterval</key><array>
-    <dict><key>Minute</key><integer>0</integer></dict>
-    <dict><key>Minute</key><integer>15</integer></dict>
-    <dict><key>Minute</key><integer>30</integer></dict>
-    <dict><key>Minute</key><integer>45</integer></dict>
-  </array>
-  <key>RunAtLoad</key><true/>
-  <key>StandardOutPath</key><string>/tmp/astek-data-refresh.log</string>
-  <key>StandardErrorPath</key><string>/tmp/astek-data-refresh.log</string>
-  <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/usr/bin:/bin</string></dict>
-</dict></plist>
diff --git a/launchd/com.steve.astek-monthly-refresh.plist b/launchd/com.steve.astek-monthly-refresh.plist
deleted file mode 100644
index 284b4da..0000000
--- a/launchd/com.steve.astek-monthly-refresh.plist
+++ /dev/null
@@ -1,23 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0"><dict>
-  <key>Label</key><string>com.steve.astek-monthly-refresh</string>
-  <key>ProgramArguments</key>
-    <array>
-      <string>/bin/bash</string>
-      <string>/Users/macstudio3/Projects/astek-landing/scripts/monthly-refresh.sh</string>
-    </array>
-  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/astek-landing</string>
-  <!-- 1st of every month, 04:30 local -->
-  <key>StartCalendarInterval</key>
-    <dict>
-      <key>Day</key><integer>1</integer>
-      <key>Hour</key><integer>4</integer>
-      <key>Minute</key><integer>30</integer>
-    </dict>
-  <key>RunAtLoad</key><false/>
-  <key>StandardOutPath</key><string>/tmp/astek-monthly-refresh.log</string>
-  <key>StandardErrorPath</key><string>/tmp/astek-monthly-refresh.log</string>
-  <key>EnvironmentVariables</key>
-    <dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
-</dict></plist>
diff --git a/package.json b/package.json
index b2c2ffd..0a0ad49 100644
--- a/package.json
+++ b/package.json
@@ -2,11 +2,11 @@
   "name": "muralsource-internal",
   "version": "0.2.1",
   "private": true,
-  "description": "Editorial vendor landing for Astek (INTERNAL dw_unified data — not on Shopify)",
+  "description": "Internal basic-auth line viewer for Mural Source (dw_unified.muralsource_catalog — off Shopify, DTD verdict C)",
   "scripts": {
-    "scrape": "node scripts/scrape-astek.js",
     "build-products": "node scripts/build-products-json.js",
-    "start": "node server.js"
+    "start": "node server.js",
+    "build": "node scripts/build-products-json.js"
   },
   "dependencies": {
     "compression": "^1.8.1",
diff --git a/public/index.html b/public/index.html
index 1b0ec4a..dffae1f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -414,37 +414,36 @@ async function loadAll(){
 }
 loadAll();
 
-/* ── Corner refresh countdown ──────────────────────────────────────────────
-   Data is rebuilt from dw_unified every 15 min by a cron. This pill counts down
-   to the next refresh (synced to the real data-file timestamp via /api/meta) and
-   pulls fresh data in-place when it lands — no manual reload. */
+/* ── Corner snapshot-age pill ───────────────────────────────────────────────
+   HONEST: this internal line has NO auto-refresh cron. data/products.json is a
+   STATIC snapshot rebuilt only when someone runs `npm run build`
+   (node scripts/build-products-json.js) from dw_unified.muralsource_catalog.
+   The pill shows the snapshot's age; it polls /api/meta and hot-swaps ONLY if
+   the file is actually rebuilt (advanced mtime). No fake countdown. */
 (function(){
-  var meta={lastRefresh:null,intervalSec:900}, target=0, waiting=false;
+  var last=null;
   var pill=document.createElement('div');
   pill.className='muralsource-refresh-pill';
-  pill.title='Mural Source catalog data auto-refreshes from Designer Wallcoverings every 15 minutes';
-  pill.innerHTML='<span class="rp-dot"></span><span class="rp-txt">syncing…</span>';
+  pill.title='Static snapshot of the Mural Source catalog (dw_unified.muralsource_catalog). Rebuilt manually — no auto-refresh cron on this internal line.';
+  pill.innerHTML='<span class="rp-dot"></span><span class="rp-txt">snapshot…</span>';
   document.body.appendChild(pill);
   var txt=pill.querySelector('.rp-txt');
-  function fmt(s){s=Math.max(0,Math.round(s));var m=Math.floor(s/60);return m+':'+String(s%60).padStart(2,'0');}
-  async function syncMeta(){
+  function age(iso){
+    var s=Math.max(0,(Date.now()-new Date(iso).getTime())/1000);
+    if(s<90) return 'just now';
+    if(s<5400) return Math.round(s/60)+'m ago';
+    if(s<172800) return Math.round(s/3600)+'h ago';
+    return Math.round(s/86400)+'d ago';
+  }
+  async function sync(){
     try{
       var m=await fetch(location.origin+'/api/meta').then(r=>r.json());
-      var advanced = meta.lastRefresh && m.lastRefresh!==meta.lastRefresh;
-      meta=m;
-      var t=new Date(m.lastRefresh).getTime()+m.intervalSec*1000;
-      target = (t<=Date.now()) ? Date.now()+m.intervalSec*1000 : t; // clamp stale files to a fresh cycle
-      if(advanced) loadAll();
-      return advanced;
-    }catch(e){ return false; }
-  }
-  async function tick(){
-    var rem=(target-Date.now())/1000;
-    if(rem>0){ txt.textContent='Next refresh in '+fmt(rem); pill.classList.remove('rp-live'); }
-    else if(!waiting){ txt.textContent='Updating…'; pill.classList.add('rp-live'); waiting=true;
-      var adv=await syncMeta(); if(!adv) target=Date.now()+7000; waiting=false; }
+      if(last && m.lastRefresh!==last) loadAll();          // manual rebuild landed → hot-swap
+      last=m.lastRefresh;
+      txt.textContent='📷 Snapshot · '+age(m.lastRefresh);
+    }catch(e){}
   }
-  syncMeta().then(function(){ tick(); setInterval(tick,1000); });
+  sync(); setInterval(sync,30000);
 })();
 </script>
 </body></html>
diff --git a/scripts/monthly-refresh.sh b/scripts/monthly-refresh.sh
deleted file mode 100755
index 97ef10b..0000000
--- a/scripts/monthly-refresh.sh
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/bash
-# Astek monthly full refresh: re-scrape astek.com -> astek_catalog -> rebuild
-# snapshot -> deploy the fresh data to the live (gated, internal) microsite.
-#
-# The existing com.steve.astek-data-refresh only REBUILDS the local snapshot
-# from the DB every 15 min; it never re-scrapes the vendor and never deploys.
-# This monthly job closes both gaps. Scrape+rebuild are $0 local; the deploy
-# targets an internal basic-auth microsite (no Shopify, no prices) so an
-# unattended monthly push is low-risk.
-set -uo pipefail
-
-# launchd hands us a minimal PATH — restore node + ssh/rsync (deploy.sh needs them).
-export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
-
-PROJ="/Users/macstudio3/Projects/astek-landing"
-LOG="/tmp/astek-monthly-refresh.log"
-cd "$PROJ" || { echo "$(date '+%F %T') FATAL cd $PROJ failed" >>"$LOG"; exit 1; }
-
-echo "===== $(date '+%F %T') astek monthly refresh START =====" >>"$LOG"
-
-echo "[1/3] scrape astek.com -> astek_catalog" >>"$LOG"
-if ! node scripts/scrape-astek.js >>"$LOG" 2>&1; then
-  echo "$(date '+%F %T') SCRAPE FAILED — aborting (snapshot + live left untouched)" >>"$LOG"; exit 1
-fi
-
-echo "[2/3] rebuild data/products.json" >>"$LOG"
-if ! node scripts/build-products-json.js >>"$LOG" 2>&1; then
-  echo "$(date '+%F %T') REBUILD FAILED — aborting before deploy" >>"$LOG"; exit 1
-fi
-
-# Commit the refreshed data so the deploy ships a tracked snapshot.
-git add data/products.json data/astek-raw.json 2>/dev/null
-git -c user.email="steve@designerwallcoverings.com" \
-    commit -q -m "data: astek monthly refresh $(date '+%F')" 2>/dev/null || true
-
-echo "[3/3] deploy fresh snapshot to Kamatera" >>"$LOG"
-if ! bash /Users/macstudio3/Projects/_shared/scripts/deploy.sh >>"$LOG" 2>&1; then
-  echo "$(date '+%F %T') DEPLOY FAILED — local snapshot is fresh, live is stale" >>"$LOG"; exit 1
-fi
-
-echo "===== $(date '+%F %T') astek monthly refresh DONE =====" >>"$LOG"
diff --git a/scripts/scrape-astek.js b/scripts/scrape-astek.js
deleted file mode 100644
index 42d5d69..0000000
--- a/scripts/scrape-astek.js
+++ /dev/null
@@ -1,263 +0,0 @@
-#!/usr/bin/env node
-/**
- * Workstream A — Astek catalog scrape (INTERNAL DATA ONLY, no Shopify).
- * Feed-first: paginate https://www.astek.com/products.json?limit=250&page=N
- * PostgreSQL-FIRST: raw snapshot -> astek_catalog in dw_unified -> register DW SKUs.
- *
- * Cost: $0 (local HTTP). No Browserbase.
- */
-const fs = require('fs');
-const path = require('path');
-const { Pool } = require('pg');
-
-const VENDOR = 'Astek';
-const VENDOR_CODE = 'astek';     // existing canonical vendor_registry row
-const DW_PREFIX = 'DWPX';        // pre-assigned to Astek in vendor_registry (compliant: P,X allowed; 0 SKUs minted)
-const SKU_RANGE_START = 400000;  // Astek block (kept distinct from legacy ranges)
-const FEED = 'https://www.astek.com/products.json';
-const RAW_OUT = path.join(__dirname, '..', 'data', 'astek-raw.json');
-
-const PW = (() => {
-  try {
-    const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
-    const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m);
-    return m ? m[1].replace(/^["']|["']$/g, '').trim() : (process.env.PGPASSWORD || '');
-  } catch { return process.env.PGPASSWORD || ''; }
-})();
-
-const pool = new Pool({
-  host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW,
-});
-
-const BANNED = s => (s || '')
-  .replace(/\bWallpapers\b/gi, 'Wallcoverings')
-  .replace(/\bWallpaper\b/gi, 'Wallcovering');
-
-function toTitleCase(str) {
-  const small = new Set(['a','an','the','and','but','or','for','nor','on','at','to','from','by','in','of','with']);
-  return (str || '').split(/\s+/).map((w, i) => {
-    const lw = w.toLowerCase();
-    if (i !== 0 && small.has(lw)) return lw;
-    return w.charAt(0).toUpperCase() + w.slice(1);
-  }).join(' ').trim();
-}
-
-// Parse specs out of the body_html prose / spec block.
-function parseSpecs(html) {
-  const text = (html || '').replace(/<[^>]+>/g, ' ').replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
-  const grab = (re) => { const m = text.match(re); return m ? m[1].trim() : null; };
-  return {
-    width:    grab(/(?:Roll\s*Width|Width)[:\s]*([0-9][0-9.\s\/"]*(?:in(?:ches)?|"|cm|mm)?)/i),
-    length:   grab(/(?:Roll\s*Length|Length)[:\s]*([0-9][0-9.\s\/"]*(?:ft|feet|yd|yard|in(?:ches)?|m)?)/i),
-    repeat:   grab(/(?:Pattern\s*Repeat|Repeat)[:\s]*([0-9][0-9.\s\/"]*(?:in(?:ches)?|"|cm)?)/i),
-    material: grab(/(?:Material|Substrate|Type|Content)[:\s]*([A-Za-z][A-Za-z0-9 ,\/\-]{2,40})/i),
-    match:    grab(/(?:Match)[:\s]*([A-Za-z][A-Za-z ]{2,30})/i),
-  };
-}
-
-const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
-
-// Shopify/CF occasionally returns a transient 503 on the products.json feed
-// (seen 2026-07-13). Retry with backoff so the monthly unattended job doesn't
-// die on a single blip. Raises only after all attempts are exhausted.
-async function fetchFeedPage(url, attempts = 4) {
-  let lastStatus = 0;
-  for (let i = 0; i < attempts; i++) {
-    const res = await fetch(url, { headers: { 'User-Agent': UA } });
-    if (res.ok) return res;
-    lastStatus = res.status;
-    if (i < attempts - 1) await new Promise(r => setTimeout(r, 1500 * (i + 1))); // 1.5s, 3s, 4.5s
-  }
-  throw new Error(`feed HTTP ${lastStatus} after ${attempts} attempts`);
-}
-
-async function fetchAllProducts() {
-  let page = 1, all = [];
-  while (true) {
-    const url = `${FEED}?limit=250&page=${page}`;
-    const res = await fetchFeedPage(url);
-    const json = await res.json();
-    const products = json.products || [];
-    if (products.length === 0) break;
-    all.push(...products);
-    process.stdout.write(`\r  fetched page ${page} (total ${all.length})   `);
-    page++;
-    if (page > 40) break; // hard safety cap
-    await new Promise(r => setTimeout(r, 120)); // polite
-  }
-  process.stdout.write('\n');
-  return all;
-}
-
-async function ensureSchema(client) {
-  await client.query(`
-    CREATE TABLE IF NOT EXISTS astek_catalog (
-      id                 BIGSERIAL PRIMARY KEY,
-      dw_sku             TEXT UNIQUE,
-      mfr_sku            TEXT NOT NULL,
-      shopify_product_id BIGINT,
-      handle             TEXT,
-      pattern_name       TEXT,
-      color_name         TEXT,
-      title              TEXT,             -- Pattern Color | Astek (title-cased, banned-word clean)
-      vendor             TEXT DEFAULT 'Astek',
-      product_type       TEXT,
-      body_html          TEXT,
-      description_text   TEXT,
-      price              NUMERIC,
-      cost               NUMERIC,
-      width              TEXT,
-      length             TEXT,
-      repeat             TEXT,
-      material           TEXT,
-      match_type         TEXT,
-      color_tags         TEXT[],
-      style_tags         TEXT[],
-      all_images         TEXT[],
-      image_url          TEXT,             -- primary image
-      variant_image      TEXT,
-      product_url        TEXT,
-      raw                JSONB,
-      created_at         TIMESTAMPTZ DEFAULT NOW(),
-      updated_at         TIMESTAMPTZ DEFAULT NOW(),
-      UNIQUE (mfr_sku)
-    );
-    CREATE INDEX IF NOT EXISTS astek_catalog_pattern_idx ON astek_catalog (pattern_name);
-    CREATE INDEX IF NOT EXISTS astek_catalog_handle_idx  ON astek_catalog (handle);
-  `);
-}
-
-// registry helpers (inline; mirror lib/sku-registry semantics but with our pooled client)
-async function checkDup(client, mfrSku) {
-  const r = await client.query(
-    `SELECT dw_sku FROM dw_sku_registry WHERE vendor_prefix=$1 AND mfr_sku=$2`, [DW_PREFIX, mfrSku]);
-  return r.rows[0]?.dw_sku || null;
-}
-async function nextSkuNum(client) {
-  const r = await client.query(
-    `SELECT MAX(CAST(SUBSTRING(dw_sku FROM '[0-9]+$') AS INTEGER)) mx FROM dw_sku_registry WHERE vendor_prefix=$1`,
-    [DW_PREFIX]);
-  return r.rows[0]?.mx ? (r.rows[0].mx + 1) : SKU_RANGE_START;
-}
-
-async function main() {
-  console.log(`\n[Astek scrape] prefix=${DW_PREFIX} range_start=${SKU_RANGE_START}`);
-  console.log('Fetching products.json feed ($0 local HTTP)...');
-  const products = await fetchAllProducts();
-  console.log(`Products: ${products.length}`);
-  fs.writeFileSync(RAW_OUT, JSON.stringify({ scraped_at: new Date().toISOString(), count: products.length, products }, null, 0));
-  console.log(`Raw snapshot -> ${RAW_OUT}`);
-
-  const client = await pool.connect();
-  let rows = 0, variants = 0, images = 0, registered = 0, dups = 0, skipped = 0;
-  try {
-    await ensureSchema(client);
-
-    // Seed the sku counter once, then increment locally to avoid N round-trips.
-    let counter = await nextSkuNum(client);
-
-    for (const p of products) {
-      const specs = parseSpecs(p.body_html);
-      const descText = BANNED((p.body_html || '').replace(/<[^>]+>/g, ' ').replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim());
-      const patternName = BANNED(toTitleCase((p.title || '').replace(/\s*Wallcovering\s*$/i, '').replace(/\s*Wallpaper\s*$/i, '').trim()));
-      const colorTags = (p.tags || []).filter(t => /^color_/.test(t)).map(t => t.replace(/^color_/, ''));
-      // ONLY style_ tags are real styles — feature_/design_ carry ops junk (instock, pricing, color)
-      const styleTags = (p.tags || []).filter(t => /^style_/.test(t)).map(t => t.replace(/^style_/, ''));
-      const allImages = (p.images || []).map(i => i.src);
-      images += allImages.length;
-
-      // Map variant image ids -> src
-      const imgById = {};
-      for (const im of (p.images || [])) for (const vid of (im.variant_ids || [])) imgById[vid] = im.src;
-
-      const vlist = (p.variants || []);
-      for (const v of vlist) {
-        variants++;
-        // MFR SKU: prefer clean variant SKU; fall back to handle+position.
-        let mfrSku = (v.sku && v.sku.trim()) ? v.sku.trim() : `${p.handle}-${v.position || v.id}`;
-        // Real color name from option/variant title (NEVER "Unknown").
-        let colorName = (v.option1 && v.option1.trim()) || (v.title && v.title.trim()) || '';
-        if (/^unknown$/i.test(colorName)) colorName = '';
-
-        // Title: Pattern Color | Astek  (fallback order: color -> mfr sku -> none; never "Unknown")
-        let titleColor = colorName ? toTitleCase(colorName) : '';
-        let title = titleColor ? `${patternName} ${titleColor} | Astek` : `${patternName} | Astek`;
-        if (!patternName) { // last-resort pattern fallback: mfr sku
-          title = `${mfrSku}${titleColor ? ' ' + titleColor : ''} | Astek`;
-        }
-        title = BANNED(title);
-
-        const priceNum = (v.price != null && parseFloat(v.price) > 0) ? parseFloat(v.price) : null;
-        const variantImg = imgById[v.id] || v.featured_image?.src || allImages[0] || null;
-
-        // ---- PostgreSQL FIRST: register DW SKU ----
-        // dw_sku_registry.mfr_sku is varchar(50); some Astek feed skus jam color tags in
-        // and overflow it — register those under a deterministic hash-truncated key while
-        // astek_catalog (TEXT) keeps the full sku.
-        const regSku = mfrSku.length <= 50
-          ? mfrSku
-          : mfrSku.slice(0, 41) + '~' + require('crypto').createHash('sha1').update(mfrSku).digest('hex').slice(0, 8);
-        let dwSku = await checkDup(client, regSku);
-        if (dwSku) { dups++; }
-        else {
-          dwSku = `${DW_PREFIX}-${counter}`;
-          try {
-            await client.query(
-              `INSERT INTO dw_sku_registry (dw_sku, vendor_prefix, vendor_name, mfr_sku) VALUES ($1,$2,$3,$4)`,
-              [dwSku, DW_PREFIX, VENDOR, regSku]);
-            registered++; counter++;
-          } catch (e) {
-            if (e.code === '23505') { dwSku = await checkDup(client, regSku); dups++; }
-            else { console.error('reg err', mfrSku, e.message); skipped++; continue; }
-          }
-        }
-
-        // ---- upsert catalog row ----
-        await client.query(`
-          INSERT INTO astek_catalog
-            (dw_sku, mfr_sku, handle, pattern_name, color_name, title, product_type,
-             body_html, description_text, price, cost, width, length, repeat, material, match_type,
-             color_tags, style_tags, all_images, image_url, variant_image, product_url, raw, updated_at)
-          VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,NOW())
-          ON CONFLICT (mfr_sku) DO UPDATE SET
-            dw_sku=EXCLUDED.dw_sku, pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name,
-            title=EXCLUDED.title, body_html=EXCLUDED.body_html, description_text=EXCLUDED.description_text,
-            price=EXCLUDED.price, width=EXCLUDED.width, length=EXCLUDED.length, repeat=EXCLUDED.repeat,
-            material=EXCLUDED.material, match_type=EXCLUDED.match_type, color_tags=EXCLUDED.color_tags,
-            style_tags=EXCLUDED.style_tags, all_images=EXCLUDED.all_images, image_url=EXCLUDED.image_url,
-            variant_image=EXCLUDED.variant_image, product_url=EXCLUDED.product_url, raw=EXCLUDED.raw,
-            updated_at=NOW()
-        `, [
-          dwSku, mfrSku, p.handle, patternName, colorName ? toTitleCase(colorName) : null, title, p.product_type,
-          p.body_html, descText, priceNum, null, specs.width, specs.length, specs.repeat, specs.material, specs.match,
-          colorTags, styleTags, allImages, allImages[0] || null, variantImg,
-          `https://www.astek.com/products/${p.handle}`, JSON.stringify(v),
-        ]);
-        rows++;
-      }
-    }
-
-    // update the existing canonical Astek vendor_registry row (internal, skip_shopify true)
-    await client.query(`
-      UPDATE vendor_registry
-      SET sku_prefix=$1, sku_range_start=$2, catalog_table='astek_catalog',
-          skip_shopify=true, is_active=true,
-          notes='INTERNAL ONLY — astek.designerwallcoverings.com landing; deliberately NOT on Shopify',
-          updated_at=NOW()
-      WHERE vendor_code=$3
-    `, [DW_PREFIX + '-', SKU_RANGE_START, VENDOR_CODE]).catch(e => console.warn('vendor_registry update skipped:', e.message));
-
-  } finally {
-    client.release();
-  }
-
-  const tot = await pool.query('SELECT COUNT(*) c, COUNT(DISTINCT pattern_name) p FROM astek_catalog');
-  console.log(`\n=== DONE ===`);
-  console.log(`catalog rows (variants): ${rows} | distinct patterns: ${tot.rows[0].p}`);
-  console.log(`variants seen: ${variants} | images seen: ${images}`);
-  console.log(`DW SKUs newly registered: ${registered} | dupes reused: ${dups} | skipped: ${skipped}`);
-  console.log(`astek_catalog total rows now: ${tot.rows[0].c}`);
-  await pool.end();
-}
-
-main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/server.js b/server.js
index 37135b9..f6bd0bf 100644
--- a/server.js
+++ b/server.js
@@ -34,7 +34,9 @@ app.use(express.json({ limit: '2mb' }));
 let SNAP = { products: [], facets: { total: 0, books: [], series: [], colors: [] } };
 let LIGHT = []; // grid-index payload — heavy PDP-only fields stripped
 let LAST_REFRESH = new Date().toISOString();
-const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 900); // 15 min, matches the cron
+// STATIC snapshot line: no auto-refresh cron. products.json is rebuilt manually
+// (npm run build). 0 = "no cron" → the corner pill shows snapshot age, not a countdown.
+const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 0);
 function load() {
   SNAP = JSON.parse(fs.readFileSync(DATA, 'utf8'));
   // the index grid never renders body_html / images[] / eyebrow — keep those PDP-only
@@ -43,7 +45,7 @@ function load() {
   console.log(`[muralsource] loaded ${SNAP.products.length} products (data mtime ${LAST_REFRESH})`);
 }
 load();
-// Hot-reload when the 15-min cron rewrites data/products.json — no restart needed.
+// Hot-reload if data/products.json is rebuilt manually (npm run build) — no restart needed.
 fs.watchFile(DATA, { interval: 5000 }, (cur, prev) => {
   if (cur.mtimeMs !== prev.mtimeMs) { try { load(); } catch (e) { console.error('[muralsource] reload failed:', e.message); } }
 });
@@ -77,9 +79,9 @@ const CONFIG = {
 };
 
 // vendorMeta (phone / our account # / discount / pricing model) rides in from
-// data/products.json — the 15-min refresh keeps it current from PG.
+// data/products.json — refreshed when the snapshot is rebuilt manually (npm run build).
 app.get('/api/config', (_req, res) => res.json({ ...CONFIG, vendorMeta: SNAP.vendor || null }));
-// Refresh metadata — drives the corner countdown pill on the landing.
+// Snapshot metadata — drives the corner snapshot-age pill (intervalSec 0 = no cron).
 app.get('/api/meta', (_req, res) => res.json({
   lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
   count: SNAP.products.length, now: new Date().toISOString(),

← 71f0ef9 auto-save: 2026-07-26T16:44:36 (1 files) — public/index.html  ·  back to Muralsource Internal  ·  (newest)