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