[object Object]

← back to Whatsmystyle

yolo tick 30: recs grid server-side sort (best/sustain/newest/price_asc/price_desc — ORDER BY runs across full match pool before limit slice) + Sustainability ↓ + Newest added to recs sort dropdown + brands page search box (live-filter by brand name, ephemeral state, sr-only label)

651582437a5f25b849ae0dd82cbb70e540df9491 · 2026-05-12 16:32:36 -0700 · SteveStudio2

Files touched

Diff

commit 651582437a5f25b849ae0dd82cbb70e540df9491
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:32:36 2026 -0700

    yolo tick 30: recs grid server-side sort (best/sustain/newest/price_asc/price_desc — ORDER BY runs across full match pool before limit slice) + Sustainability ↓ + Newest added to recs sort dropdown + brands page search box (live-filter by brand name, ephemeral state, sr-only label)
---
 data/waitlist.csv        |   2 ++
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 4610312 -> 4610312 bytes
 data/wms.sqlite          |   0
 public/brands.html       |  15 +++++++++++++--
 public/index.html        |   2 ++
 public/js/app.js         |  13 +++++++++----
 server.js                |  16 ++++++++++++++--
 8 files changed, 40 insertions(+), 8 deletions(-)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 149d0be..900e57b 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -65,3 +65,5 @@ e2e-1778625279476@example.com,e2e,2026-05-12T22:34:39.477Z
 e2e-1778625542846@example.com,e2e,2026-05-12T22:39:02.846Z
 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
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index b8ac341..b387201 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 3a67447..d4a91ed 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/data/wms.sqlite b/data/wms.sqlite
new file mode 100644
index 0000000..e69de29
diff --git a/public/brands.html b/public/brands.html
index 58fbb9d..b49afcd 100644
--- a/public/brands.html
+++ b/public/brands.html
@@ -56,6 +56,7 @@
     .brand-card .tier-5 { background: #d4f4d4; color: #1d5e1d; }
     .brand-card .tier-1 { background: #fbe4e4; color: #7a1717; }
     .empty { color: #999; font-size: 14px; padding: 24px 0; text-align: center; }
+    .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
   </style>
 </head>
 <body>
@@ -64,6 +65,11 @@
   <p class="sub" id="sub">Every brand in our catalog.</p>
 
   <div class="controls" id="controls" hidden>
+    <label style="flex: 1; min-width: 180px;">
+      <span class="sr-only">Filter brands</span>
+      <input id="search" type="search" placeholder="Filter brands…" autocomplete="off" spellcheck="false"
+             style="font-family: inherit; font-size: 13px; padding: 6px 12px; border: 1px solid #c8c1b3; border-radius: 8px; background: #faf7f2; color: #1d1d1f; width: 100%;" />
+    </label>
     <label>Sort
       <select id="sort">
         <option value="pieces">Most pieces</option>
@@ -88,6 +94,7 @@
     const SORT_KEY   = 'wms.brands.sort';
     const DENSITY_KEY= 'wms.brands.density';
     let ALL = [];
+    let SEARCH = '';
 
     function currentFilter()  { return localStorage.getItem(FILTER_KEY) || 'all'; }
     function currentSort()    { return localStorage.getItem(SORT_KEY) || 'pieces'; }
@@ -142,9 +149,12 @@
       `).join('');
 
       $('#sort').value = s;
-      const filtered = sortList(ALL.filter(b => matches(b, f)), s);
+      const q = SEARCH.trim().toLowerCase();
+      const searched = q ? ALL.filter(b => b.brand.toLowerCase().includes(q)) : ALL;
+      const filtered = sortList(searched.filter(b => matches(b, f)), s);
       const sortLabel = { pieces: 'most pieces', az: 'A→Z', sustain: 'sustainability' }[s];
-      $('#sub').textContent = `${filtered.length} of ${ALL.length} brand${ALL.length === 1 ? '' : 's'} · sorted by ${sortLabel}.`;
+      const note = q ? ` · matching “${q}”` : '';
+      $('#sub').textContent = `${filtered.length} of ${ALL.length} brand${ALL.length === 1 ? '' : 's'} · sorted by ${sortLabel}${note}.`;
 
       if (!filtered.length) {
         $('#grid').innerHTML = '<div class="empty">No brands match this filter.</div>';
@@ -180,6 +190,7 @@
       $('#sort').value = currentSort();
       $('#sort').addEventListener('change', () => setSort($('#sort').value));
       $('#density').addEventListener('input', () => setDensity(Number($('#density').value)));
+      $('#search').addEventListener('input', () => { SEARCH = $('#search').value; render(); });
       render();
     })();
   </script>
diff --git a/public/index.html b/public/index.html
index 8a1748b..e1e3071 100644
--- a/public/index.html
+++ b/public/index.html
@@ -126,6 +126,8 @@
         <label>Sort
           <select id="sort">
             <option value="taste">Best Match</option>
+            <option value="sustain">Sustainability ↓</option>
+            <option value="newest">Newest</option>
             <option value="price_asc">Price ↑</option>
             <option value="price_desc">Price ↓</option>
             <option value="brand">Brand A–Z</option>
diff --git a/public/js/app.js b/public/js/app.js
index b3cdf3d..58385c2 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1080,7 +1080,12 @@ async function loadRecs() {
   const activeMaterials = JSON.parse(localStorage.getItem('wms.facets.materials') || '[]');
   const colorQS = activeColors.length ? `&colors=${encodeURIComponent(activeColors.join(','))}` : '';
   const matQS   = activeMaterials.length ? `&materials=${encodeURIComponent(activeMaterials.join(','))}` : '';
-  const r = await fetch(`/api/recommend?limit=60${proQS}${colorQS}${matQS}`).then(r => r.json());
+  // Tick 30: server-side sort for price/sustain/newest. Client-side sort kept for
+  // brand/title (cheap string sort on the 60-item slice) and 'taste' (default).
+  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());
   // Render the facet pill row.
   const facetsRow = $('#facets-row');
   if (facetsRow && r.facets) {
@@ -1096,9 +1101,9 @@ async function loadRecs() {
     facetsRow.hidden = !html;
   }
   let items = r.items || [];
-  if (sort === 'price_asc') items.sort((a, b) => (a.price_cents || 0) - (b.price_cents || 0));
-  else if (sort === 'price_desc') items.sort((a, b) => (b.price_cents || 0) - (a.price_cents || 0));
-  else if (sort === 'brand') items.sort((a, b) => (a.brand || '').localeCompare(b.brand || ''));
+  // Tick 30: price_asc/price_desc/sustain/newest are now server-sorted; respect that order.
+  // Keep brand/title as client-side string sort (server doesn't expose those).
+  if (sort === 'brand') items.sort((a, b) => (a.brand || '').localeCompare(b.brand || ''));
   else if (sort === 'title') items.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
   // For set decorators on Best Match, apply Tailor re-rank on top of taste order.
   if (isSetDec && sort === 'taste') {
diff --git a/server.js b/server.js
index 58f884a..0e74874 100644
--- a/server.js
+++ b/server.js
@@ -727,14 +727,26 @@ app.get('/api/recommend', (req, res) => {
   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 allScored = 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, production_credits: tryonCounts.get(r.id) || 0 };
-  }).sort((a, b) => b.score - a.score).slice(0, limit);
+  });
+  // Tick 30: server-side sort. Default "best" = cosine score (existing behavior).
+  // Other modes operate on the full scored pool BEFORE the limit slice so the
+  // user sees true Newest / Price ↑↓ / Sustain rankings across the whole match
+  // set, not just the top-N-by-cosine.
+  const sort = String(req.query.sort || 'best').toLowerCase();
+  let ordered = allScored;
+  if (sort === 'sustain')      ordered = allScored.slice().sort((a, b) => (b.sustain || 0) - (a.sustain || 0) || b.score - a.score);
+  else if (sort === 'price_asc')  ordered = allScored.slice().sort((a, b) => (a.price_cents ?? Number.POSITIVE_INFINITY) - (b.price_cents ?? Number.POSITIVE_INFINITY));
+  else if (sort === 'price_desc') ordered = allScored.slice().sort((a, b) => (b.price_cents ?? -1) - (a.price_cents ?? -1));
+  else if (sort === 'newest')  ordered = allScored.slice().sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
+  else                          ordered = allScored.slice().sort((a, b) => b.score - a.score); // 'best' default
+  const scored = ordered.slice(0, limit);
   // Tick 23: facet counts over the SCORED slice (what user sees), top 6 colors + top 4 materials.
   function topFacets(field, n) {
     const counts = new Map();

← 3c7e580 yolo tick 29: brands page sort selector (Most pieces / A→Z /  ·  back to Whatsmystyle  ·  crawl-catalog: log session-minutes after Browserbase session 69af117 →