[object Object]

← back to Whatsmystyle

yolo tick 23: color+material facet pills on /recs (server-side filter + localStorage) + /api/connect/test/:kind real env checks + /admin/cron diagnostic page (7 tasks, last-run from app_config)

fcfd42288361e2bd9d9a8067b2e004e2ed5169fc · 2026-05-12 12:41:04 -0700 · SteveStudio2

Files touched

Diff

commit fcfd42288361e2bd9d9a8067b2e004e2ed5169fc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 12:41:04 2026 -0700

    yolo tick 23: color+material facet pills on /recs (server-side filter + localStorage) + /api/connect/test/:kind real env checks + /admin/cron diagnostic page (7 tasks, last-run from app_config)
---
 public/admin-cron.html | 100 +++++++++++++++++++++++++++++++++++++++++++++++++
 public/css/app.css     |  28 ++++++++++++++
 public/index.html      |   2 +
 public/js/app.js       |  35 ++++++++++++++++-
 server.js              |  99 +++++++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 262 insertions(+), 2 deletions(-)

diff --git a/public/admin-cron.html b/public/admin-cron.html
new file mode 100644
index 0000000..89de7e8
--- /dev/null
+++ b/public/admin-cron.html
@@ -0,0 +1,100 @@
+<!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>WhatsMyStyle — Cron diagnostics</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: 24px; font-size: 14px; }
+    .nav { margin-bottom: 24px; }
+    .nav a { color: #707070; text-decoration: none; font-size: 14px; margin-right: 12px; }
+    .nav a:hover { color: #1d1d1f; }
+    .card { background: #fff; border: 1px solid #e6e1d8; border-radius: 16px; padding: 18px 22px; margin: 14px 0; }
+    table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    th, td { padding: 10px; text-align: left; border-bottom: 1px solid #f0eadf; vertical-align: top; }
+    th { font-weight: 600; color: #707070; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
+    .kind-launchd  { color: #1e6cff; }
+    .kind-setInterval { color: #5a3a1f; }
+    .pill { display: inline-block; background: #faf7f2; border: 1px solid #e6e1d8; padding: 3px 10px; border-radius: 999px; font-size: 11px; color: #555; }
+    .pill-good { background: #e6f6ec; border-color: #b7e0c4; color: #14572a; }
+    .pill-bad  { background: #fbe4e4; border-color: #e9b8b8; color: #7a1717; }
+    .muted { color: #707070; }
+    .hint  { font-size: 12px; color: #707070; max-width: 360px; }
+    h2 { font-size: 18px; font-weight: 600; margin: 0 0 14px; letter-spacing: -0.01em; }
+    .flag-row { display: flex; gap: 12px; flex-wrap: wrap; }
+    code { background: #efe9dd; padding: 2px 6px; border-radius: 5px; font-size: 12px; }
+  </style>
+</head>
+<body>
+  <div class="nav"><a href="/admin-config">← admin config</a><a href="/admin/drifts">drifts</a><a href="/">app home</a></div>
+  <h1>Cron diagnostics</h1>
+  <p class="sub">Every recurring task in the server + launchd. Last-run timestamps come from <code>app_config</code> updated_at columns or log file mtimes. Auto-refresh every 30s.</p>
+
+  <div class="card">
+    <h2>Flags</h2>
+    <div class="flag-row" id="flags-row"></div>
+  </div>
+
+  <div class="card">
+    <h2>Mac1</h2>
+    <div id="mac1-row" class="muted">checking…</div>
+  </div>
+
+  <div class="card">
+    <h2>Tasks</h2>
+    <table id="tasks-tbl">
+      <thead><tr><th>Task</th><th>Kind</th><th>Every</th><th>Last run</th><th>Notes</th></tr></thead>
+      <tbody></tbody>
+    </table>
+  </div>
+
+  <script>
+    const $ = (s) => document.querySelector(s);
+    function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+    function fmtInterval(s) {
+      if (s < 60) return s + 's';
+      if (s < 3600) return Math.round(s/60) + 'm';
+      if (s < 86400) return Math.round(s/3600) + 'h';
+      return Math.round(s/86400) + 'd';
+    }
+    function fmtSinceTs(ts) {
+      if (!ts) return '<span class="muted">never</span>';
+      const ms = Date.now() - new Date(ts).getTime();
+      if (isNaN(ms)) return '<span class="muted">' + escapeHtml(ts) + '</span>';
+      const s = Math.round(ms / 1000);
+      if (s < 60) return s + 's ago';
+      if (s < 3600) return Math.round(s/60) + 'm ago';
+      if (s < 86400) return Math.round(s/3600) + 'h ago';
+      return Math.round(s/86400) + 'd ago';
+    }
+    async function load() {
+      const r = await fetch('/api/admin/cron');
+      if (!r.ok) { document.body.innerHTML = '<p class="muted">admin gate failed</p>'; return; }
+      const j = await r.json();
+      $('#flags-row').innerHTML = Object.entries(j.flags || {}).map(([k, v]) =>
+        `<span class="pill ${v ? 'pill-good' : ''}">${escapeHtml(k)}: <strong>${v ? 'on' : 'off'}</strong></span>`).join('');
+      const m = j.mac1;
+      if (m) {
+        const cls = m.state === 'idle' ? 'pill-good' : m.state === 'busy' ? '' : 'pill-bad';
+        $('#mac1-row').innerHTML = `<span class="pill ${cls}">${m.state}</span> <span class="muted">${escapeHtml(m.detail || '')} · checked ${fmtSinceTs(m.checked_at)}</span>`;
+      } else $('#mac1-row').innerHTML = '<span class="muted">no status cached yet</span>';
+
+      $('#tasks-tbl tbody').innerHTML = (j.tasks || []).map(t => `
+        <tr>
+          <td><strong>${escapeHtml(t.name)}</strong></td>
+          <td class="kind-${escapeHtml(t.kind)}">${escapeHtml(t.kind)}</td>
+          <td>${fmtInterval(t.interval_s)}</td>
+          <td>${fmtSinceTs(t.last_run)}</td>
+          <td class="hint">${escapeHtml(t.hint || '')}</td>
+        </tr>
+      `).join('');
+    }
+    load();
+    setInterval(load, 30 * 1000);
+  </script>
+</body>
+</html>
diff --git a/public/css/app.css b/public/css/app.css
index 9516130..f6bea46 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -575,6 +575,34 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 }
 .active-prod-bar .active-prod-edit:hover { color: #1d1d1f; }
 
+/* ---- Facet pills (tick 23) — color + material filters above recs grid ---- */
+.facets-row {
+  display: flex; flex-wrap: wrap; gap: 6px;
+  margin-top: 10px;
+}
+.facet-pill {
+  font: inherit; font-size: 12px;
+  padding: 5px 12px;
+  border: 1px solid #d6d0c4;
+  background: #fff;
+  color: #555;
+  border-radius: 999px;
+  cursor: pointer;
+  text-transform: capitalize;
+}
+.facet-pill:hover { background: #faf7f2; color: #1d1d1f; }
+.facet-pill.is-active {
+  background: #1d1d1f;
+  border-color: #1d1d1f;
+  color: #faf7f2;
+}
+.facet-pill .facet-count {
+  margin-left: 4px;
+  font-size: 10px;
+  color: inherit;
+  opacity: 0.7;
+}
+
 /* ---- 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 84d2c09..ff36db5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -134,6 +134,8 @@
           <span>Set-grade only</span>
         </label>
       </div>
+      <!-- Tick 23: color + material facet pills. Tap to toggle. Persisted to localStorage. -->
+      <div id="facets-row" class="facets-row" hidden></div>
     </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 b3c353d..943d291 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -232,6 +232,20 @@ $('#set-grade-only')?.addEventListener('change', e => {
   loadRecs();
 });
 
+// ---- Facet pills (tick 23) — toggle color/material filter on click -------
+document.addEventListener('click', e => {
+  const pill = e.target.closest('.facet-pill');
+  if (!pill) return;
+  const kind = pill.dataset.facetKind;
+  const value = pill.dataset.facetValue;
+  const key = `wms.facets.${kind}s`;        // colors/materials
+  const current = JSON.parse(localStorage.getItem(key) || '[]');
+  const idx = current.indexOf(value);
+  if (idx >= 0) current.splice(idx, 1); else current.push(value);
+  localStorage.setItem(key, JSON.stringify(current));
+  loadRecs();
+});
+
 // ---- Couple lifecycle UI (tick 16, flag-gated) ----
 async function coupleInvite() {
   const out = $('#couple-invite-out');
@@ -765,7 +779,26 @@ async function loadRecs() {
   const proOnlyChecked = proOnly === '1';
   if ($('#set-grade-only')) $('#set-grade-only').checked = proOnlyChecked;
   const proQS = proOnlyChecked && isSetDec ? '&pro_only=1' : '';
-  const r = await fetch(`/api/recommend?limit=60${proQS}`).then(r => r.json());
+  // Tick 23: color + material facet filters from localStorage.
+  const activeColors = JSON.parse(localStorage.getItem('wms.facets.colors') || '[]');
+  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());
+  // Render the facet pill row.
+  const facetsRow = $('#facets-row');
+  if (facetsRow && r.facets) {
+    const pill = (kind, value, count, active) => `
+      <button type="button" class="facet-pill ${active ? 'is-active' : ''}" data-facet-kind="${kind}" data-facet-value="${value.replace(/"/g, '&quot;')}">
+        ${value} <span class="facet-count">${count}</span>
+      </button>`;
+    const html = [
+      ...r.facets.colors.map(c => pill('color', c.value, c.count, activeColors.includes(c.value))),
+      ...r.facets.materials.map(m => pill('material', m.value, m.count, activeMaterials.includes(m.value))),
+    ].join('');
+    facetsRow.innerHTML = html;
+    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));
diff --git a/server.js b/server.js
index ca83ba7..4e37c7c 100644
--- a/server.js
+++ b/server.js
@@ -655,6 +655,19 @@ app.get('/api/recommend', (req, res) => {
   const params = [];
   if (cat) { where.push('category = ?'); params.push(cat); }
   if (proOnly) { where.push('pro_grade = 1'); }
+  // 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.
+  const colors = (req.query.colors || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
+  const materials = (req.query.materials || '').split(',').map(s => s.trim().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);
   const sustainBoost = u.sustainability === 'top' ? 0.15 : u.sustainability === 'nice' ? 0.05 : 0;
   // Tick 19: production credit count per catalog item for the current user.
@@ -673,7 +686,21 @@ app.get('/api/recommend', (req, res) => {
     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);
-  res.json({ items: scored, sustainability_weight: sustainBoost });
+  // 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();
+    for (const r of scored) {
+      const v = (r[field] || '').trim().toLowerCase();
+      if (v) counts.set(v, (counts.get(v) || 0) + 1);
+    }
+    return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));
+  }
+  res.json({
+    items: scored,
+    sustainability_weight: sustainBoost,
+    facets: { colors: topFacets('color', 6), materials: topFacets('material', 4) },
+    applied: { colors, materials },
+  });
 });
 
 // ---- closet ---------------------------------------------------------------
@@ -746,6 +773,33 @@ app.get('/api/connect', (req, res) => {
   res.json({ connections: rows });
 });
 
+// Tick 23: real connectivity check per integration. Just verifies the env
+// keys exist for now — full OAuth handshakes happen in /oauth/<kind>/start.
+// Returns { ok, configured, last_checked, hint } so the connect screen can
+// stop showing "pending" for every source.
+app.get('/api/connect/test/:kind', (req, res) => {
+  const kind = req.params.kind;
+  const checks = {
+    stripe_fc:   { keys: ['STRIPE_SECRET_KEY', 'STRIPE_FC_CLIENT_ID'], hint: 'Stripe Financial Connections — set both keys in .env' },
+    gmail:       { keys: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], hint: 'Google OAuth client — Gmail receipt parsing needs Workspace consent screen' },
+    camera_roll: { keys: ['GOOGLE_PICKER_API_KEY', 'GOOGLE_CLIENT_ID'], hint: 'Google Photos via Picker API — already wired in /api/public-config' },
+    social:      { keys: ['META_GRAPH_TOKEN'], hint: 'Meta Graph for Instagram — needs Business account + Graph token' },
+    location:    { keys: ['GOOGLE_PLACES_API_KEY'], hint: 'Google Places — used by /api/stores' },
+  };
+  const c = checks[kind];
+  if (!c) return res.status(400).json({ error: 'unknown integration kind' });
+  const present = c.keys.map(k => ({ key: k, set: !!process.env[k] }));
+  const configured = present.every(p => p.set);
+  res.json({
+    ok: true,
+    kind,
+    configured,
+    keys: present,
+    hint: c.hint,
+    last_checked: new Date().toISOString(),
+  });
+});
+
 // ---- stores near me -------------------------------------------------------
 app.get('/api/stores', async (req, res) => {
   const { lat, lng } = req.query;
@@ -824,6 +878,49 @@ app.get('/admin/drifts', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin-drifts.html'));
 });
 
+app.get('/admin/cron', (req, res) => {
+  if (!adminGate(req)) return res.status(403).send('admin only');
+  res.sendFile(path.join(__dirname, 'public', 'admin-cron.html'));
+});
+
+// Tick 23: cron diagnostic data. Returns every recurring task with interval +
+// last-run timestamp (pulled from app_config). Server-side tasks log their
+// last-run; launchd tasks pull mtime from their log file.
+app.get('/api/admin/cron', (req, res) => {
+  if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
+  const fs = require('fs');
+  function logMtime(p) {
+    try { return fs.statSync(p).mtime.toISOString(); } catch { return null; }
+  }
+  function configMtime(key) {
+    try {
+      const r = db.prepare('SELECT updated_at FROM app_config WHERE key=?').get(key);
+      return r?.updated_at || null;
+    } catch { return null; }
+  }
+  const tasks = [
+    { name: 'Closet vision drain',     kind: 'setInterval', interval_s: 60,    last_run: null, hint: 'spawns scripts/closet-vision.js when pending rows in closet table' },
+    { name: 'Tryon job drain',         kind: 'setInterval', interval_s: 30,    last_run: null, hint: 'spawns scripts/tryon-worker.js when queued tryon_jobs > 0' },
+    { name: 'Avatar build drain',      kind: 'setInterval', interval_s: 30,    last_run: null, hint: 'spawns scripts/avatar-build.js for pending/building avatars' },
+    { name: 'Drift sweep',             kind: 'setInterval', interval_s: 86400, last_run: configMtime('drift_offset'), hint: '24h rolling re-embed check vs stored vectors' },
+    { name: 'Unembedded drainer (in-process)', kind: 'setInterval', interval_s: 300, last_run: null, hint: 'gated on embed_drainer_enabled + Mac1 idle' },
+    { name: 'Mac1 watcher',            kind: 'setInterval', interval_s: 60,    last_run: configMtime('mac1_status'), hint: 'polls 192.168.1.133:11434/api/ps + caches state' },
+    { name: 'Embed drainer (launchd)', kind: 'launchd', interval_s: 300, last_run: logMtime(path.join(__dirname, 'data/logs/embed-drainer.out')), hint: 'com.steve.wms-embed-drainer.plist — survives pm2 restarts' },
+  ];
+  // app_config flags that gate the tasks
+  const flags = {
+    embed_drainer_enabled: configValue('embed_drainer_enabled', false),
+    couples_pilot:         configValue('couples_pilot', false),
+  };
+  // mac1 status snapshot
+  let mac1 = null;
+  try {
+    const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
+    if (r) mac1 = JSON.parse(r.value);
+  } catch {}
+  res.json({ tasks, flags, mac1, now: new Date().toISOString() });
+});
+
 app.get('/api/admin/drifts', (req, res) => {
   if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
   // Recent drift events, joined to items for context.

← c0bf68a yolo tick 22: SPA selector calibrator (BB fetch + cheerio pr  ·  back to Whatsmystyle  ·  yolo tick 24: favorites table + ★ star icon on cards + /favo 4a6d125 →