[object Object]

← back to Lifestyle Asset Intel

yolo tick #10: server-side /api/assets?q= filter

6d5a46c93e97873a012c7c8989f58e11a7559ce7 · 2026-05-10 00:16:56 -0700 · Steve Abrams

Catalog discovery scaling. Until now the grid filter was DOM-only —
lib/valuation.listAssets returned the entire catalog and the client-
side JS hid non-matching cards. That works at 15 assets but breaks
when a paying API consumer pulls /api/assets at 1,500+ assets.

listAssets now accepts opts.q — a single token matched
case-insensitively across slug, brand, family, size, material, color,
hardware, and construction via parameterized SQL LIKE. Returns
{ count, q, assets[] } so callers can confirm the filter was honoured.

routes/console.js GET / pulls req.query.q through to listAssets and
passes it to the EJS view, which SSR-renders the filter into the
search input plus a "Clear ×" link when q is set. Pressing
Enter in the search box now navigates to /?q=… for server-side
scoping; typing keeps the existing DOM-side live filter for
sub-second feedback.

40/40 tests green; new tests assert ?q=birkin returns only Birkins
and ?q=clemence filters by material.

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

Files touched

Diff

commit 6d5a46c93e97873a012c7c8989f58e11a7559ce7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 00:16:56 2026 -0700

    yolo tick #10: server-side /api/assets?q= filter
    
    Catalog discovery scaling. Until now the grid filter was DOM-only —
    lib/valuation.listAssets returned the entire catalog and the client-
    side JS hid non-matching cards. That works at 15 assets but breaks
    when a paying API consumer pulls /api/assets at 1,500+ assets.
    
    listAssets now accepts opts.q — a single token matched
    case-insensitively across slug, brand, family, size, material, color,
    hardware, and construction via parameterized SQL LIKE. Returns
    { count, q, assets[] } so callers can confirm the filter was honoured.
    
    routes/console.js GET / pulls req.query.q through to listAssets and
    passes it to the EJS view, which SSR-renders the filter into the
    search input plus a "Clear &times;" link when q is set. Pressing
    Enter in the search box now navigates to /?q=… for server-side
    scoping; typing keeps the existing DOM-side live filter for
    sub-second feedback.
    
    40/40 tests green; new tests assert ?q=birkin returns only Birkins
    and ?q=clemence filters by material.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/valuation.js   | 28 ++++++++++++++++++++++++++--
 public/css/app.css |  5 +++++
 public/js/app.js   | 19 +++++++++++++++++--
 routes/console.js  |  4 +++-
 views/index.ejs    |  5 ++++-
 5 files changed, 55 insertions(+), 6 deletions(-)

diff --git a/lib/valuation.js b/lib/valuation.js
index 1908a63..02bae3f 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -288,12 +288,34 @@ function computeBreakdown({ asset, comps, snapshot, sourceWeights }) {
   };
 }
 
-async function listAssets() {
+async function listAssets(opts) {
   // Joins each asset to its latest snapshot AND a 24-month transaction
   // window for live-computed liquidity/dts. The snapshot's stored
   // liquidity_score/expected_dts are constants in v0 seed data; we
   // override with live values before returning so the grid sorts and
   // filters reflect actual signal, not seed defaults.
+  //
+  // opts.q (string)         — case-insensitive filter token. Matches
+  //                            against any of: slug, brand, family,
+  //                            size, material, color, hardware,
+  //                            construction.
+  const q = opts && typeof opts.q === 'string' ? opts.q.trim() : '';
+  const params = [];
+  let whereSql = '';
+  if (q) {
+    params.push(`%${q.toLowerCase()}%`);
+    const i = '$' + params.length;
+    whereSql = `
+      WHERE LOWER(ca.slug) LIKE ${i}
+         OR LOWER(b.name) LIKE ${i}
+         OR LOWER(mf.name) LIKE ${i}
+         OR LOWER(COALESCE(ca.size,'')) LIKE ${i}
+         OR LOWER(COALESCE(ca.material,'')) LIKE ${i}
+         OR LOWER(COALESCE(ca.color,'')) LIKE ${i}
+         OR LOWER(COALESCE(ca.hardware,'')) LIKE ${i}
+         OR LOWER(COALESCE(ca.construction,'')) LIKE ${i}`;
+  }
+
   const rows = await many(
     `SELECT ca.id, ca.slug,
             mf.name AS family_name, mf.slug AS family_slug,
@@ -324,7 +346,9 @@ async function listAssets() {
              WHERE t.canonical_asset_id = ca.id
                AND t.transacted_at > now() - interval '24 months'
        ) stats ON true
-      ORDER BY b.name, mf.name, ca.size, ca.material, ca.color`
+      ${whereSql}
+      ORDER BY b.name, mf.name, ca.size, ca.material, ca.color`,
+    params
   );
 
   return rows.map((r) => {
diff --git a/public/css/app.css b/public/css/app.css
index 6d9c39b..8396e1e 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -77,6 +77,11 @@ h2 { margin: 1.6rem 0 0.6rem; font-size: 1.15rem; }
   padding: 0.4rem 0.6rem; border-radius: 6px; font: inherit;
 }
 .controls input[type="range"] { accent-color: var(--accent); }
+.controls .clear-q {
+  padding: 0.35rem 0.7rem; border: 1px solid var(--line); border-radius: 6px;
+  color: var(--muted); text-decoration: none; font-size: 0.85em;
+}
+.controls .clear-q:hover { border-color: var(--accent); color: var(--accent); }
 
 .grid {
   display: grid;
diff --git a/public/js/app.js b/public/js/app.js
index ef6d0d4..d1f71a1 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -86,8 +86,23 @@
     sort.addEventListener('change', function () { applySort(sort.value); });
   }
   if (search) {
-    search.value = initial.search;
-    if (initial.search) applySearch(initial.search);
+    // Server-side scoping: pressing Enter navigates to /?q=… so the
+    // grid is filtered by the SQL LIKE in lib/valuation.listAssets,
+    // which keeps working past the client-side DOM size limit.
+    search.addEventListener('keydown', function (ev) {
+      if (ev.key === 'Enter') {
+        ev.preventDefault();
+        var q = (search.value || '').trim();
+        var u = new URL(window.location.href);
+        if (q) u.searchParams.set('q', q);
+        else u.searchParams.delete('q');
+        window.location.href = u.toString();
+      }
+    });
+    // If the page was rendered with a server-side q, prefer that over
+    // localStorage; else fall back to the persisted client-side search.
+    if (!search.value) search.value = initial.search;
+    if (search.value) applySearch(search.value);
     search.addEventListener('input', function () { applySearch(search.value); });
   }
 })();
diff --git a/routes/console.js b/routes/console.js
index 0110991..e0cec62 100644
--- a/routes/console.js
+++ b/routes/console.js
@@ -26,10 +26,12 @@ const METHODOLOGY_HTML = (() => {
 
 router.get('/', async (req, res, next) => {
   try {
-    const assets = await listAssets();
+    const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
+    const assets = await listAssets(q ? { q } : {});
     res.render('index', {
       title: 'Lifestyle Asset Intelligence — Market-Ops Console',
       assets,
+      q,
       path: '/'
     });
   } catch (e) { next(e); }
diff --git a/views/index.ejs b/views/index.ejs
index 0ad1e31..05e9947 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -21,8 +21,11 @@
       <output id="densityOut">5</output>
     </label>
     <label class="grow">Search
-      <input type="search" id="search" placeholder="brand, family, material, color…" aria-label="Filter assets">
+      <input type="search" id="search" name="q" value="<%= typeof q !== 'undefined' && q ? q : '' %>" placeholder="brand, family, material, color…" aria-label="Filter assets">
     </label>
+    <% if (typeof q !== 'undefined' && q) { %>
+      <a class="clear-q" href="/" title="Clear server-side filter">Clear &times;</a>
+    <% } %>
   </div>
 
   <section class="grid" id="grid">

← 8639f38 yolo tick #9: OpenAPI 3.1 spec at /api/openapi.json  ·  back to Lifestyle Asset Intel  ·  yolo tick #11: per-route Cache-Control discipline 25a6082 →