[object Object]

← back to Whatsmystyle

yolo tick 17: active-production bar (set-decorator persistent context) + tryOnItem inherits it + expanded pro_grade seed (+6 brands, fuzzy normalize) + GET /api/outfits/:id rehydrates rationale

63402bbc2edd229c21fd4110be20727500b21dc2 · 2026-05-12 08:56:02 -0700 · SteveStudio2

Files touched

Diff

commit 63402bbc2edd229c21fd4110be20727500b21dc2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 08:56:02 2026 -0700

    yolo tick 17: active-production bar (set-decorator persistent context) + tryOnItem inherits it + expanded pro_grade seed (+6 brands, fuzzy normalize) + GET /api/outfits/:id rehydrates rationale
---
 public/css/app.css | 30 ++++++++++++++++++++++++++++++
 public/index.html  |  9 +++++++++
 public/js/app.js   | 34 ++++++++++++++++++++++++++++------
 server.js          | 50 +++++++++++++++++++++++++++++++++++++++++++-------
 4 files changed, 110 insertions(+), 13 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index fd9181e..8026868 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -545,6 +545,36 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .tag-prod-status { font-size: 13px; }
 .tag-prod a { color: #1d1d1f; }
 
+/* ---- Active production bar (tick 17) — set-decorator persistent context ---- */
+.active-prod-bar {
+  position: sticky; top: 0; z-index: 49;
+  background: #f5edd9;
+  border-bottom: 1px solid #d6c89c;
+  color: #4a3a14;
+  padding: 8px 20px;
+  display: flex; align-items: center; gap: 10px;
+  font-size: 13px;
+}
+.active-prod-bar label {
+  font-weight: 600;
+  color: #6e4f0d;
+}
+.active-prod-bar select {
+  font: inherit;
+  padding: 4px 12px;
+  border: 1px solid #c8b78e;
+  border-radius: 10px;
+  background: #fff;
+  color: #1d1d1f;
+}
+.active-prod-bar .active-prod-edit {
+  margin-left: auto;
+  color: #6e4f0d;
+  text-decoration: none;
+  font-size: 12px;
+}
+.active-prod-bar .active-prod-edit:hover { color: #1d1d1f; }
+
 /* ---- Set-grade toggle (tick 16) — set-decorator catalog filter ---- */
 .set-grade-toggle {
   display: inline-flex;
diff --git a/public/index.html b/public/index.html
index e882dac..38bc162 100644
--- a/public/index.html
+++ b/public/index.html
@@ -10,6 +10,15 @@
 <body>
 <a href="#main" class="skip-link">Skip to main content</a>
 <div id="role-banner" class="role-banner" hidden></div>
+<!-- Active production selector — set-decorator only. Every closet upload + try-on
+     inherits this id. Live across page sessions (localStorage). -->
+<div id="active-prod-bar" class="active-prod-bar" data-role-only="set_decorator" hidden>
+  <label for="active-prod-select">Active production</label>
+  <select id="active-prod-select" name="production_id">
+    <option value="">— untagged —</option>
+  </select>
+  <a href="#" data-screen="productions" class="active-prod-edit">manage →</a>
+</div>
 <header class="topbar" role="banner">
   <button class="logo" aria-label="Home — WhatsMyStyle" onclick="route('home')">WhatsMyStyle<span class="bullet" aria-hidden="true">·</span><em>placeholder</em></button>
   <button class="hamburger" aria-label="Open menu" aria-expanded="false" aria-controls="menu" onclick="toggleMenu()">
diff --git a/public/js/app.js b/public/js/app.js
index fc010ec..2abc4c3 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -127,9 +127,10 @@ const Role = {
 };
 window.Role = Role;
 
-// Populate every <select> that's a production picker (closet upload, future
-// try-on submit) with the current user's productions. Idempotent — wipes &
-// re-fills.
+// Populate every <select> that's a production picker (closet upload, try-on
+// submit, header active-production bar). Idempotent — wipes & re-fills.
+// Reads / writes localStorage 'wms.activeProduction' so the choice persists
+// across pages and is auto-applied to all writes.
 async function populateProductionSelects() {
   const selects = $$('select[name="production_id"]');
   if (!selects.length) return;
@@ -143,13 +144,30 @@ async function populateProductionSelects() {
       ).join('');
     }
   } catch {}
+  const active = localStorage.getItem('wms.activeProduction') || '';
   selects.forEach(s => {
-    const cur = s.value;
     s.innerHTML = opts;
-    if (cur) s.value = cur;
+    s.value = active;
   });
 }
 
+// Active-production helper — single source of truth for tryOnItem + any form
+// that wants the current set-decorator project context.
+function getActiveProductionId() {
+  return localStorage.getItem('wms.activeProduction') || '';
+}
+
+// The header bar select drives the global. Other selects mirror via the
+// `wms.activeProduction` localStorage value on populate().
+document.addEventListener('change', e => {
+  if (e.target.id !== 'active-prod-select') return;
+  const v = e.target.value || '';
+  if (v) localStorage.setItem('wms.activeProduction', v);
+  else localStorage.removeItem('wms.activeProduction');
+  // Mirror to any other production-picker selects on the page.
+  $$('select[name="production_id"]').forEach(s => { if (s !== e.target) s.value = v; });
+});
+
 // ---- Set-grade only toggle (tick 16) — set-decorator role ----
 $('#set-grade-only')?.addEventListener('change', e => {
   localStorage.setItem('wms.proOnly', e.target.checked ? '1' : '0');
@@ -976,13 +994,17 @@ async function pollAvatar() {
 }
 
 // ---------- Try-on (from duel / recs / closet) ----------------------------
-async function tryOnItem({ item_id, closet_id, mode = 'avatar', occasion, source_photo_file }) {
+async function tryOnItem({ item_id, closet_id, mode = 'avatar', occasion, source_photo_file, production_id }) {
   const fd = new FormData();
   if (item_id) fd.append('item_id', item_id);
   if (closet_id) fd.append('closet_id', closet_id);
   fd.append('mode', mode);
   if (occasion) fd.append('occasion', occasion);
   if (source_photo_file) fd.append('source_photo', source_photo_file);
+  // Auto-inherit the active production for set-decorator context unless the
+  // caller passed one explicitly.
+  const pid = production_id || getActiveProductionId();
+  if (pid) fd.append('production_id', pid);
   const r = await fetch('/api/tryon', { method: 'POST', body: fd }).then(r => r.json());
   if (r.error) { alert(r.error + (r.hint ? ' — ' + r.hint : '')); return null; }
   return r.job_id;
diff --git a/server.js b/server.js
index a53fd26..35c52fa 100644
--- a/server.js
+++ b/server.js
@@ -274,26 +274,40 @@ try {
   // Idempotent seed pass — tag items as pro_grade where brand is a known
   // multi-stock retailer (mass + DTC contemporary). Resale-only brands stay
   // untagged (single-piece inventory). Run once-per-boot; quick.
+  // PRO_BRANDS — multi-stocked, restockable, set-decorator-friendly. Tick 17:
+  // broadened to cover seed-catalog brands the original list missed
+  // (Reformation, Saint Laurent line-extension basics, Burberry trench, etc.)
+  // and added a normalize() pass so 'L.L. Bean' matches 'L.L.Bean'.
   const PRO_BRANDS = new Set([
     'Everlane', 'COS', 'Uniqlo', 'Madewell', 'J.Crew', 'Banana Republic',
-    'Theory', 'AGOLDE', 'Mother', 'Frame', 'Levi\'s', "Levi's",
+    'Theory', 'AGOLDE', 'Mother', 'Frame', "Levi's",
     'Vince', 'rag & bone', 'Marine Layer', 'L.L.Bean', 'Carhartt',
     'Citizens of Humanity', 'Saint James', 'Tradlands', 'Mott & Bow',
     'Patagonia', 'Allbirds', 'Veja', 'Naadam', 'Boody', 'Pact',
     'Aritzia', 'Anthropologie', 'Free People', 'Express', 'Gap', 'Old Navy',
     'Nike', 'Adidas',
+    // tick 17 additions — restockable basics that landed in seed catalog
+    'Reformation', 'Outerknown', 'Cuyana', 'Kotn', 'Mate the Label',
+    'Christy Dawn', 'Boyish', 'Boyish Jeans', 'For Days', 'Knickey',
+    'Sezane', 'Mara Hoffman', 'Frank And Oak', 'DL1961', 'Toteme',
+    'Burberry',          // classic trench coats are multi-stocked
+    'G.H. Bass',         // classic loafers — Weejuns are stock
+    'L.L. Bean',         // alias for L.L.Bean
+    'Common Projects',   // staple white sneaker
   ]);
-  const RESALE_SOURCES = new Set(['realreal', 'poshmark', 'vestiaire', 'grailed']);
+  const RESALE_SOURCES = new Set(['realreal', 'poshmark', 'vestiaire', 'grailed', 'depop']);
+  // Normalize for fuzzy matching: lowercase, collapse whitespace, drop dots.
+  const norm = s => (s || '').toLowerCase().replace(/[.\s]+/g, '').trim();
+  const PRO_BRANDS_NORM = new Set(Array.from(PRO_BRANDS).map(norm));
   const all = db.prepare("SELECT id, source, brand, pro_grade FROM items").all();
   const upd = db.prepare('UPDATE items SET pro_grade = ? WHERE id = ?');
   let tagged = 0;
   for (const r of all) {
-    if (r.pro_grade !== null) continue;  // respect any prior manual override
+    if (r.pro_grade !== null && r.pro_grade !== 0) continue;  // keep manual 1, re-run 0/null
     const isResale = RESALE_SOURCES.has((r.source || '').toLowerCase());
-    const isProBrand = PRO_BRANDS.has((r.brand || '').trim());
-    const grade = isResale ? 0 : (isProBrand ? 1 : 0);
-    upd.run(grade, r.id);
-    tagged++;
+    const isProBrand = PRO_BRANDS_NORM.has(norm(r.brand));
+    const grade = isResale ? 0 : (isProBrand ? 1 : (r.pro_grade ?? 0));
+    if (grade !== r.pro_grade) { upd.run(grade, r.id); tagged++; }
   }
   if (tagged > 0) console.log(`[pro_grade seed] tagged ${tagged} items`);
 } catch (e) { console.error('[pro_grade seed] error', e.message); }
@@ -1212,6 +1226,28 @@ app.post('/api/outfits', (req, res) => {
   res.json({ ok: true, pick_id: lastInsertRowid, outfits });
 });
 
+// Tick 17: GET a stored pick + rehydrate the rationale text per outfit.
+// We don't persist `rationale` on outfit_picks (deterministic template, cheap
+// to recompute), so re-run rationaleFor() before returning.
+app.get('/api/outfits/:id', (req, res) => {
+  const u = currentUser(req);
+  const row = db.prepare('SELECT * FROM outfit_picks WHERE id=? AND user_id=?').get(req.params.id, u.id);
+  if (!row) return res.status(404).json({ error: 'pick not found' });
+  let outfits;
+  try { outfits = JSON.parse(row.outfits || '[]'); }
+  catch { outfits = []; }
+  for (const o of outfits) o.rationale = rationaleFor(o);
+  res.json({
+    pick_id: row.id,
+    anchor_kind: row.anchor_kind,
+    anchor_id: row.anchor_id,
+    picked_index: row.picked_index,
+    created_at: row.created_at,
+    picked_at: row.picked_at,
+    outfits,
+  });
+});
+
 app.post('/api/outfits/:id/pick', (req, res) => {
   const u = currentUser(req);
   const idx = Number(req.body?.index);

← 202347c feat(catalog-apis): adapter framework for Shopify/Etsy/eBay  ·  back to Whatsmystyle  ·  yolo tick 18: embed-items --batch + boot-time drainer (5min/ b0fcb62 →