[object Object]

← back to Lifestyle Asset Intel

yolo tick #29: /api/index ?q= filter (slug/name, case-insensitive, composes with sort)

4206774017bd8343436ce1af2673a8c810743992 · 2026-05-14 01:29:32 -0700 · Steve Abrams

Files touched

Diff

commit 4206774017bd8343436ce1af2673a8c810743992
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 01:29:32 2026 -0700

    yolo tick #29: /api/index ?q= filter (slug/name, case-insensitive, composes with sort)
---
 lib/openapi.js      |  7 ++++++-
 lib/valuation.js    | 15 ++++++++++++++-
 routes/api.js       | 10 ++++++++--
 tests/smoke.test.js | 31 +++++++++++++++++++++++++++++++
 4 files changed, 59 insertions(+), 4 deletions(-)

diff --git a/lib/openapi.js b/lib/openapi.js
index fe44e28..e9e4170 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -433,9 +433,13 @@ function buildSpec({ version, gitSha }) {
           'order is by slug. Pass ?sort= to switch — supported modes: ' +
           'slug, name, value-desc, value-asc, change-desc, change-asc, ' +
           'points-desc, recent-desc. Unknown sort values fall back to ' +
-          'default ordering.',
+          'default ordering. Pass ?q= for a case-insensitive substring ' +
+          'filter on slug or name; composes with ?sort=.',
         tags: ['indices'],
         parameters: [{
+          name: 'q', in: 'query', required: false,
+          schema: { type: 'string' }, example: 'birkin'
+        }, {
           name: 'sort', in: 'query', required: false,
           schema: { type: 'string' }, example: 'value-desc'
         }],
@@ -444,6 +448,7 @@ function buildSpec({ version, gitSha }) {
             type: 'object',
             properties: {
               count: { type: 'integer' },
+              q: { type: 'string', nullable: true },
               sort: { type: 'string', nullable: true },
               indices: { type: 'array', items: {
                 type: 'object',
diff --git a/lib/valuation.js b/lib/valuation.js
index 4bc06c8..4b4dfb0 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -688,9 +688,20 @@ async function getIndex(slug) {
 // Single SQL pass with a lateral subquery for the latest point, so this
 // stays cheap as the indices table grows.
 //
+// opts.q (string)    — case-insensitive filter token. Matches against
+//                       slug or name. Mirrors /api/family q semantics.
 // opts.sort (string) — see INDEX_SORTS. Default ordering is by slug.
 async function listIndices(opts) {
+  const q = opts && typeof opts.q === 'string' ? opts.q.trim() : '';
   const sort = opts && typeof opts.sort === 'string' ? opts.sort.trim() : '';
+  const params = [];
+  let whereSql = '';
+  if (q) {
+    params.push(`%${q.toLowerCase()}%`);
+    const i = '$' + params.length;
+    whereSql = `WHERE LOWER(i.slug) LIKE ${i}
+                   OR LOWER(i.name) LIKE ${i}`;
+  }
   const rows = await many(
     `SELECT i.slug, i.name, i.definition, i.methodology_version,
             COALESCE(stats.point_count, 0)::int AS point_count,
@@ -711,7 +722,9 @@ async function listIndices(opts) {
              ORDER BY as_of DESC
              LIMIT 1
        ) latest ON true
-   ORDER BY i.slug`
+      ${whereSql}
+   ORDER BY i.slug`,
+    params
   );
   const enriched = rows.map((r) => ({
     slug: r.slug,
diff --git a/routes/api.js b/routes/api.js
index e7dc5e9..e8e11d7 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -192,9 +192,15 @@ router.get('/family/:slug', setCache(PUB_300), async (req, res, next) => {
 
 router.get('/index', setCache(PUB_300), async (req, res, next) => {
   try {
+    const q = typeof req.query.q === 'string' ? req.query.q : '';
     const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
-    const indices = await listIndices({ sort });
-    res.json({ count: indices.length, sort: sort || null, indices });
+    const indices = await listIndices({ q, sort });
+    res.json({
+      count: indices.length,
+      q: q || null,
+      sort: sort || null,
+      indices
+    });
   } catch (e) { next(e); }
 });
 
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 3f499cc..b5db9ad 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -425,6 +425,37 @@ test('GET /api/index?sort=bogus falls back to default ordering (no error)', asyn
   assert.ok(r.json.indices.length >= 4);
 });
 
+test('GET /api/index?q=kelly filters indices case-insensitively by slug/name', async () => {
+  const r = await get('/api/index?q=KELLY');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.q, 'KELLY');
+  assert.ok(r.json.indices.length >= 1, 'matches at least one index');
+  for (const idx of r.json.indices) {
+    const hay = (idx.slug + ' ' + idx.name).toLowerCase();
+    assert.ok(hay.includes('kelly'), `${idx.slug} should match kelly`);
+  }
+  const slugs = r.json.indices.map((i) => i.slug);
+  assert.ok(!slugs.includes('birkin-30-togo-neutral'), 'birkin index filtered out');
+});
+
+test('GET /api/index?q=birkin composes with sort=value-desc', async () => {
+  const r = await get('/api/index?q=birkin&sort=value-desc');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.q, 'birkin');
+  assert.equal(r.json.sort, 'value-desc');
+  assert.ok(r.json.indices.length >= 1);
+  for (const idx of r.json.indices) {
+    assert.ok((idx.slug + ' ' + idx.name).toLowerCase().includes('birkin'));
+  }
+});
+
+test('GET /api/index?q=zzznomatch returns empty list (no error)', async () => {
+  const r = await get('/api/index?q=zzznomatch');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.count, 0);
+  assert.deepEqual(r.json.indices, []);
+});
+
 test('GET / renders the console with sort and density controls', async () => {
   const r = await get('/');
   assert.equal(r.status, 200);

← 5b70e7f yolo tick #28: /api/family ?q= filter (slug/name/brand, case  ·  back to Lifestyle Asset Intel  ·  yolo tick #30: /api/sources ?q= filter (slug/name/kind/notes 456f827 →