← 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
M modules/assets/index.jsM public/panels/assets.htmlM public/panels/assets.js
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 →