[object Object]

← 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

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 &amp; 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 →