← 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
M routes/console.jsM tests/smoke.test.jsA views/families.ejsM views/partials/head.ejs
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="/">← 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 →