[object Object]

← back to Whatsmystyle

yolo tick 19: embed-progress live bar (30s poll + ETA) + production-credit ★ badge on cards (set-decorator only) + 4 stuck brands marked disabled with reason + Shopify adapter detects HTML-when-expecting-JSON

7bf88789797819f577eb02d85c09f2471ffa4e10 · 2026-05-12 10:13:54 -0700 · SteveStudio2

Files touched

Diff

commit 7bf88789797819f577eb02d85c09f2471ffa4e10
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 10:13:54 2026 -0700

    yolo tick 19: embed-progress live bar (30s poll + ETA) + production-credit ★ badge on cards (set-decorator only) + 4 stuck brands marked disabled with reason + Shopify adapter detects HTML-when-expecting-JSON
---
 public/admin-config.html          | 55 +++++++++++++++++++++++++++++++++++++++
 public/css/app.css                | 17 ++++++++++++
 public/js/app.js                  |  8 +++++-
 scripts/clothing-apis/brands.json |  8 +++---
 scripts/clothing-apis/index.js    | 10 ++++++-
 scripts/clothing-apis/shopify.js  | 14 ++++++++--
 server.js                         | 28 +++++++++++++++++---
 7 files changed, 129 insertions(+), 11 deletions(-)

diff --git a/public/admin-config.html b/public/admin-config.html
index c896259..e624759 100644
--- a/public/admin-config.html
+++ b/public/admin-config.html
@@ -109,6 +109,23 @@
     <span class="muted">Loading catalog status…</span>
   </div>
 
+  <!-- Tick 19: live embed progress bar. Polls /api/admin/catalog-status every
+       30s while unembedded > 0, fades out when the catalog is fully embedded. -->
+  <div id="embed_progress" class="embed-progress" hidden>
+    <div class="embed-progress-head">
+      <span><strong id="embed_pct">0</strong>% embedded · <span id="embed_remaining">0</span> items left</span>
+      <span class="muted" id="embed_eta"></span>
+    </div>
+    <div class="embed-progress-track"><div class="embed-progress-fill" id="embed_fill"></div></div>
+  </div>
+  <style>
+    .embed-progress { margin: 12px 0 16px; padding: 14px 18px; background: #fff; border: 1px solid #e6e1d8; border-radius: 16px; }
+    .embed-progress.fading { opacity: 0; transition: opacity 1.2s ease-out; pointer-events: none; }
+    .embed-progress-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; margin-bottom: 8px; }
+    .embed-progress-track { height: 10px; background: #f3eee2; border-radius: 999px; overflow: hidden; }
+    .embed-progress-fill { height: 100%; background: linear-gradient(90deg, #c9a96e 0%, #6b4f1a 100%); border-radius: 999px; width: 0%; transition: width 0.6s ease-out; }
+  </style>
+
   <div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
     <button id="import_all_shopify" class="save" type="button">Import all Shopify brands →</button>
     <button id="import_one"          class="save" type="button" style="background:#5a3a1f;">Import one brand…</button>
@@ -183,6 +200,7 @@
     loadRole();
 
     // ---- Catalog status + import ------------------------------------------
+    let _embedHistory = [];   // {ts, embedded} samples for ETA estimate
     async function loadCatalogStatus() {
       const r = await fetch('/api/admin/catalog-status' + isAdminQ);
       const j = await r.json();
@@ -192,8 +210,45 @@
         <div><strong>${j.total}</strong> items total · <strong>${j.embedded}</strong> embedded · <strong>${j.unembedded}</strong> pending re-embed</div>
         <div style="margin-top:8px;">${bySource}</div>
       `;
+      // Tick 19: progress bar + ETA. Bar shows when unembedded > 0, fades
+      // when catalog is fully embedded.
+      const bar = $('embed_progress');
+      if (j.unembedded > 0) {
+        bar.hidden = false;
+        bar.classList.remove('fading');
+        const pct = j.total > 0 ? Math.round((j.embedded / j.total) * 100) : 0;
+        $('embed_pct').textContent = pct;
+        $('embed_remaining').textContent = j.unembedded;
+        $('embed_fill').style.width = pct + '%';
+        // ETA via two-point rate estimate from the polling history.
+        _embedHistory.push({ ts: Date.now(), embedded: j.embedded });
+        if (_embedHistory.length > 8) _embedHistory.shift();
+        if (_embedHistory.length >= 2) {
+          const first = _embedHistory[0], last = _embedHistory[_embedHistory.length - 1];
+          const dEmbedded = last.embedded - first.embedded;
+          const dSec = (last.ts - first.ts) / 1000;
+          if (dEmbedded > 0 && dSec > 0) {
+            const rate = dEmbedded / dSec;        // items/sec
+            const etaSec = Math.round(j.unembedded / rate);
+            const etaH = Math.floor(etaSec / 3600);
+            const etaM = Math.round((etaSec % 3600) / 60);
+            $('embed_eta').textContent = `eta ${etaH > 0 ? etaH + 'h ' : ''}${etaM}m at ${(rate * 60).toFixed(1)}/min`;
+          } else {
+            $('embed_eta').textContent = dEmbedded === 0 ? 'no progress — drainer paused?' : '';
+          }
+        }
+      } else {
+        // fully embedded — fade then hide
+        if (!bar.hidden && !bar.classList.contains('fading')) {
+          bar.classList.add('fading');
+          setTimeout(() => { bar.hidden = true; }, 1300);
+        }
+      }
     }
 
+    // 30s poll while the page is open.
+    setInterval(() => loadCatalogStatus().catch(() => {}), 30 * 1000);
+
     async function runImport(body) {
       const s = $('import_status');
       s.style.display = 'block';
diff --git a/public/css/app.css b/public/css/app.css
index 8026868..7e6b277 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -733,6 +733,23 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 }
 .tailor-reset:hover { background: #fff; color: #1d1d1f; }
 
+/* ---- Production credit badge (tick 19) ---- */
+.card { position: relative; }   /* anchor for the absolute-positioned badge */
+.prod-credit-badge {
+  position: absolute;
+  top: 8px; left: 8px;
+  z-index: 2;
+  background: #1d1d1f;
+  color: #f3d76f;
+  padding: 4px 10px;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+  box-shadow: 0 2px 6px rgba(0,0,0,0.25);
+  pointer-events: none;
+}
+
 /* ---- Closet privacy pill (tick 15.8) ---- */
 .meta-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
 .privacy-pill {
diff --git a/public/js/app.js b/public/js/app.js
index 2abc4c3..0a8ea8e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -576,8 +576,12 @@ async function loadCloset() {
     const c = document.createElement('div');
     c.className = 'card';
     const isPrivate = (it.privacy || 'shared') === 'private';
+    // Tick 19: production-credit badge — set-decorator role only, item must have ≥1 credit.
+    const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
+    const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
     c.innerHTML = `
       <img src="/api/closet/photo/${it.id}" alt="">
+      ${badge}
       <div class="meta">
         <div class="title">${it.category || 'analyzing…'}</div>
         <div class="brand">${it.color || ''}</div>
@@ -728,7 +732,9 @@ async function loadRecs() {
     a.target = '_blank';
     a.rel = 'noopener';
     const sustain = it.sustain ? `<span class="sustain" aria-label="Sustainability ${it.sustain} of 5">♻ ${it.sustain}/5</span>` : '';
-    a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy"><div class="meta"><div class="title">${it.title}</div><div class="brand">${it.brand || ''}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
+    const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
+    const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
+    a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy">${badge}<div class="meta"><div class="title">${it.title}</div><div class="brand">${it.brand || ''}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
     g.appendChild(a);
   });
 }
diff --git a/scripts/clothing-apis/brands.json b/scripts/clothing-apis/brands.json
index 164e12e..f112385 100644
--- a/scripts/clothing-apis/brands.json
+++ b/scripts/clothing-apis/brands.json
@@ -4,19 +4,19 @@
 
   "shopify_dtc": [
     { "id": "allbirds",       "name": "Allbirds",        "domain": "allbirds.com",          "sustain_tier": 5, "pro_grade": 1 },
-    { "id": "reformation",    "name": "Reformation",     "domain": "thereformation.com",    "sustain_tier": 5, "pro_grade": 0 },
-    { "id": "pact",           "name": "Pact",            "domain": "wearpact.com",          "sustain_tier": 5, "pro_grade": 1 },
+    { "id": "reformation",    "name": "Reformation",     "domain": "thereformation.com",    "sustain_tier": 5, "pro_grade": 0, "disabled": true, "disabled_reason": "Shopify Plus headless — /products.json returns 404. Try affiliate feed (Awin) instead." },
+    { "id": "pact",           "name": "Pact",            "domain": "wearpact.com",          "sustain_tier": 5, "pro_grade": 1, "disabled": true, "disabled_reason": "Not Shopify — wearpact.com is a custom site, /products.json returns the HTML homepage. Needs a bespoke adapter." },
     { "id": "outerknown",     "name": "Outerknown",      "domain": "outerknown.com",        "sustain_tier": 5, "pro_grade": 1 },
     { "id": "tradlands",      "name": "Tradlands",       "domain": "tradlands.com",         "sustain_tier": 4, "pro_grade": 1 },
     { "id": "whimsyandrow",   "name": "Whimsy + Row",    "domain": "whimsyandrow.com",      "sustain_tier": 4, "pro_grade": 1 },
     { "id": "cuyana",         "name": "Cuyana",          "domain": "cuyana.com",            "sustain_tier": 4, "pro_grade": 1 },
-    { "id": "kotn",           "name": "Kotn",            "domain": "kotn.com",              "sustain_tier": 5, "pro_grade": 1 },
+    { "id": "kotn",           "name": "Kotn",            "domain": "kotn.com",              "sustain_tier": 5, "pro_grade": 1, "disabled": true, "disabled_reason": "Not Shopify — kotn.com is a Next.js + custom backend, /products.json returns SPA HTML. Needs a bespoke adapter." },
     { "id": "matethelabel",   "name": "Mate the Label",  "domain": "matethelabel.com",      "sustain_tier": 5, "pro_grade": 1 },
     { "id": "boody",          "name": "Boody",           "domain": "boody.com",             "sustain_tier": 4, "pro_grade": 1 },
     { "id": "naadam",         "name": "Naadam",          "domain": "naadam.co",             "sustain_tier": 4, "pro_grade": 1 },
     { "id": "christydawn",    "name": "Christy Dawn",    "domain": "christydawn.com",       "sustain_tier": 5, "pro_grade": 0 },
     { "id": "mottandbow",     "name": "Mott & Bow",      "domain": "mottandbow.com",        "sustain_tier": 4, "pro_grade": 1 },
-    { "id": "saintjames",     "name": "Saint James",     "domain": "saintjames.us",         "sustain_tier": 3, "pro_grade": 1 },
+    { "id": "saintjames",     "name": "Saint James",     "domain": "saintjames.us",         "sustain_tier": 3, "pro_grade": 1, "disabled": true, "disabled_reason": "Connection timeout — saintjames.us blocks or rate-limits this network. Retry from a different IP or use the affiliate feed." },
     { "id": "tentree",        "name": "Tentree",         "domain": "tentree.com",           "sustain_tier": 4, "pro_grade": 1 },
     { "id": "mara-hoffman",   "name": "Mara Hoffman",    "domain": "marahoffman.com",       "sustain_tier": 4, "pro_grade": 0 },
     { "id": "knickey",        "name": "Knickey",         "domain": "knickey.com",           "sustain_tier": 4, "pro_grade": 1 },
diff --git a/scripts/clothing-apis/index.js b/scripts/clothing-apis/index.js
index f8f1a10..2bb284e 100644
--- a/scripts/clothing-apis/index.js
+++ b/scripts/clothing-apis/index.js
@@ -55,7 +55,11 @@ async function importAll(db, opts = {}) {
     // serialized). Cap concurrency at 6 so we don't slam the brands all at
     // once (politeness + a memory ceiling on parsed pages).
     const CONCURRENCY = 6;
-    const brands = BRANDS.shopify_dtc.slice(0, Math.min(BRANDS.shopify_dtc.length, limit - n));
+    // Tick 19: respect `disabled:true` in brands.json so the parallel fan-out
+    // doesn't keep retrying brands we've already proven aren't Shopify (or
+    // are unreachable from this network).
+    const eligible = BRANDS.shopify_dtc.filter(b => !b.disabled);
+    const brands = eligible.slice(0, Math.min(eligible.length, limit - n));
     let idx = 0;
     async function worker() {
       while (idx < brands.length) {
@@ -69,6 +73,10 @@ async function importAll(db, opts = {}) {
       }
     }
     await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
+    // Surface the disabled brands so admin sees them.
+    for (const b of BRANDS.shopify_dtc.filter(b => b.disabled)) {
+      results.push({ kind: 'shopify', id: b.id, skipped: b.disabled_reason || 'disabled' });
+    }
     n += brands.length;
   }
   if (!filterKind || filterKind === 'etsy') {
diff --git a/scripts/clothing-apis/shopify.js b/scripts/clothing-apis/shopify.js
index 68ea948..cc60308 100644
--- a/scripts/clothing-apis/shopify.js
+++ b/scripts/clothing-apis/shopify.js
@@ -53,8 +53,18 @@ async function fetchOnePage(domain, page) {
   if (r.status === 404) return { products: [], end: true, status: 404 };
   if (r.status === 429) return { products: [], end: true, status: 429 };
   if (!r.ok) throw new Error(`shopify ${domain} page ${page} → ${r.status}`);
-  const j = await r.json();
-  return { products: j.products || [], end: (j.products || []).length < PAGE_SIZE, status: r.status };
+  // Tick 19: detect non-Shopify sites that return 200 + HTML (Next.js SPAs,
+  // custom backends). Without this, we'd silently treat the homepage HTML as
+  // an empty product list and report "ok: true, fetched: 0".
+  const ct = (r.headers.get('content-type') || '').toLowerCase();
+  if (!ct.includes('json')) {
+    return { products: [], end: true, status: 'not-shopify' };
+  }
+  let j;
+  try { j = await r.json(); }
+  catch { return { products: [], end: true, status: 'invalid-json' }; }
+  if (!Array.isArray(j.products)) return { products: [], end: true, status: 'no-products-array' };
+  return { products: j.products, end: j.products.length < PAGE_SIZE, status: r.status };
 }
 
 async function fetchAllPages(domain) {
diff --git a/server.js b/server.js
index 95203ae..67bd7b7 100644
--- a/server.js
+++ b/server.js
@@ -657,14 +657,21 @@ app.get('/api/recommend', (req, res) => {
   if (proOnly) { where.push('pro_grade = 1'); }
   const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(' AND ')}`).all(...params);
   const sustainBoost = u.sustainability === 'top' ? 0.15 : u.sustainability === 'nice' ? 0.05 : 0;
+  // Tick 19: production credit count per catalog item for the current user.
+  // Aggregates over tryon_jobs where production_id is set + outfit_picks JSON
+  // where this item appeared in a slot. Pre-computed once per request, then
+  // attached to each item via a Map lookup so we don't N+1 the DB.
+  const tryonCounts = new Map();
+  db.prepare(`SELECT item_id, COUNT(DISTINCT production_id) c
+                FROM tryon_jobs WHERE user_id=? AND production_id IS NOT NULL AND item_id IS NOT NULL
+                GROUP BY item_id`).all(u.id).forEach(r => tryonCounts.set(r.item_id, r.c));
   const scored = rows.map(r => {
     const e = JSON.parse(r.embedding);
     let dot = 0;
     for (let i = 0; i < 32; i++) dot += taste[i] * e[i];
     const tier = tierFor(r.brand);
-    // tier 1-5 → normalized [-0.4 .. +0.4]; multiplied by user's sustainBoost weight
     const sustainAdj = tier ? ((tier - 3) / 5) * sustainBoost : 0;
-    return { ...r, score: dot + sustainAdj, sustain: tier };
+    return { ...r, score: dot + sustainAdj, sustain: tier, production_credits: tryonCounts.get(r.id) || 0 };
   }).sort((a, b) => b.score - a.score).slice(0, limit);
   res.json({ items: scored, sustainability_weight: sustainBoost });
 });
@@ -673,7 +680,22 @@ app.get('/api/recommend', (req, res) => {
 app.get('/api/closet', (req, res) => {
   const u = currentUser(req);
   const rows = db.prepare('SELECT * FROM closet WHERE user_id = ? ORDER BY id DESC').all(u.id);
-  res.json({ items: rows });
+  // Tick 19: production credit count per closet item. Closet items can appear
+  // in two places — tryon_jobs.closet_id (rendered onto avatar/photo) and
+  // outfit_picks.outfits[].slots[*] where the slot kind is 'closet'.
+  const tryonCounts = new Map();
+  db.prepare(`SELECT closet_id, COUNT(DISTINCT production_id) c
+                FROM tryon_jobs WHERE user_id=? AND production_id IS NOT NULL AND closet_id IS NOT NULL
+                GROUP BY closet_id`).all(u.id).forEach(r => tryonCounts.set(r.closet_id, r.c));
+  // Also count direct closet.production_id assignments (a closet item that
+  // *belongs* to a production counts as one credit on itself).
+  const closetSelfCount = new Map();
+  rows.forEach(r => { if (r.production_id) closetSelfCount.set(r.id, 1); });
+  const withCounts = rows.map(r => ({
+    ...r,
+    production_credits: (tryonCounts.get(r.id) || 0) + (closetSelfCount.get(r.id) || 0),
+  }));
+  res.json({ items: withCounts });
 });
 
 app.post('/api/closet/photo', upload.single('photo'), (req, res) => {

← 94d3a5a fix(load): route llava drainer to Mac1 Ollama (192.168.1.133  ·  back to Whatsmystyle  ·  yolo tick 20: Mac1 health watcher (skip drain when >9GB VRAM 59b9a4e →