[object Object]

← back to Marketing Command Center

assets: background catalog warmer (boot + 6h) + manual refresh endpoint + UI freshness signal

4a911a501818ea021a574232f3fa49f39a3d83c8 · 2026-08-25 10:58:50 -0700 · Steve

Files touched

Diff

commit 4a911a501818ea021a574232f3fa49f39a3d83c8
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 10:58:50 2026 -0700

    assets: background catalog warmer (boot + 6h) + manual refresh endpoint + UI freshness signal
---
 modules/assets/index.js   | 81 +++++++++++++++++++++++++++++++++++++++++------
 public/panels/assets.html |  4 +++
 public/panels/assets.js   | 42 ++++++++++++++++++++++++
 3 files changed, 117 insertions(+), 10 deletions(-)

diff --git a/modules/assets/index.js b/modules/assets/index.js
index 6f8683b..667a717 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -249,9 +249,17 @@ async function suggestSearch(q, limit) {
   }).filter(p => p.url);
 }
 
-async function fetchCatalog() {
-  const cached = readCatCache();
-  if (cached) return { products: cached.products, cached: true };
+// Pull the newest ~500 catalog products straight from products.json and rewrite
+// the on-disk cache with a fresh fetchedAt. With { force:true } it BYPASSES the
+// TTL cache (used by the background warmer + the manual refresh endpoint) so the
+// cache stays warm even when nobody opens the #assets tab. Without force it
+// serves a still-fresh cache when one exists (the original lazy behavior).
+async function fetchCatalog(opts) {
+  const force = opts && opts.force === true;
+  if (!force) {
+    const cached = readCatCache();
+    if (cached) return { products: cached.products, cached: true, fetchedAt: cached.fetchedAt || 0 };
+  }
   const out = [];
   // products.json is paginated 250/page; 2 pages (=500) is plenty for a picker.
   for (let page = 1; page <= 2; page++) {
@@ -275,8 +283,49 @@ async function fetchCatalog() {
     }
     if (prods.length < 250) break;
   }
-  try { fs.writeFileSync(CAT_CACHE, JSON.stringify({ fetchedAt: Date.now(), products: out })); } catch { /* non-fatal */ }
-  return { products: out, cached: false };
+  const fetchedAt = Date.now();
+  try { fs.writeFileSync(CAT_CACHE, JSON.stringify({ fetchedAt, products: out })); } catch { /* non-fatal */ }
+  return { products: out, cached: false, fetchedAt };
+}
+
+// Forced re-pull that always bypasses the TTL cache and rewrites CAT_CACHE with
+// a fresh fetchedAt. Reuses fetchCatalog's pagination/normImg logic.
+async function refreshCatalog() {
+  return fetchCatalog({ force: true });
+}
+
+// ── background warmer ────────────────────────────────────────────────────────
+// Keeps the catalog cache fresh regardless of inbound traffic. If nobody opens
+// the #assets tab, the lazy GET path never fires and the on-disk copy silently
+// goes stale (observed ~9.5 days stale). This warms on boot then re-pulls every
+// ASSETS_CATALOG_REFRESH_MS (default = CAT_TTL_MS = 6h). Env-overridable so the
+// cadence can be tuned without a code change. Single-flight: overlapping ticks
+// are skipped. Never throws — a failed refresh must never crash the server.
+const CAT_REFRESH_MS = Math.max(60 * 1000, parseInt(process.env.ASSETS_CATALOG_REFRESH_MS, 10) || CAT_TTL_MS);
+let _catRefreshInFlight = false;
+let _catWarmerStarted = false;
+
+async function warmCatalogOnce(reason) {
+  if (_catRefreshInFlight) { console.log('[mcc:assets] catalog warm skipped — refresh already in flight'); return; }
+  _catRefreshInFlight = true;
+  try {
+    const { products } = await refreshCatalog();
+    console.log(`[mcc:assets] catalog warm ok (${reason}) — ${products.length} products cached`);
+  } catch (e) {
+    console.warn(`[mcc:assets] catalog warm FAILED (${reason}) — ${e && e.message ? e.message : e}`);
+  } finally {
+    _catRefreshInFlight = false;
+  }
+}
+
+function startCatalogWarmer() {
+  if (_catWarmerStarted) return; // idempotent — mount() may be called once, but guard anyway
+  _catWarmerStarted = true;
+  // warm on boot (non-blocking, errors swallowed) then on a fixed cadence.
+  setImmediate(() => { warmCatalogOnce('boot').catch(() => {}); });
+  const timer = setInterval(() => { warmCatalogOnce('interval').catch(() => {}); }, CAT_REFRESH_MS);
+  if (typeof timer.unref === 'function') timer.unref(); // don't hold the event loop open on shutdown
+  console.log(`[mcc:assets] catalog warmer scheduled every ${Math.round(CAT_REFRESH_MS / 60000)} min`);
 }
 
 // ── module ───────────────────────────────────────────────────────────────────
@@ -288,6 +337,7 @@ module.exports = {
   mount(router) {
     ensureDirs();
     seedIfEmpty();
+    startCatalogWarmer(); // keep the DW catalog cache warm on a 6h cadence, traffic or not
 
     // List — newest first. Optional ?q= case-insensitively matches the asset
     // name/title, its existing tags, and any aiTags values (colors/style/room/
@@ -397,20 +447,31 @@ module.exports = {
         if (q) {
           try {
             const hits = await suggestSearch(q, limit);
-            if (hits.length) return res.json({ products: hits.slice(0, limit), total: hits.length, cached: false, source: 'search' });
+            if (hits.length) return res.json({ products: hits.slice(0, limit), total: hits.length, cached: false, source: 'search', fetchedAt: readCatCache()?.fetchedAt || null });
           } catch { /* rate-limited / down → fall back to cached browse filter below */ }
-          const { products } = await fetchCatalog();
+          const { products, fetchedAt } = await fetchCatalog();
           const ql = q.toLowerCase();
           const rows = products.filter(p => (p.title + ' ' + p.type + ' ' + p.handle).toLowerCase().includes(ql));
-          return res.json({ products: rows.slice(0, limit), total: rows.length, cached: true, source: 'browse-filter' });
+          return res.json({ products: rows.slice(0, limit), total: rows.length, cached: true, source: 'browse-filter', fetchedAt: fetchedAt || null });
         }
-        const { products, cached } = await fetchCatalog();
-        res.json({ products: products.slice(0, limit), total: products.length, cached, source: 'browse' });
+        const { products, cached, fetchedAt } = await fetchCatalog();
+        res.json({ products: products.slice(0, limit), total: products.length, cached, source: 'browse', fetchedAt: fetchedAt || null });
       } catch (e) {
         res.status(502).json({ error: 'catalog fetch failed: ' + e.message });
       }
     });
 
+    // Manual force-refresh of the catalog cache — bypasses the TTL and re-pulls
+    // the newest ~500 products. Basic-auth gated by the shell like every route.
+    router.post('/refresh-catalog', async (_req, res) => {
+      try {
+        const { products, fetchedAt } = await refreshCatalog();
+        res.json({ ok: true, count: products.length, fetchedAt });
+      } catch (e) {
+        res.status(502).json({ ok: false, error: 'catalog refresh failed: ' + (e && e.message ? e.message : e) });
+      }
+    });
+
     // Persist a catalog image into the library. Body: { name, url }.
     router.post('/save-catalog', (req, res) => {
       const { name, url } = req.body || {};
diff --git a/public/panels/assets.html b/public/panels/assets.html
index b89c75d..ade6cbf 100644
--- a/public/panels/assets.html
+++ b/public/panels/assets.html
@@ -55,6 +55,10 @@
         </div>
         <button id="as-cat-search" class="btn">Search</button>
       </div>
+      <div class="row" style="align-items:center;justify-content:space-between;margin-bottom:8px;gap:8px;">
+        <div id="as-cat-freshness" class="muted" style="font-size:11.5px;"></div>
+        <button id="as-cat-refresh" class="btn ghost" style="font-size:11.5px;padding:4px 10px;">↻ Refresh now</button>
+      </div>
       <div id="as-cat-status" class="muted" style="font-size:12px;min-height:16px;margin-bottom:8px;"></div>
       <div id="as-cat-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;max-height:420px;overflow:auto;"></div>
     </div>
diff --git a/public/panels/assets.js b/public/panels/assets.js
index a0a72f3..fec52f2 100644
--- a/public/panels/assets.js
+++ b/public/panels/assets.js
@@ -187,6 +187,23 @@ window.MCC_PANELS['assets'] = {
     // ── catalog search ─────────────────────────────────────────────────────────
     const catGrid = $('#as-cat-grid');
     const catStatus = $('#as-cat-status');
+    const catFresh = $('#as-cat-freshness');
+
+    // Render "Catalog updated <relative time> ago" from the cache fetchedAt.
+    function relTime(ms) {
+      if (!ms) return '';
+      const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
+      if (s < 60) return 'just now';
+      const m = Math.round(s / 60); if (m < 60) return `${m} min ago`;
+      const h = Math.round(m / 60); if (h < 48) return `${h} hr ago`;
+      return `${Math.round(h / 24)} days ago`;
+    }
+    function setFreshness(fetchedAt) {
+      if (!catFresh) return;
+      catFresh.textContent = fetchedAt ? `Catalog updated ${relTime(fetchedAt)}` : 'Catalog freshness unknown';
+      catFresh.title = fetchedAt ? new Date(fetchedAt).toISOString() : '';
+    }
+
     async function searchCatalog() {
       const q = $('#as-cat-q').value.trim();
       catStatus.textContent = 'Searching the live DW catalog…';
@@ -195,6 +212,7 @@ window.MCC_PANELS['assets'] = {
         const r = await fetchO('/api/assets/catalog?limit=80&q=' + encodeURIComponent(q));
         const d = await r.json();
         if (!r.ok) throw new Error(d.error || r.status);
+        setFreshness(d.fetchedAt);
         catStatus.textContent = `${d.products.length} of ${d.total} match${d.total === 1 ? '' : 'es'}${d.cached ? ' · cached' : ''}. Click an image to save it to your library.`;
         catGrid.innerHTML = d.products.map(p => `
           <div class="as-cat-card" data-url="${esc(p.url)}" data-name="${esc(p.title)}" title="${esc(p.title)} — click to save" style="cursor:pointer;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff;">
@@ -223,6 +241,30 @@ window.MCC_PANELS['assets'] = {
     $('#as-cat-search').addEventListener('click', searchCatalog);
     $('#as-cat-q').addEventListener('keydown', e => { if (e.key === 'Enter') searchCatalog(); });
 
+    // Force a catalog cache refresh, then re-render (so the picker + freshness
+    // line reflect the newest ~500 products immediately).
+    const catRefreshBtn = $('#as-cat-refresh');
+    if (catRefreshBtn) catRefreshBtn.addEventListener('click', async () => {
+      catRefreshBtn.disabled = true;
+      const prev = catStatus.textContent;
+      catStatus.textContent = 'Refreshing the DW catalog cache…';
+      try {
+        const r = await fetchO('/api/assets/refresh-catalog', { method: 'POST' });
+        const d = await r.json();
+        if (!r.ok || !d.ok) throw new Error(d.error || r.status);
+        setFreshness(d.fetchedAt);
+        catStatus.textContent = `Catalog refreshed — ${d.count} products cached.`;
+        // re-render whatever the current query is (blank = browse newest)
+        searchCatalog();
+      } catch (e) {
+        catStatus.textContent = 'Refresh failed: ' + e.message; catStatus.style.color = '#e07a5f';
+      } finally { catRefreshBtn.disabled = false; }
+    });
+
+    // Show freshness as soon as the catalog pane is available, without waiting
+    // for a search (cheap — a browse GET reads the cache).
+    fetchO('/api/assets/catalog?limit=1').then(r => r.json()).then(d => setFreshness(d && d.fetchedAt)).catch(() => {});
+
     // ── GDrive: import folder (rclone) ──────────────────────────────────────────
     $('#as-gd-import').addEventListener('click', async () => {
       const path = $('#as-gd-path').value.trim();

← 4c5fac6 Route settlement vision gate to local $0 ollama qwen2.5vl (b  ·  back to Marketing Command Center  ·  Composer: one create flow — photo → details → song → decide 3c85e99 →