← 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
A public/admin-cron.htmlM public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, '"')}">
+ ${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 →