← 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
M public/admin-config.htmlM public/css/app.cssM public/js/app.jsM scripts/clothing-apis/brands.jsonM scripts/clothing-apis/index.jsM scripts/clothing-apis/shopify.jsM server.js
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 →