[object Object]

← back to Whatsmystyle

yolo tick 27: duel prefetch (next_pair returned + client Image() preload) + /brands index page (38 brands sorted by count, tier badges) + bulk closet upload (cap 50, single transaction)

12e6669fff1cf944c6226878e9542ad363133c46 · 2026-05-12 15:01:03 -0700 · SteveStudio2

Files touched

Diff

commit 12e6669fff1cf944c6226878e9542ad363133c46
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 15:01:03 2026 -0700

    yolo tick 27: duel prefetch (next_pair returned + client Image() preload) + /brands index page (38 brands sorted by count, tier badges) + bulk closet upload (cap 50, single transaction)
---
 public/brands.html | 58 ++++++++++++++++++++++++++++++++++++++++++
 public/index.html  |  4 ++-
 public/js/app.js   | 40 ++++++++++++++++++++++++++---
 server.js          | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 171 insertions(+), 5 deletions(-)

diff --git a/public/brands.html b/public/brands.html
new file mode 100644
index 0000000..b3c6377
--- /dev/null
+++ b/public/brands.html
@@ -0,0 +1,58 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Brands — 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: 1100px; margin: 40px auto; padding: 0 24px; }
+    h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
+    .sub { color: #707070; margin-bottom: 28px; font-size: 14px; }
+    .nav { margin-bottom: 18px; }
+    .nav a { color: #707070; text-decoration: none; font-size: 14px; }
+    .nav a:hover { color: #1d1d1f; }
+    .brand-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
+    .brand-card {
+      background: #fff; border: 1px solid #e6e1d8; border-radius: 16px;
+      padding: 18px 22px; text-decoration: none; color: inherit;
+      display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
+      transition: background 0.15s;
+    }
+    .brand-card:hover { background: #f9f5ed; border-color: #c8c1b3; }
+    .brand-card .name { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
+    .brand-card .count { font-size: 12px; color: #707070; font-variant-numeric: tabular-nums; }
+    .brand-card .tier {
+      font-size: 11px; padding: 2px 8px; border-radius: 999px;
+      background: #f3eee2; color: #6e4f0d; margin-top: 4px;
+    }
+    .brand-card .tier-5 { background: #d4f4d4; color: #1d5e1d; }
+    .brand-card .tier-1 { background: #fbe4e4; color: #7a1717; }
+  </style>
+</head>
+<body>
+  <div class="nav"><a href="/">← back to app</a></div>
+  <h1>Brands</h1>
+  <p class="sub" id="sub">Every brand in our catalog, sorted by piece count.</p>
+  <div id="grid" class="brand-grid"></div>
+
+  <script>
+    const $ = s => document.querySelector(s);
+    function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+    (async function () {
+      const r = await fetch('/api/brands');
+      const j = await r.json();
+      $('#sub').textContent = `${j.brands.length} brand${j.brands.length === 1 ? '' : 's'} · sorted by piece count.`;
+      $('#grid').innerHTML = j.brands.map(b => `
+        <a class="brand-card" href="/?brand=${encodeURIComponent(b.brand)}" onclick="event.preventDefault();const u=new URL(location.origin);u.searchParams.set('brand',this.dataset.brand||'${escapeHtml(b.brand)}');location.href=u.toString();" data-brand="${escapeHtml(b.brand)}">
+          <div>
+            <div class="name">${escapeHtml(b.brand)}</div>
+            ${b.sustain_tier ? `<span class="tier tier-${b.sustain_tier}">♻ ${b.sustain_tier}/5</span>` : ''}
+          </div>
+          <div class="count">${b.item_count} pcs</div>
+        </a>
+      `).join('');
+    })();
+  </script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index 09b1539..51e7a05 100644
--- a/public/index.html
+++ b/public/index.html
@@ -39,6 +39,7 @@
   <a href="#" data-screen="studio">Studio</a>
   <a href="#" data-screen="tryons">My Try-Ons</a>
   <a href="#" data-screen="favorites">My Favorites</a>
+  <a href="/brands">Brands</a>
   <a href="#" data-screen="productions" data-role-only="set_decorator">My Productions</a>
   <a href="#" data-screen="couple" data-role-only="couples_pilot">Couple</a>
   <a href="/admin-config" data-role-only="admin">Admin Config</a>
@@ -104,11 +105,12 @@
     <h2>My Closet</h2>
     <p class="muted" id="closet-empty-state">Upload a photo of your closet — we'll catalogue each piece automatically.</p>
     <form id="closet-form">
-      <input type="file" name="photo" accept="image/*" capture="environment">
+      <input type="file" name="photo" accept="image/*" capture="environment" multiple>
       <select name="production_id" id="closet-prod-select" data-role-only="set_decorator" hidden>
         <option value="">— untagged —</option>
       </select>
       <button type="submit" id="closet-upload-btn">Upload</button>
+      <span id="closet-upload-status" class="muted"></span>
     </form>
     <div id="closet-grid" class="grid"></div>
   </section>
diff --git a/public/js/app.js b/public/js/app.js
index cb23538..cf4c19c 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -744,6 +744,17 @@ async function loadDuel() {
   $('#duel-q').textContent = j.question;
   paintCard('a', j.a);
   paintCard('b', j.b);
+  // Tick 27: prefetch next pair's images so the next tap is instant.
+  if (j.next_pair) {
+    [j.next_pair.a, j.next_pair.b].forEach(it => {
+      if (it?.image_url) {
+        const img = new Image();
+        img.decoding = 'async';
+        img.referrerPolicy = 'no-referrer';
+        img.src = it.image_url;
+      }
+    });
+  }
 }
 
 let avatarReady = false;
@@ -815,9 +826,32 @@ async function seedCatalog() {
 // ---------- Closet ---------------------------------------------------------
 $('#closet-form').addEventListener('submit', async e => {
   e.preventDefault();
-  const fd = new FormData(e.target);
-  const r = await fetch('/api/closet/photo', { method: 'POST', body: fd });
-  if (r.ok) loadCloset();
+  const fileInput = e.target.querySelector('input[type=file]');
+  const files = fileInput?.files || [];
+  const status = $('#closet-upload-status');
+  if (status) status.textContent = '';
+  if (files.length === 0) return;
+  // Tick 27: bulk path when >1 file selected.
+  if (files.length === 1) {
+    const fd = new FormData(e.target);
+    if (status) status.textContent = 'uploading…';
+    const r = await fetch('/api/closet/photo', { method: 'POST', body: fd });
+    if (r.ok) { if (status) status.textContent = 'done.'; loadCloset(); }
+    else if (status) status.textContent = 'failed.';
+    return;
+  }
+  // Multi: re-build FormData with photos[] field name + production_id passthrough.
+  const fd = new FormData();
+  for (const f of files) fd.append('photos', f);
+  const pid = $('#closet-prod-select')?.value;
+  if (pid) fd.append('production_id', pid);
+  if (status) status.textContent = `uploading ${files.length}…`;
+  const r = await fetch('/api/closet/photo/bulk', { method: 'POST', body: fd });
+  if (r.ok) {
+    const j = await r.json();
+    if (status) status.textContent = `uploaded ${j.count}. analyzing in background.`;
+    loadCloset();
+  } else if (status) status.textContent = 'bulk upload failed.';
 });
 async function loadCloset() {
   const r = await fetch('/api/closet').then(r => r.json());
diff --git a/server.js b/server.js
index 10f3ed8..58f884a 100644
--- a/server.js
+++ b/server.js
@@ -624,7 +624,23 @@ app.get('/api/duel', (req, res) => {
   const { lastInsertRowid } = db.prepare(
     'INSERT INTO duels (user_id, item_a, item_b, question) VALUES (?, ?, ?, ?)'
   ).run(u.id, pair[0].id, pair[1].id, question);
-  res.json({ duel_id: lastInsertRowid, a: pair[0], b: pair[1], question });
+  // Tick 27: prefetch the next pair so the client can preload images and
+  // taps feel instant. Cheap second random pick from the same category
+  // (skipping the items just served). No duels row inserted for the
+  // prefetch — that happens on the next /api/duel call.
+  let nextPair = null;
+  try {
+    const skipIds = [pair[0].id, pair[1].id];
+    const nextSql = cat
+      ? `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND category = ? AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 2`
+      : `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 2`;
+    const prefetch = cat ? db.prepare(nextSql).all(cat, ...skipIds) : db.prepare(nextSql).all(...skipIds);
+    if (prefetch.length === 2) {
+      prefetch.forEach(p => p.sustain = tierFor(p.brand));
+      nextPair = { a: prefetch[0], b: prefetch[1] };
+    }
+  } catch {}
+  res.json({ duel_id: lastInsertRowid, a: pair[0], b: pair[1], question, next_pair: nextPair });
 });
 
 app.post('/api/duel', (req, res) => {
@@ -741,6 +757,31 @@ app.get('/api/recommend', (req, res) => {
 // Server-side filter on items.brand (case-insensitive match). Sustainability
 // tier pulled from scripts/sustainability.js. Bio sourced from brands.json
 // (clothing-apis registry) for any registered brand.
+// Tick 27: brand index — all brands present in the catalog with item count +
+// sustain tier. Public, no auth. Sort desc by item count so the heavy hitters
+// surface first.
+app.get('/api/brands', (req, res) => {
+  const rows = db.prepare(
+    `SELECT brand, COUNT(*) c FROM items
+       WHERE brand IS NOT NULL AND brand != ''
+         AND image_url IS NOT NULL AND image_url != ''
+       GROUP BY brand
+       ORDER BY c DESC
+       LIMIT 200`
+  ).all();
+  let tierFor; try { ({ tierFor } = require('./scripts/sustainability')); } catch { tierFor = () => null; }
+  const items = rows.map(r => ({
+    brand: r.brand,
+    item_count: r.c,
+    sustain_tier: tierFor(r.brand),
+  }));
+  res.json({ brands: items });
+});
+
+app.get('/brands', (_req, res) => {
+  res.sendFile(path.join(__dirname, 'public', 'brands.html'));
+});
+
 app.get('/api/brand/:name', (req, res) => {
   const u = currentUser(req);
   const name = decodeURIComponent(req.params.name || '').trim();
@@ -879,6 +920,37 @@ app.get('/api/closet', (req, res) => {
   res.json({ items: withCounts });
 });
 
+// Tick 27: bulk-upload — drop a folder of photos at once. Server caps at 50
+// per call, batches the closet inserts in a single transaction. Each row
+// goes through the existing closet-vision drain so categorization happens
+// in the background.
+app.post('/api/closet/photo/bulk', upload.array('photos', 50), (req, res) => {
+  const u = currentUser(req);
+  if (!req.files?.length) return res.status(400).json({ error: 'no files' });
+  let pid = null;
+  const raw = req.body?.production_id;
+  if (raw && raw !== '0' && raw !== '') {
+    const n = Number(raw);
+    if (Number.isFinite(n) && n > 0) {
+      const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
+      if (owned) pid = n;
+    }
+  }
+  const insert = db.prepare(
+    'INSERT INTO closet (user_id, photo_path, acquired_via, production_id) VALUES (?, ?, ?, ?)'
+  );
+  const tx = db.transaction((files) => {
+    const ids = [];
+    for (const f of files) {
+      const r = insert.run(u.id, f.path, 'photo', pid);
+      ids.push(r.lastInsertRowid);
+    }
+    return ids;
+  });
+  const ids = tx(req.files);
+  res.json({ ok: true, count: ids.length, closet_ids: ids, production_id: pid });
+});
+
 app.post('/api/closet/photo', upload.single('photo'), (req, res) => {
   const u = currentUser(req);
   if (!req.file) return res.status(400).json({ error: 'no file' });

← e29fe3f yolo tick 26: image-isnull guard on duel/recs/outfit-builder  ·  back to Whatsmystyle  ·  yolo tick 28: brands sustainability filter pills (All/♻5/♻4/ 636d4e8 →