← back to Lifestyle Asset Intel
yolo tick #24: /api/family ?sort= (assets/comps/value/name/slug, default+UI)
e863c10f818ae005ce1bf84dba25a114973c05af · 2026-05-13 20:31:05 -0700 · Steve Abrams
Files touched
M lib/valuation.jsM routes/api.jsM routes/console.jsM tests/smoke.test.jsM views/families.ejs
Diff
commit e863c10f818ae005ce1bf84dba25a114973c05af
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 20:31:05 2026 -0700
yolo tick #24: /api/family ?sort= (assets/comps/value/name/slug, default+UI)
---
lib/valuation.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++--
routes/api.js | 5 +++--
routes/console.js | 4 +++-
tests/smoke.test.js | 34 ++++++++++++++++++++++++++++++++++
views/families.ejs | 16 ++++++++++++++++
5 files changed, 103 insertions(+), 5 deletions(-)
diff --git a/lib/valuation.js b/lib/valuation.js
index b2974b1..3fff123 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -506,7 +506,10 @@ function computeExpectedDts(liquidity) {
// the /api/family (no slug) endpoint and any "browse by family" UI.
// Single SQL pass — does NOT call listAssets() per family, so this
// stays cheap as the catalog grows.
-async function listFamilies() {
+//
+// opts.sort (string) — see FAMILY_SORTS. Default ordering is brand+name.
+async function listFamilies(opts) {
+ const sort = opts && typeof opts.sort === 'string' ? opts.sort.trim() : '';
const rows = await many(
`SELECT mf.slug, mf.name,
b.slug AS brand_slug, b.name AS brand_name,
@@ -530,7 +533,7 @@ async function listFamilies() {
GROUP BY mf.slug, mf.name, b.slug, b.name
ORDER BY b.name, mf.name`
);
- return rows.map((r) => ({
+ const enriched = rows.map((r) => ({
slug: r.slug,
name: r.name,
brand: { slug: r.brand_slug, name: r.brand_name },
@@ -538,6 +541,48 @@ async function listFamilies() {
total_comps_24m: r.total_comps_24m,
avg_q50: r.avg_q50 != null ? +parseFloat(r.avg_q50).toFixed(0) : null
}));
+ return applyFamilySort(enriched, sort);
+}
+
+// Supported sort modes for /api/family. Default (brand+name) is the SQL
+// ORDER BY above; everything else is applied in JS post-aggregation so
+// computed columns (avg_q50, total_comps_24m) sort against the values
+// the caller actually sees in the response, not raw SQL numerics.
+const FAMILY_SORTS = new Set([
+ 'default', 'name', 'slug',
+ 'assets-desc', 'comps-desc', 'value-desc', 'value-asc'
+]);
+function applyFamilySort(rows, sort) {
+ if (!sort || !FAMILY_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 'name':
+ sorted.sort((a, b) => String(a.name).localeCompare(String(b.name)));
+ break;
+ case 'slug':
+ sorted.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
+ break;
+ case 'assets-desc':
+ sorted.sort((a, b) => cmpNullsLast(a.asset_count, b.asset_count, true));
+ break;
+ case 'comps-desc':
+ sorted.sort((a, b) => cmpNullsLast(a.total_comps_24m, b.total_comps_24m, true));
+ break;
+ case 'value-desc':
+ sorted.sort((a, b) => cmpNullsLast(a.avg_q50, b.avg_q50, true));
+ break;
+ case 'value-asc':
+ sorted.sort((a, b) => cmpNullsLast(a.avg_q50, b.avg_q50, false));
+ break;
+ }
+ return sorted;
}
// getFamily(slug) — cohort detail. Returns the family + brand + the
diff --git a/routes/api.js b/routes/api.js
index c3d867a..2f08be6 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -150,8 +150,9 @@ router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
router.get('/family', setCache(PUB_300), async (req, res, next) => {
try {
- const families = await listFamilies();
- res.json({ count: families.length, families });
+ const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
+ const families = await listFamilies({ sort });
+ res.json({ count: families.length, sort: sort || null, families });
} catch (e) { next(e); }
});
diff --git a/routes/console.js b/routes/console.js
index ab22632..2150608 100644
--- a/routes/console.js
+++ b/routes/console.js
@@ -54,10 +54,12 @@ router.get('/asset/:slug', async (req, res, next) => {
router.get('/family', async (req, res, next) => {
try {
- const families = await listFamilies();
+ const sort = typeof req.query.sort === 'string' ? req.query.sort.trim() : '';
+ const families = await listFamilies({ sort });
res.render('families', {
title: 'Model families — lifestyle-asset-intel',
families,
+ sort,
path: req.path
});
} catch (e) { next(e); }
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index c7dafd4..e33664c 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -244,6 +244,40 @@ test('GET /api/family/<unknown> returns 404', async () => {
assert.equal(r.json.error, 'family_not_found');
});
+test('GET /api/family?sort=assets-desc orders by asset_count descending', async () => {
+ const r = await get('/api/family?sort=assets-desc');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.sort, 'assets-desc');
+ const counts = r.json.families.map((f) => f.asset_count || 0);
+ for (let i = 1; i < counts.length; i++) {
+ assert.ok(counts[i] <= counts[i - 1],
+ `assets-desc sort broken at idx ${i}: ${counts[i - 1]} < ${counts[i]}`);
+ }
+});
+
+test('GET /api/family?sort=value-desc orders by avg_q50 descending (nulls last)', async () => {
+ const r = await get('/api/family?sort=value-desc');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.sort, 'value-desc');
+ const vals = r.json.families.map((f) => f.avg_q50);
+ let sawNull = false;
+ for (let i = 0; i < vals.length; i++) {
+ if (vals[i] == null) { sawNull = true; continue; }
+ assert.ok(!sawNull, `non-null avg_q50 at idx ${i} appeared after a null`);
+ if (i > 0 && vals[i - 1] != null) {
+ assert.ok(vals[i] <= vals[i - 1],
+ `value-desc broken at idx ${i}: ${vals[i - 1]} < ${vals[i]}`);
+ }
+ }
+});
+
+test('GET /api/family?sort=bogus falls back to default ordering (no error)', async () => {
+ const r = await get('/api/family?sort=not-a-real-sort');
+ assert.equal(r.status, 200);
+ assert.ok(Array.isArray(r.json.families));
+ assert.ok(r.json.families.length >= 2);
+});
+
test('GET /family renders the families index page', async () => {
const r = await get('/family');
assert.equal(r.status, 200);
diff --git a/views/families.ejs b/views/families.ejs
index 620b6ac..0fe6f75 100644
--- a/views/families.ejs
+++ b/views/families.ejs
@@ -5,6 +5,22 @@
<h1>Model families</h1>
<p class="muted">Cohort index across every brand + model family in the catalog. Click into a family for its asset roster, related indices, and 24-month comp totals.</p>
+ <form class="controls" method="GET" action="/family" role="region" aria-label="Family sort">
+ <label>Sort
+ <% var s = (typeof sort === 'string' ? sort : '') || ''; %>
+ <select name="sort" onchange="this.form.submit()">
+ <option value="" <%= s === '' ? 'selected' : '' %>>Brand & name (default)</option>
+ <option value="name" <%= s === 'name' ? 'selected' : '' %>>Family name A→Z</option>
+ <option value="slug" <%= s === 'slug' ? 'selected' : '' %>>Slug A→Z</option>
+ <option value="assets-desc"<%= s === 'assets-desc' ? 'selected' : '' %>>Asset count ↓</option>
+ <option value="comps-desc" <%= s === 'comps-desc' ? 'selected' : '' %>>24-month comps ↓</option>
+ <option value="value-desc" <%= s === 'value-desc' ? 'selected' : '' %>>Avg Q50 ↓</option>
+ <option value="value-asc" <%= s === 'value-asc' ? 'selected' : '' %>>Avg Q50 ↑</option>
+ </select>
+ </label>
+ <noscript><button type="submit">Apply</button></noscript>
+ </form>
+
<% if (!families.length) { %>
<p class="muted">No families seeded yet — run <code>npm run seed</code>.</p>
<% } else { %>
← 0e64230 yolo tick #23: source citation links on comp rows
·
back to Lifestyle Asset Intel
·
yolo tick #25: family-detail grid gets sort + density + sear 41093f5 →