[object Object]

← back to Whatsmystyle

yolo tick 31: /recs empty-state panel (clear-filters button wipes facets/proOnly/brand) + shareable recs snapshot (POST /api/share/recs + GET /share/recs/:id — stores filter+sort state, replays against owner's CURRENT taste vector so recipient sees fresh stock; role-gated to admin+set_decorator) + brands→main brand-param routing (LOWER(brand)=LOWER(?) server filter, hydrate localStorage from ?brand= on boot, clearable in-page pill)

91dc0d466c891318f82a944d78369acce2f963bc · 2026-05-12 16:54:45 -0700 · SteveStudio2

Files touched

Diff

commit 91dc0d466c891318f82a944d78369acce2f963bc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:54:45 2026 -0700

    yolo tick 31: /recs empty-state panel (clear-filters button wipes facets/proOnly/brand) + shareable recs snapshot (POST /api/share/recs + GET /share/recs/:id — stores filter+sort state, replays against owner's CURRENT taste vector so recipient sees fresh stock; role-gated to admin+set_decorator) + brands→main brand-param routing (LOWER(brand)=LOWER(?) server filter, hydrate localStorage from ?brand= on boot, clearable in-page pill)
---
 data/waitlist.csv        |   2 +
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 4610312 -> 4610312 bytes
 public/css/app.css       |   5 +++
 public/index.html        |  11 ++++++
 public/js/app.js         |  84 ++++++++++++++++++++++++++++++++++++++-
 public/share-recs.html   |  65 ++++++++++++++++++++++++++++++
 server.js                | 100 +++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 266 insertions(+), 1 deletion(-)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 900e57b..fe5d598 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -67,3 +67,5 @@ e2e-1778627519615@example.com,e2e,2026-05-12T23:11:59.620Z
 e2e-1778627684022@example.com,e2e,2026-05-12T23:14:44.022Z
 e2e-1778628530526@example.com,e2e,2026-05-12T23:28:50.526Z
 e2e-1778628742953@example.com,e2e,2026-05-12T23:32:22.953Z
+e2e-1778629716355@example.com,e2e,2026-05-12T23:48:36.377Z
+e2e-1778630034615@example.com,e2e,2026-05-12T23:53:54.615Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index b387201..dfb6320 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index d4a91ed..771dad8 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index c6c5730..6bc96d2 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -162,6 +162,11 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 }
 .bulk-progress .bulk-progress-close:hover { color: #1d1d1f; background: rgba(0,0,0,.05); }
 .bulk-progress.is-dismissed { opacity: 0; transition: opacity .2s ease; pointer-events: none; }
+.recs-empty {
+  text-align: center; padding: 40px 24px; margin: 24px 0;
+  background: var(--paper-2); border-radius: 16px; border: 1px dashed var(--line);
+}
+.recs-empty p { margin: 0 0 16px; }
 @media (hover: none) and (pointer: coarse) {
   .kb-hint { display: none !important; }
 }
diff --git a/public/index.html b/public/index.html
index e1e3071..21a43e4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -144,6 +144,17 @@
       </div>
       <!-- Tick 23: color + material facet pills. Tap to toggle. Persisted to localStorage. -->
       <div id="facets-row" class="facets-row" hidden></div>
+      <!-- Tick 31: brand filter pill (set by /brands → ?brand= deep link). -->
+      <div id="brand-filter-row" class="facets-row" hidden></div>
+      <!-- Tick 31: share-this-list — set-decorator + admin only. -->
+      <div id="recs-share-row" data-role-only="set_decorator" hidden style="margin-top: 10px;">
+        <button type="button" class="ghost" id="recs-share-btn">Share this list →</button>
+        <span id="recs-share-out" class="muted" style="margin-left: 10px;"></span>
+      </div>
+    </div>
+    <div id="recs-empty" class="recs-empty" hidden role="status">
+      <p class="muted" id="recs-empty-msg">No matches with your current filters.</p>
+      <button type="button" class="ghost" id="recs-empty-clear">Clear filters</button>
     </div>
 
     <!-- Tailor the view — set-decorator only (tick 16). Sliders re-rank
diff --git a/public/js/app.js b/public/js/app.js
index 58385c2..12686a8 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1085,7 +1085,21 @@ async function loadRecs() {
   const SERVER_SORTS = new Set(['price_asc', 'price_desc', 'sustain', 'newest']);
   const serverSort = SERVER_SORTS.has(sort) ? sort : 'best';
   const sortQS = `&sort=${encodeURIComponent(serverSort)}`;
-  const r = await fetch(`/api/recommend?limit=60${proQS}${colorQS}${matQS}${sortQS}`).then(r => r.json());
+  // Tick 31: brand filter from localStorage (set by /brands → ?brand= deep link).
+  const brandFilter = (localStorage.getItem('wms.brand_filter') || '').trim();
+  const brandQS = brandFilter ? `&brand=${encodeURIComponent(brandFilter)}` : '';
+  // Render brand-filter pill (clearable).
+  const bfr = $('#brand-filter-row');
+  if (bfr) {
+    if (brandFilter) {
+      bfr.hidden = false;
+      bfr.innerHTML = `<button type="button" class="facet-pill is-active" id="clear-brand-filter">Brand: ${escapeHtml(brandFilter)} <span style="margin-left: 6px;">×</span></button>`;
+    } else {
+      bfr.hidden = true;
+      bfr.innerHTML = '';
+    }
+  }
+  const r = await fetch(`/api/recommend?limit=60${proQS}${colorQS}${matQS}${sortQS}${brandQS}`).then(r => r.json());
   // Render the facet pill row.
   const facetsRow = $('#facets-row');
   if (facetsRow && r.facets) {
@@ -1117,6 +1131,18 @@ async function loadRecs() {
   const g = $('#recs-grid');
   g.style.setProperty('--card-min', minPx + 'px');
   g.innerHTML = '';
+  // Tick 31: empty-state — if no items, show a friendly message + clear-filters button.
+  const empty = $('#recs-empty');
+  const anyFilter = activeColors.length || activeMaterials.length || proOnlyChecked || brandFilter;
+  if (!items.length && empty) {
+    empty.hidden = false;
+    $('#recs-empty-msg').textContent = anyFilter
+      ? 'No matches with your current filters.'
+      : 'No catalog yet — your duels are seeding it.';
+    $('#recs-empty-clear').hidden = !anyFilter;
+  } else if (empty) {
+    empty.hidden = true;
+  }
   items.forEach(it => {
     const a = document.createElement('a');
     a.className = 'card';
@@ -1136,6 +1162,62 @@ async function loadRecs() {
 $('#sort').addEventListener('change', e => { localStorage.setItem('wms.sort', e.target.value); loadRecs(); });
 $('#density').addEventListener('input', e => { localStorage.setItem('wms.density', e.target.value); loadRecs(); });
 
+// Tick 31: brand filter on boot — read ?brand= and stash; clear-brand-filter pill resets.
+(function hydrateBrandFilterFromURL() {
+  try {
+    const params = new URLSearchParams(location.search);
+    const b = (params.get('brand') || '').trim();
+    if (b) {
+      localStorage.setItem('wms.brand_filter', b);
+      // Strip the param so reloads don't keep re-applying it after manual clear.
+      params.delete('brand');
+      const qs = params.toString();
+      history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
+    }
+  } catch {}
+})();
+
+document.addEventListener('click', e => {
+  if (e.target.closest('#clear-brand-filter')) {
+    localStorage.removeItem('wms.brand_filter');
+    loadRecs();
+  }
+  if (e.target.closest('#recs-empty-clear')) {
+    localStorage.removeItem('wms.facets.colors');
+    localStorage.removeItem('wms.facets.materials');
+    localStorage.removeItem('wms.proOnly');
+    localStorage.removeItem('wms.brand_filter');
+    loadRecs();
+  }
+});
+
+// Tick 31: share-this-list — POST current state, copy URL to clipboard.
+$('#recs-share-btn')?.addEventListener('click', async () => {
+  const out = $('#recs-share-out');
+  out.textContent = 'Generating…';
+  const state = {
+    sort: localStorage.getItem('wms.sort') || 'best',
+    colors: JSON.parse(localStorage.getItem('wms.facets.colors') || '[]'),
+    materials: JSON.parse(localStorage.getItem('wms.facets.materials') || '[]'),
+    proOnly: localStorage.getItem('wms.proOnly') === '1',
+    brand: (localStorage.getItem('wms.brand_filter') || '').trim(),
+  };
+  const SERVER_SORTS = new Set(['price_asc', 'price_desc', 'sustain', 'newest']);
+  if (!SERVER_SORTS.has(state.sort)) state.sort = 'best';
+  try {
+    const r = await fetch('/api/share/recs', {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify(state),
+    });
+    const j = await r.json();
+    if (!r.ok) { out.textContent = j.error || 'failed'; return; }
+    const full = `${location.origin}${j.url}`;
+    try { await navigator.clipboard.writeText(full); out.innerHTML = `Copied — <a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`; }
+    catch { out.innerHTML = `<a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`; }
+  } catch (e) { out.textContent = 'failed'; }
+});
+
 // Tailor sliders — live update output + localStorage + reflow grid.
 ['era', 'palette', 'density', 'tone'].forEach(k => {
   const inp = $(`#tailor-${k}`);
diff --git a/public/share-recs.html b/public/share-recs.html
new file mode 100644
index 0000000..93f49e3
--- /dev/null
+++ b/public/share-recs.html
@@ -0,0 +1,65 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <meta name="robots" content="noindex,nofollow" />
+  <title>Shared list — WhatsMyStyle</title>
+  <link rel="stylesheet" href="/css/app.css" />
+  <style>
+    body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 1200px; margin: 40px auto; padding: 0 24px; }
+    h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 6px; }
+    .sub { color: #707070; margin-bottom: 16px; }
+    .state-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 22px; }
+    .state-pills .pill {
+      font-size: 12px; padding: 4px 10px; border-radius: 999px;
+      background: #fff; border: 1px solid #e6e1d8; color: #1d1d1f;
+    }
+    .cta { margin-top: 24px; padding-top: 18px; border-top: 1px solid #e6e1d8; font-size: 14px; }
+    .cta a { color: #1d1d1f; }
+    .empty { color: #999; padding: 24px 0; text-align: center; }
+  </style>
+</head>
+<body>
+  <h1 id="title">Shared list</h1>
+  <p class="sub" id="sub">Loading…</p>
+  <div id="state-pills" class="state-pills" hidden></div>
+  <div id="grid" class="grid"></div>
+  <p class="cta">Want a list like this? <a href="/">Build yours on WhatsMyStyle →</a></p>
+
+  <script>
+    const $ = s => document.querySelector(s);
+    function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+    (async function () {
+      const shareId = location.pathname.split('/').pop();
+      const r = await fetch(`/api/share/recs/${encodeURIComponent(shareId)}`);
+      if (!r.ok) { $('#sub').textContent = 'This share link is no longer active.'; return; }
+      const j = await r.json();
+      $('#title').textContent = `${j.owner}'s picks`;
+      const sortLabel = { best: 'Best match', sustain: 'Sustainability', newest: 'Newest', price_asc: 'Price ↑', price_desc: 'Price ↓' }[j.state?.sort || 'best'] || 'Best match';
+      $('#sub').textContent = `${j.count} live piece${j.count === 1 ? '' : 's'} matching this lens — re-evaluated against today's catalog.`;
+      const pills = [];
+      pills.push(`<span class="pill">Sort: ${escapeHtml(sortLabel)}</span>`);
+      if (j.state?.brand)     pills.push(`<span class="pill">Brand: ${escapeHtml(j.state.brand)}</span>`);
+      if (j.state?.category)  pills.push(`<span class="pill">Category: ${escapeHtml(j.state.category)}</span>`);
+      if (j.state?.proOnly)   pills.push(`<span class="pill">Set-grade only</span>`);
+      (j.state?.colors || []).forEach(c => pills.push(`<span class="pill">Color: ${escapeHtml(c)}</span>`));
+      (j.state?.materials || []).forEach(m => pills.push(`<span class="pill">Material: ${escapeHtml(m)}</span>`));
+      const sp = $('#state-pills');
+      sp.innerHTML = pills.join('');
+      sp.hidden = false;
+      if (!j.items.length) { $('#grid').innerHTML = '<div class="empty">No live matches against today\'s catalog.</div>'; return; }
+      $('#grid').innerHTML = j.items.map(it => `
+        <a class="card" href="${escapeHtml(it.product_url || '#')}" target="_blank" rel="noopener">
+          <img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
+          <div class="meta">
+            <div class="title">${escapeHtml(it.title || '')}</div>
+            <div class="brand">${escapeHtml(it.brand || '')}${it.sustain ? ` <span class="sustain">♻ ${it.sustain}/5</span>` : ''}</div>
+            ${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
+          </div>
+        </a>
+      `).join('');
+    })();
+  </script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 0e74874..56af680 100644
--- a/server.js
+++ b/server.js
@@ -262,6 +262,19 @@ CREATE INDEX IF NOT EXISTS favorites_user_idx ON favorites(user_id, created_at D
 // Tick 25: per-user share_id for public favorites list. Stored on users.
 safeAddColumn('users', 'favorites_share_id TEXT');
 
+// Tick 31: shareable recs snapshots. Stores the OWNER's filter+sort state, not
+// the items themselves — public viewers see fresh stock re-evaluated against
+// the owner's current taste vector at view time.
+db.exec(`
+CREATE TABLE IF NOT EXISTS recs_shares (
+  share_id TEXT PRIMARY KEY,
+  user_id INTEGER NOT NULL,
+  state_json TEXT NOT NULL,
+  created_at TEXT DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS recs_shares_user_idx ON recs_shares(user_id, created_at DESC);
+`);
+
 // Tick 15.5: productions — film/TV/commercial projects a set_decorator user
 // is buying for. Lets set-decorator-mode anchor closet + try-on rows to a
 // specific show + episode, mirroring shotonwhat.com's credit structure.
@@ -704,6 +717,9 @@ app.get('/api/recommend', (req, res) => {
   const params = [];
   if (cat) { where.push('category = ?'); params.push(cat); }
   if (proOnly) { where.push('pro_grade = 1'); }
+  // Tick 31: brand filter — case-insensitive exact match. Drives the /brands → main app deep link.
+  const brandFilter = (req.query.brand || '').trim();
+  if (brandFilter) { where.push('LOWER(brand) = LOWER(?)'); params.push(brandFilter); }
   // Tick 23: color + material facets. Comma-separated query strings; case-
   // insensitive LIKE-match on the seed column values so 'red' matches 'red',
   // 'Red', or 'true red'. Empty filter list = no filter.
@@ -910,6 +926,90 @@ app.get('/share/favorites/:share_id', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'share-favorites.html'));
 });
 
+// Tick 31: shareable recs snapshots. Stores the filter+sort state; resolves
+// against the OWNER's current taste vector at view time so the recipient sees
+// FRESH stock against the same filters (not a frozen item list).
+app.post('/api/share/recs', (req, res) => {
+  const u = currentUser(req);
+  // Role gate: set-decorator + admin only.
+  const role = u.role || 'public';
+  if (role !== 'admin' && role !== 'set_decorator') {
+    return res.status(403).json({ error: 'role not allowed' });
+  }
+  const b = req.body || {};
+  const state = {
+    sort: typeof b.sort === 'string' ? b.sort.slice(0, 32) : 'best',
+    colors: Array.isArray(b.colors) ? b.colors.slice(0, 12).map(String) : [],
+    materials: Array.isArray(b.materials) ? b.materials.slice(0, 12).map(String) : [],
+    proOnly: !!b.proOnly,
+    brand: typeof b.brand === 'string' ? b.brand.slice(0, 64) : '',
+    category: typeof b.category === 'string' ? b.category.slice(0, 32) : '',
+  };
+  const share_id = require('crypto').randomBytes(12).toString('hex');
+  db.prepare('INSERT INTO recs_shares (share_id, user_id, state_json) VALUES (?, ?, ?)').run(
+    share_id, u.id, JSON.stringify(state)
+  );
+  res.json({ ok: true, share_id, url: `/share/recs/${share_id}` });
+});
+
+app.get('/api/share/recs/:share_id', (req, res) => {
+  const row = db.prepare('SELECT user_id, state_json, created_at FROM recs_shares WHERE share_id = ?').get(req.params.share_id);
+  if (!row) return res.status(404).json({ error: 'share link not found' });
+  const owner = db.prepare('SELECT id, display_name, taste_vec, sustainability FROM users WHERE id = ?').get(row.user_id);
+  if (!owner) return res.status(404).json({ error: 'owner missing' });
+  const state = JSON.parse(row.state_json);
+  // Replay against the OWNER's taste vector. Same scoring as /api/recommend
+  // (just inlined here so we don't have to spoof the auth layer).
+  let taste = JSON.parse(owner.taste_vec || '[]');
+  if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
+  const where = ['embedding IS NOT NULL', "image_url IS NOT NULL AND image_url != ''"];
+  const params = [];
+  if (state.category) { where.push('category = ?'); params.push(state.category); }
+  if (state.proOnly)  { where.push('pro_grade = 1'); }
+  if (state.brand)    { where.push('LOWER(brand) = LOWER(?)'); params.push(state.brand); }
+  const colors = (state.colors || []).map(s => String(s).toLowerCase()).filter(Boolean);
+  const materials = (state.materials || []).map(s => String(s).toLowerCase()).filter(Boolean);
+  if (colors.length) {
+    where.push(`(${colors.map(() => 'LOWER(color) LIKE ?').join(' OR ')})`);
+    params.push(...colors.map(c => `%${c}%`));
+  }
+  if (materials.length) {
+    where.push(`(${materials.map(() => 'LOWER(material) LIKE ?').join(' OR ')})`);
+    params.push(...materials.map(m => `%${m}%`));
+  }
+  const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(' AND ')}`).all(...params);
+  let tierFor; try { ({ tierFor } = require('./scripts/sustainability')); } catch { tierFor = () => null; }
+  const sustainBoost = owner.sustainability === 'top' ? 0.15 : owner.sustainability === 'nice' ? 0.05 : 0;
+  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);
+    const sustainAdj = tier ? ((tier - 3) / 5) * sustainBoost : 0;
+    return { ...r, score: dot + sustainAdj, sustain: tier };
+  });
+  let ordered = scored;
+  const s = state.sort || 'best';
+  if (s === 'sustain')         ordered = scored.slice().sort((a, b) => (b.sustain || 0) - (a.sustain || 0) || b.score - a.score);
+  else if (s === 'price_asc')  ordered = scored.slice().sort((a, b) => (a.price_cents ?? Number.POSITIVE_INFINITY) - (b.price_cents ?? Number.POSITIVE_INFINITY));
+  else if (s === 'price_desc') ordered = scored.slice().sort((a, b) => (b.price_cents ?? -1) - (a.price_cents ?? -1));
+  else if (s === 'newest')     ordered = scored.slice().sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
+  else                          ordered = scored.slice().sort((a, b) => b.score - a.score);
+  const items = ordered.slice(0, 60).map(it => ({
+    id: it.id, title: it.title, brand: it.brand, category: it.category,
+    color: it.color, price_cents: it.price_cents, image_url: it.image_url,
+    product_url: it.product_url, sustain: it.sustain,
+  }));
+  res.json({
+    owner: owner.display_name || 'a WhatsMyStyle user',
+    state, count: items.length, items, created_at: row.created_at,
+  });
+});
+
+app.get('/share/recs/:share_id', (req, res) => {
+  res.sendFile(path.join(__dirname, 'public', 'share-recs.html'));
+});
+
 // ---- closet ---------------------------------------------------------------
 app.get('/api/closet', (req, res) => {
   const u = currentUser(req);

← 69af117 crawl-catalog: log session-minutes after Browserbase session  ·  back to Whatsmystyle  ·  yolo tick 32: copy-URL button on /share/recs/:id page (clipb 24e6d95 →