← 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
M lib/valuation.jsM public/css/app.cssM public/js/app.jsM routes/console.jsM views/index.ejs
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 ×" 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 ×</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 →