[object Object]

← back to Lifestyle Asset Intel

yolo tick #21: /family HTML index page (families list with cohort summary)

3a8e130093f54c66171d028763eeba2ebac8cec9 · 2026-05-13 15:21:11 -0700 · Steve Abrams

Adds GET /family — HTML listing of every model family with one-row
cohort summary cards (brand, family name, avg Q50, asset count, total
comps 24mo). Each card links to /family/:slug detail page.

- routes/console.js — new /family handler ordered before /family/:slug
- views/families.ejs — new view, reuses .grid + .card styles
- views/partials/head.ejs — adds 'Families' nav link beside Console
- tests/smoke.test.js — new smoke test (52/52 green)

Author: steve@designerwallcoverings.com

Files touched

Diff

commit 3a8e130093f54c66171d028763eeba2ebac8cec9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 15:21:11 2026 -0700

    yolo tick #21: /family HTML index page (families list with cohort summary)
    
    Adds GET /family — HTML listing of every model family with one-row
    cohort summary cards (brand, family name, avg Q50, asset count, total
    comps 24mo). Each card links to /family/:slug detail page.
    
    - routes/console.js — new /family handler ordered before /family/:slug
    - views/families.ejs — new view, reuses .grid + .card styles
    - views/partials/head.ejs — adds 'Families' nav link beside Console
    - tests/smoke.test.js — new smoke test (52/52 green)
    
    Author: steve@designerwallcoverings.com
---
 routes/console.js       | 13 ++++++++++++-
 tests/smoke.test.js     |  9 +++++++++
 views/families.ejs      | 36 ++++++++++++++++++++++++++++++++++++
 views/partials/head.ejs |  1 +
 4 files changed, 58 insertions(+), 1 deletion(-)

diff --git a/routes/console.js b/routes/console.js
index 50689cf..ab22632 100644
--- a/routes/console.js
+++ b/routes/console.js
@@ -2,7 +2,7 @@ const express = require('express');
 const fs = require('fs');
 const path = require('path');
 const { marked } = require('marked');
-const { listAssets, valueAsset, getIndex, getFamily } = require('../lib/valuation');
+const { listAssets, valueAsset, getIndex, getFamily, listFamilies } = require('../lib/valuation');
 const {
   isValidEmail,
   listHoldings,
@@ -52,6 +52,17 @@ router.get('/asset/:slug', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+router.get('/family', async (req, res, next) => {
+  try {
+    const families = await listFamilies();
+    res.render('families', {
+      title: 'Model families — lifestyle-asset-intel',
+      families,
+      path: req.path
+    });
+  } catch (e) { next(e); }
+});
+
 router.get('/family/:slug', async (req, res, next) => {
   try {
     const out = await getFamily(req.params.slug);
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 03f9549..557cf54 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -243,6 +243,15 @@ test('GET /api/family/<unknown> returns 404', async () => {
   assert.equal(r.json.error, 'family_not_found');
 });
 
+test('GET /family renders the families index page', async () => {
+  const r = await get('/family');
+  assert.equal(r.status, 200);
+  assert.match(r.text, /Model families/);
+  assert.match(r.text, /Hermès/);
+  assert.match(r.text, /href="\/family\/birkin"/);
+  assert.match(r.text, /href="\/family\/kelly"/);
+});
+
 test('GET /family/kelly renders cohort detail page', async () => {
   const r = await get('/family/kelly');
   assert.equal(r.status, 200);
diff --git a/views/families.ejs b/views/families.ejs
new file mode 100644
index 0000000..620b6ac
--- /dev/null
+++ b/views/families.ejs
@@ -0,0 +1,36 @@
+<%- include('partials/head') %>
+<main class="container">
+  <p class="crumbs"><a href="/">&larr; Console</a></p>
+
+  <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>
+
+  <% if (!families.length) { %>
+    <p class="muted">No families seeded yet — run <code>npm run seed</code>.</p>
+  <% } else { %>
+    <section class="grid" style="--cols: 3;">
+      <% families.forEach(function (f) { %>
+        <a class="card" href="/family/<%= f.slug %>">
+          <div class="card-head">
+            <span class="brand-tag"><%= f.brand.name %></span>
+            <span class="family-tag"><%= f.name %></span>
+          </div>
+          <div class="card-body">
+            <div class="material muted mono"><%= f.brand.slug %> / <%= f.slug %></div>
+            <div class="value-band">
+              <span class="q50">$<%= money(f.avg_q50) %></span>
+            </div>
+            <div class="meta">
+              <span title="Canonical assets in this family">A <%= f.asset_count %></span>
+              <span title="Total comparable transactions in trailing 24 months">n <%= f.total_comps_24m %></span>
+            </div>
+          </div>
+        </a>
+      <% }); %>
+    </section>
+  <% } %>
+</main>
+<%
+function money(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'—'; }
+%>
+<%- include('partials/foot') %>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index ccc2842..5efc637 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -20,6 +20,7 @@
   <a class="brand" href="/">LAI <span class="brand-sub">Lifestyle Asset Intel</span></a>
   <nav class="topnav" id="topnav">
     <a href="/" class="<%= path === '/' ? 'active' : '' %>">Console</a>
+    <a href="/family" class="<%= path === '/family' ? 'active' : '' %>">Families</a>
     <a href="/family/birkin" class="<%= path === '/family/birkin' ? 'active' : '' %>">Birkin</a>
     <a href="/family/kelly" class="<%= path === '/family/kelly' ? 'active' : '' %>">Kelly</a>
     <a href="/indices/birkin-30-togo-neutral" class="<%= path && path.indexOf('/indices') === 0 ? 'active' : '' %>">Indices</a>

← 39ba614 yolo tick #20: sparkline Y-axis min/max labels + descriptive  ·  back to Lifestyle Asset Intel  ·  yolo tick #22: source-weight-aware comparable_similarity_wei 69ece51 →