[object Object]

← back to Zuber Internal

Add color context+Color sort, tag facet (needs-curation), strip healthz count

2d17e2abc77d582d53e14cdaa942f4b109bacd52 · 2026-07-09 13:44:34 -0700 · Steve Abrams

FIX1: color_name falls back to ai_colors[0].name (column null for all rows);
color_hex falls back to ai_colors[0].hex; server-side Color sort by hue(color_hex)
+ Color option in sort select (parity with sibling viewers).
FIX2: unauthenticated /healthz returns {ok:true} only, no product count leak.
FIX3: ai_tags now built into a Tag facet (needs-curation/broken-source-image
visible+filterable, count 2) + tag filter in /api/products.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2d17e2abc77d582d53e14cdaa942f4b109bacd52
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 9 13:44:34 2026 -0700

    Add color context+Color sort, tag facet (needs-curation), strip healthz count
    
    FIX1: color_name falls back to ai_colors[0].name (column null for all rows);
    color_hex falls back to ai_colors[0].hex; server-side Color sort by hue(color_hex)
    + Color option in sort select (parity with sibling viewers).
    FIX2: unauthenticated /healthz returns {ok:true} only, no product count leak.
    FIX3: ai_tags now built into a Tag facet (needs-curation/broken-source-image
    visible+filterable, count 2) + tag filter in /api/products.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html |  3 ++-
 server.js         | 37 ++++++++++++++++++++++++++++---------
 2 files changed, 30 insertions(+), 10 deletions(-)

diff --git a/public/index.html b/public/index.html
index 3a8a44b..7f0746e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -54,6 +54,7 @@
   <label>Sort
     <select id="sort">
       <option value="newest">Newest</option>
+      <option value="color">Color</option>
       <option value="pattern">Pattern A→Z</option>
       <option value="collection">Collection</option>
       <option value="sku">SKU A→Z</option>
@@ -82,7 +83,7 @@
 const $ = (s, r=document) => r.querySelector(s);
 const API = location.origin;
 const state = { q: '', sort: 'newest', sel: {} };
-const FACET_DEFS = [['collection','Collection'],['product_type','Type'],['material','Material'],['style','Style']];
+const FACET_DEFS = [['collection','Collection'],['product_type','Type'],['material','Material'],['style','Style'],['tag','Tag']];
 
 function ls(k, d) { try { return JSON.parse(localStorage.getItem('zi_'+k)) ?? d; } catch { return d; } }
 function save(k, v) { localStorage.setItem('zi_'+k, JSON.stringify(v)); }
diff --git a/server.js b/server.js
index 646ccc4..3c7457a 100644
--- a/server.js
+++ b/server.js
@@ -15,8 +15,9 @@ const REFRESH_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 300);
 const app = express();
 app.use(require('compression')());
 
-// unauthenticated health probe BEFORE the gate
-app.get('/healthz', (_req, res) => res.json({ ok: true, products: ROWS.length, lastRefresh: LAST_REFRESH }));
+// unauthenticated health probe BEFORE the gate — keep it a bare liveness signal
+// (no catalog size/detail; the count is only exposed behind auth via /api/config)
+app.get('/healthz', (_req, res) => res.json({ ok: true }));
 
 app.use((req, res, next) => {
   const [scheme, b64] = (req.headers.authorization || '').split(' ');
@@ -50,13 +51,16 @@ function load() {
         if (r.all_images) { try { imgs = JSON.parse(r.all_images); } catch { imgs = []; } }
         if (!imgs.length && r.image_url) imgs = [r.image_url];
         const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
+        const aiColors = parseArr(r.ai_colors);
         return {
           idx: i,
           sku: r.mfr_sku,
           mfr_sku: r.mfr_sku,
           pattern_name: r.pattern_name || r.mfr_sku,
           pattern_name_fr: r.pattern_name_fr || null,
-          color_name: r.color_name || null,
+          // zuber_catalog.color_name is null for all rows; fall back to the enriched
+          // ai_colors[0].name (the same pattern Gracie uses) so cards + Color sort have context.
+          color_name: r.color_name || (aiColors[0] && aiColors[0].name) || null,
           collection: r.collection || null,
           product_type: r.product_type || null,
           material: r.material || null,
@@ -66,10 +70,10 @@ function load() {
           image_count: imgs.length,
           product_url: r.product_url || null,
           description: r.description || null,
-          ai_colors: parseArr(r.ai_colors),
+          ai_colors: aiColors,
           ai_styles: parseArr(r.ai_styles),
           ai_tags: parseArr(r.ai_tags),
-          color_hex: r.color_hex || null,
+          color_hex: r.color_hex || (aiColors[0] && aiColors[0].hex) || null,
           created_at: r.created_at || null,
         };
       });
@@ -84,25 +88,39 @@ setInterval(load, REFRESH_SEC * 1000);
 // ---- facets (field-tab tables) -------------------------------------------
 function facets() {
   const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
-  const collection = {}, product_type = {}, material = {}, style = {};
+  const collection = {}, product_type = {}, material = {}, style = {}, tag = {};
   for (const r of ROWS) {
     bump(collection, r.collection);
     bump(product_type, r.product_type);
     bump(material, r.material);
     for (const s of r.ai_styles) bump(style, s);
+    for (const t of r.ai_tags) bump(tag, t);
   }
   const byCount = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
   const byAlpha = (o) => Object.entries(o).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
-  return { collection: byAlpha(collection), product_type: byCount(product_type), material: byCount(material), style: byCount(style) };
+  return { collection: byAlpha(collection), product_type: byCount(product_type), material: byCount(material), style: byCount(style), tag: byCount(tag) };
 }
 
 // ---- api ------------------------------------------------------------------
 const cmp = (a, b) => String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
+// hue() maps a #rrggbb hex to a 0-360 color-wheel bucket (grays/unknown → 999 so they
+// sort last). Identical helper the sibling viewers (fromental/crezana) use for Color sort.
+function hue(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return 999;
+  const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  if (d === 0) return 999;
+  let h;
+  if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
+  h *= 60; if (h < 0) h += 360;
+  return h;
+}
 const sorters = {
   newest: null,
   pattern: (a, b) => cmp(a.pattern_name, b.pattern_name),
   collection: (a, b) => cmp(a.collection, b.collection) || cmp(a.pattern_name, b.pattern_name),
   sku: (a, b) => cmp(a.mfr_sku, b.mfr_sku),
+  color: (a, b) => (hue(a.color_hex) - hue(b.color_hex)) || cmp(a.color_name, b.color_name) || cmp(a.pattern_name, b.pattern_name),
 };
 
 app.get('/api/config', (_req, res) => res.json({
@@ -115,11 +133,12 @@ app.get('/api/products', (req, res) => {
   const q = (req.query.q || '').toString().trim().toLowerCase();
   const sort = (req.query.sort || 'newest').toString();
   const sel = (k) => { const v = req.query[k]; return v ? v.toString().split('|').filter(Boolean) : []; };
-  const fColl = sel('collection'), fType = sel('product_type'), fMat = sel('material'), fStyle = sel('style');
+  const fColl = sel('collection'), fType = sel('product_type'), fMat = sel('material'), fStyle = sel('style'), fTag = sel('tag');
   const inAny = (arr, val) => arr.length === 0 || arr.includes(val ?? '');
   let rows = ROWS.filter((r) =>
     inAny(fColl, r.collection) && inAny(fType, r.product_type) && inAny(fMat, r.material) &&
-    (fStyle.length === 0 || r.ai_styles.some((s) => fStyle.includes(s))));
+    (fStyle.length === 0 || r.ai_styles.some((s) => fStyle.includes(s))) &&
+    (fTag.length === 0 || r.ai_tags.some((t) => fTag.includes(t))));
   if (q) rows = rows.filter((r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase().includes(q));
   if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
   res.json({ total: rows.length, all: ROWS.length, products: rows });

← afe7565 Fix zuber viewer 401 crash: plain-ASCII WWW-Authenticate rea  ·  back to Zuber Internal  ·  jsonl-backed data layer: self-contained bundle for Kamatera ada4b82 →