← 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
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/css/app.cssM public/index.htmlM public/js/app.jsA public/share-recs.htmlM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →