← 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
M lib/openapi.jsM lib/valuation.jsM routes/api.jsM tests/smoke.test.js
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 →