[object Object]

← back to Lifestyle Asset Intel

yolo tick #17: /api/assets sort param (value/liquidity/comps/newest/slug)

1df0cc4c56be20e31b4fdecf1129cf68c28c9a77 · 2026-05-13 11:47:12 -0700 · Steve Abrams

Files touched

Diff

commit 1df0cc4c56be20e31b4fdecf1129cf68c28c9a77
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 11:47:12 2026 -0700

    yolo tick #17: /api/assets sort param (value/liquidity/comps/newest/slug)
---
 lib/openapi.js      | 12 +++++++++++
 lib/valuation.js    | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 routes/api.js       |  5 +++--
 tests/smoke.test.js | 42 +++++++++++++++++++++++++++++++++++++
 4 files changed, 114 insertions(+), 5 deletions(-)

diff --git a/lib/openapi.js b/lib/openapi.js
index 1eb7f8a..8d6a310 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -306,7 +306,19 @@ function buildSpec({ version, gitSha }) {
     '/api/assets': {
       get: {
         summary: 'List canonical assets with latest snapshot + live liquidity/dts',
+        description:
+          'Optional `q` filter (case-insensitive across slug/brand/family/size/' +
+          'material/color/hardware/construction). Optional `sort` (default ordering ' +
+          'is by brand → family → size → material → color).',
         tags: ['catalog'],
+        parameters: [
+          { name: 'q', in: 'query', schema: { type: 'string' } },
+          { name: 'sort', in: 'query', schema: {
+            type: 'string',
+            enum: ['default', 'value-desc', 'value-asc', 'liquidity-desc',
+                   'comps-desc', 'newest-comp', 'slug']
+          } }
+        ],
         responses: {
           200: { description: 'OK', content: { 'application/json': { schema: {
             type: 'object',
diff --git a/lib/valuation.js b/lib/valuation.js
index f9a4fc9..3673daa 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -299,7 +299,12 @@ async function listAssets(opts) {
   //                            against any of: slug, brand, family,
   //                            size, material, color, hardware,
   //                            construction.
+  // opts.sort (string)      — see LIST_SORTS below. Sort happens in JS
+  //                            post-enrichment so computed columns
+  //                            (liquidity_score, expected_dts) sort
+  //                            against their live values, not seed.
   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) {
@@ -351,7 +356,7 @@ async function listAssets(opts) {
     params
   );
 
-  return rows.map((r) => {
+  const enriched = rows.map((r) => {
     const stats = {
       n_comps: r.n_comps || 0,
       newest_comp: r.newest_comp,
@@ -360,8 +365,10 @@ async function listAssets(opts) {
     };
     const liquidity = computeLiquidity(stats);
     const expected_dts = computeExpectedDts(liquidity);
-    // Strip the helper columns + replace with live values.
-    const { n_comps, newest_comp, avg_gross, spread,
+    // Strip the helper columns + replace with live values. We keep
+    // newest_comp inline as a sort key — exposed under that name so
+    // sort=newest-comp can use it without a second SQL hit.
+    const { n_comps, avg_gross, spread,
             seed_liquidity_score, seed_expected_dts, ...rest } = r;
     return {
       ...rest,
@@ -369,6 +376,53 @@ async function listAssets(opts) {
       expected_dts
     };
   });
+
+  return applyListSort(enriched, sort);
+}
+
+// applyListSort — stable, in-place-safe sort over the enriched list.
+// Sort keys live here (not SQL) so computed columns (liquidity_score,
+// expected_dts) sort against live values, not the v0 seed constants.
+// Unknown sort values fall through to 'default'.
+const LIST_SORTS = new Set([
+  'default', 'value-desc', 'value-asc', 'liquidity-desc',
+  'comps-desc', 'newest-comp', 'slug'
+]);
+function applyListSort(rows, sort) {
+  if (!sort || !LIST_SORTS.has(sort) || sort === 'default') return rows;
+  const sorted = rows.slice();
+  const cmpNullsLast = (av, bv, desc) => {
+    const an = av == null, bn = bv == null;
+    if (an && bn) return 0;
+    if (an) return 1;
+    if (bn) return -1;
+    return desc ? (bv - av) : (av - bv);
+  };
+  switch (sort) {
+    case 'value-desc':
+      sorted.sort((a, b) => cmpNullsLast(numberOrNull(a.q50), numberOrNull(b.q50), true));
+      break;
+    case 'value-asc':
+      sorted.sort((a, b) => cmpNullsLast(numberOrNull(a.q50), numberOrNull(b.q50), false));
+      break;
+    case 'liquidity-desc':
+      sorted.sort((a, b) => cmpNullsLast(a.liquidity_score, b.liquidity_score, true));
+      break;
+    case 'comps-desc':
+      sorted.sort((a, b) => cmpNullsLast(a.comp_count, b.comp_count, true));
+      break;
+    case 'newest-comp':
+      sorted.sort((a, b) => {
+        const at = a.newest_comp ? new Date(a.newest_comp).getTime() : null;
+        const bt = b.newest_comp ? new Date(b.newest_comp).getTime() : null;
+        return cmpNullsLast(at, bt, true);
+      });
+      break;
+    case 'slug':
+      sorted.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
+      break;
+  }
+  return sorted;
 }
 
 // computeLiquidity — pure function combining comp depth, recency, and
diff --git a/routes/api.js b/routes/api.js
index 21dfb8a..01ec3ad 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -130,8 +130,9 @@ router.get('/sources', setCache(PUB_300), async (req, res, next) => {
 router.get('/assets', setCache(PUB_60), async (req, res, next) => {
   try {
     const q = typeof req.query.q === 'string' ? req.query.q : '';
-    const rows = await listAssets({ q });
-    res.json({ count: rows.length, q: q || null, assets: rows });
+    const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
+    const rows = await listAssets({ q, sort });
+    res.json({ count: rows.length, q: q || null, sort: sort || null, assets: rows });
   } catch (e) { next(e); }
 });
 
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index ba68505..668d688 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -91,6 +91,48 @@ test('GET /api/assets?q=clemence filters by material', async () => {
   }
 });
 
+test('GET /api/assets?sort=value-desc orders by q50 descending', async () => {
+  const r = await get('/api/assets?sort=value-desc');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.sort, 'value-desc');
+  assert.ok(r.json.assets.length >= 2);
+  const q50s = r.json.assets.map((a) => a.q50).filter((v) => v != null);
+  for (let i = 1; i < q50s.length; i++) {
+    assert.ok(Number(q50s[i - 1]) >= Number(q50s[i]),
+      `value-desc broken at idx ${i}: ${q50s[i - 1]} < ${q50s[i]}`);
+  }
+});
+
+test('GET /api/assets?sort=slug orders alphabetically by slug', async () => {
+  const r = await get('/api/assets?sort=slug');
+  assert.equal(r.status, 200);
+  const slugs = r.json.assets.map((a) => a.slug);
+  for (let i = 1; i < slugs.length; i++) {
+    assert.ok(slugs[i - 1].localeCompare(slugs[i]) <= 0,
+      `slug sort broken at idx ${i}: ${slugs[i - 1]} > ${slugs[i]}`);
+  }
+});
+
+test('GET /api/assets?sort=value-desc&q=birkin sorts within filtered set', async () => {
+  const r = await get('/api/assets?sort=value-desc&q=birkin');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.q, 'birkin');
+  assert.equal(r.json.sort, 'value-desc');
+  for (const a of r.json.assets) {
+    assert.equal(a.family_name, 'Birkin');
+  }
+  const q50s = r.json.assets.map((a) => a.q50).filter((v) => v != null);
+  for (let i = 1; i < q50s.length; i++) {
+    assert.ok(Number(q50s[i - 1]) >= Number(q50s[i]));
+  }
+});
+
+test('GET /api/assets?sort=bogus falls back to default ordering (no error)', async () => {
+  const r = await get('/api/assets?sort=not-a-real-sort');
+  assert.equal(r.status, 200);
+  assert.ok(r.json.assets.length > 0);
+});
+
 test('GET /api/openapi.json is valid OpenAPI 3.1 with all current routes', async () => {
   const r = await get('/api/openapi.json');
   assert.equal(r.status, 200);

← 06d377f snapshot: 6 file(s) changed, ~6 modified  ·  back to Lifestyle Asset Intel  ·  yolo tick #18: /admin/audit HTML view over recent valuation_ ced9794 →