← back to Lifestyle Asset Intel
yolo tick #12: family taxonomy pages (/family/:slug)
41d82139eb2e349490af3f24b7649df635bfabef · 2026-05-10 00:32:37 -0700 · Steve Abrams
Cohort-level discovery surface, symmetric to /indices/:slug. Each
Hermès model family becomes a first-class page with summary stats,
asset grid, and related indices.
lib/valuation.getFamily(slug) joins model_families + brands, reuses
listAssets() enrichment via post-filter, pulls related indices via
slug-substring match (v0 heuristic), and computes a 4-key summary:
asset_count, avg_q50, total_comps (24mo), avg_liquidity
API: GET /api/family/{slug} → FamilyResponse (Cache-Control: public,
max-age=300). 404s on unknown slugs.
Console: GET /family/{slug} → views/family.ejs with breadcrumb, KPI
cohort block, related-indices list, 4-column asset grid.
asset.ejs h1 links family name to /family/{slug} so navigation closes
the loop. Top-level "Birkin" and "Kelly" nav entries with active-
state highlighting. OpenAPI 3.1 spec gains the path + FamilyResponse
schema.
Live shape (Birkin): 9 assets, $24,367 avg Q50, 26 trailing-24mo
comps, 0.615 avg liquidity, 2 related indices.
44/44 tests green: 3 new (api JSON, 404, HTML).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M lib/openapi.jsM lib/valuation.jsM routes/api.jsM routes/console.jsM tests/smoke.test.jsM views/asset.ejsA views/family.ejsM views/partials/head.ejs
Diff
commit 41d82139eb2e349490af3f24b7649df635bfabef
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 10 00:32:37 2026 -0700
yolo tick #12: family taxonomy pages (/family/:slug)
Cohort-level discovery surface, symmetric to /indices/:slug. Each
Hermès model family becomes a first-class page with summary stats,
asset grid, and related indices.
lib/valuation.getFamily(slug) joins model_families + brands, reuses
listAssets() enrichment via post-filter, pulls related indices via
slug-substring match (v0 heuristic), and computes a 4-key summary:
asset_count, avg_q50, total_comps (24mo), avg_liquidity
API: GET /api/family/{slug} → FamilyResponse (Cache-Control: public,
max-age=300). 404s on unknown slugs.
Console: GET /family/{slug} → views/family.ejs with breadcrumb, KPI
cohort block, related-indices list, 4-column asset grid.
asset.ejs h1 links family name to /family/{slug} so navigation closes
the loop. Top-level "Birkin" and "Kelly" nav entries with active-
state highlighting. OpenAPI 3.1 spec gains the path + FamilyResponse
schema.
Live shape (Birkin): 9 assets, $24,367 avg Q50, 26 trailing-24mo
comps, 0.615 avg liquidity, 2 related indices.
44/44 tests green: 3 new (api JSON, 404, HTML).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/openapi.js | 50 ++++++++++++++++++++++++++++++++++++
lib/valuation.js | 57 ++++++++++++++++++++++++++++++++++++++++-
routes/api.js | 10 +++++++-
routes/console.js | 18 ++++++++++++-
tests/smoke.test.js | 29 +++++++++++++++++++++
views/asset.ejs | 6 ++++-
views/family.ejs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++
views/partials/head.ejs | 2 ++
8 files changed, 235 insertions(+), 4 deletions(-)
diff --git a/lib/openapi.js b/lib/openapi.js
index da4d365..d66b7e5 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -180,6 +180,37 @@ function buildSpec({ version, gitSha }) {
change_pct: { type: 'number', nullable: true }
}
},
+ FamilyResponse: {
+ type: 'object',
+ properties: {
+ family: {
+ type: 'object',
+ properties: { slug: { type: 'string' }, name: { type: 'string' } }
+ },
+ brand: {
+ type: 'object',
+ properties: { slug: { type: 'string' }, name: { type: 'string' } }
+ },
+ summary: {
+ type: 'object',
+ properties: {
+ asset_count: { type: 'integer' },
+ avg_q50: { type: 'number', nullable: true },
+ total_comps: { type: 'integer' },
+ avg_liquidity: { type: 'number', nullable: true }
+ }
+ },
+ assets: { type: 'array', items: { $ref: '#/components/schemas/CanonicalAsset' } },
+ indices: { type: 'array', items: {
+ type: 'object',
+ properties: {
+ slug: { type: 'string' }, name: { type: 'string' },
+ definition: { type: 'object', additionalProperties: true },
+ methodology_version: { type: 'string' }
+ }
+ } }
+ }
+ },
IndexResponse: {
type: 'object',
properties: {
@@ -303,6 +334,25 @@ function buildSpec({ version, gitSha }) {
}
}
},
+ '/api/family/{slug}': {
+ get: {
+ summary: 'Cohort detail for a model family',
+ description:
+ 'Returns the family + brand identity, a cohort summary block ' +
+ '(asset_count, avg_q50, total_comps, avg_liquidity), the list of ' +
+ 'canonical assets in the family with their live snapshots, and ' +
+ 'related indices.',
+ tags: ['catalog'],
+ parameters: [{
+ name: 'slug', in: 'path', required: true,
+ schema: { type: 'string' }, example: 'birkin'
+ }],
+ responses: {
+ 200: { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/FamilyResponse' } } } },
+ 404: { description: 'Family not found' }
+ }
+ }
+ },
'/api/index/{slug}': {
get: {
summary: 'Benchmark index time series',
diff --git a/lib/valuation.js b/lib/valuation.js
index 02bae3f..d3f607a 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -413,6 +413,61 @@ function computeExpectedDts(liquidity) {
return Math.round(clamp(60 - l * 50, 7, 120));
}
+// getFamily(slug) — cohort detail. Returns the family + brand + the
+// list of canonical assets (with their live snapshot values) plus a
+// summary block (count, weighted-avg Q50, total comps in trailing 24m,
+// liquidity histogram). Returns null when the slug is unknown.
+async function getFamily(slug) {
+ const fam = await one(
+ `SELECT mf.id, mf.name, mf.slug,
+ b.id AS brand_id, b.name AS brand_name, b.slug AS brand_slug
+ FROM model_families mf
+ JOIN brands b ON b.id = mf.brand_id
+ WHERE mf.slug = $1`,
+ [slug]
+ );
+ if (!fam) return null;
+
+ // Reuse listAssets's enrichment by SQL-filtering on family slug. We
+ // can't pass a structured filter to listAssets today (it only takes
+ // q), so call it then post-filter — fine for v0 catalog size.
+ const all = await listAssets();
+ const assets = all.filter((a) => a.family_slug === fam.slug && a.brand_slug === fam.brand_slug);
+
+ // Find indices whose definition references this family. Today
+ // index definitions store filters like {"size":"30","material":"Togo",
+ // "colors":[…],"region":"US"}; family is implicit in the assets the
+ // definition selects. v0: match on slug substring (e.g. 'birkin-30-…').
+ const indices = await many(
+ `SELECT slug, name, definition, methodology_version
+ FROM indices
+ WHERE slug LIKE $1
+ ORDER BY slug`,
+ [`%${fam.slug}%`]
+ );
+
+ // Cohort summary
+ const q50s = assets.map((a) => numberOrNull(a.q50)).filter((x) => x !== null);
+ const compCounts = assets.map((a) => a.comp_count || 0);
+ const liquidities = assets.map((a) => numberOrNull(a.liquidity_score)).filter((x) => x !== null);
+ const summary = {
+ asset_count: assets.length,
+ avg_q50: q50s.length ? +(q50s.reduce((a, b) => a + b, 0) / q50s.length).toFixed(0) : null,
+ total_comps: compCounts.reduce((a, b) => a + b, 0),
+ avg_liquidity: liquidities.length
+ ? +(liquidities.reduce((a, b) => a + b, 0) / liquidities.length).toFixed(3)
+ : null
+ };
+
+ return {
+ family: { slug: fam.slug, name: fam.name },
+ brand: { slug: fam.brand_slug, name: fam.brand_name },
+ summary,
+ assets,
+ indices
+ };
+}
+
async function getIndex(slug) {
const idx = await one(`SELECT * FROM indices WHERE slug = $1`, [slug]);
if (!idx) return null;
@@ -466,6 +521,6 @@ function quantile(sorted, q) {
}
module.exports = {
- valueAsset, listAssets, getIndex,
+ valueAsset, listAssets, getIndex, getFamily,
computeBreakdown, computeLiquidity, computeExpectedDts, quantile
};
diff --git a/routes/api.js b/routes/api.js
index d7967cf..b2deee0 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -3,7 +3,7 @@ const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { pool } = require('../lib/db');
-const { valueAsset, listAssets, getIndex } = require('../lib/valuation');
+const { valueAsset, listAssets, getIndex, getFamily } = require('../lib/valuation');
const {
isValidEmail,
listHoldings,
@@ -145,6 +145,14 @@ router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
} catch (e) { next(e); }
});
+router.get('/family/:slug', setCache(PUB_300), async (req, res, next) => {
+ try {
+ const out = await getFamily(req.params.slug);
+ if (!out) return res.status(404).json({ error: 'family_not_found' });
+ res.json(out);
+ } catch (e) { next(e); }
+});
+
router.get('/index/:slug', setCache(PUB_300), async (req, res, next) => {
try {
const out = await getIndex(req.params.slug);
diff --git a/routes/console.js b/routes/console.js
index e0cec62..442578b 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 } = require('../lib/valuation');
+const { listAssets, valueAsset, getIndex, getFamily } = require('../lib/valuation');
const {
isValidEmail,
listHoldings,
@@ -51,6 +51,22 @@ router.get('/asset/:slug', async (req, res, next) => {
} catch (e) { next(e); }
});
+router.get('/family/:slug', async (req, res, next) => {
+ try {
+ const out = await getFamily(req.params.slug);
+ if (!out) return res.status(404).render('error', {
+ title: 'Family not found',
+ message: `No model family with slug "${req.params.slug}"`,
+ path: req.path
+ });
+ res.render('family', {
+ title: `${out.brand.name} ${out.family.name}`,
+ out,
+ path: req.path
+ });
+ } catch (e) { next(e); }
+});
+
router.get('/indices/:slug', async (req, res, next) => {
try {
const out = await getIndex(req.params.slug);
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index e171c52..1324e36 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -165,6 +165,35 @@ test('GET /api/valuation/<unknown-slug> returns 404 with shape', async () => {
assert.equal(r.json.error, 'asset_not_found');
});
+test('GET /api/family/birkin returns cohort summary + assets', async () => {
+ const r = await get('/api/family/birkin');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.family.slug, 'birkin');
+ assert.equal(r.json.brand.slug, 'hermes');
+ assert.ok(r.json.summary.asset_count >= 6, 'multiple Birkin canonical assets');
+ assert.ok(typeof r.json.summary.avg_q50 === 'number');
+ assert.ok(Array.isArray(r.json.assets));
+ for (const a of r.json.assets) {
+ assert.equal(a.family_slug, 'birkin');
+ }
+ assert.ok(Array.isArray(r.json.indices));
+});
+
+test('GET /api/family/<unknown> returns 404', async () => {
+ const r = await get('/api/family/nonexistent');
+ assert.equal(r.status, 404);
+ assert.equal(r.json.error, 'family_not_found');
+});
+
+test('GET /family/kelly renders cohort detail page', async () => {
+ const r = await get('/family/kelly');
+ assert.equal(r.status, 200);
+ assert.match(r.text, /Cohort summary/);
+ assert.match(r.text, /Hermès/);
+ assert.match(r.text, /Kelly/);
+ assert.match(r.text, /Assets in family/);
+});
+
test('GET /api/index/birkin-30-togo-neutral returns time series', async () => {
const r = await get('/api/index/birkin-30-togo-neutral');
assert.equal(r.status, 200);
diff --git a/views/asset.ejs b/views/asset.ejs
index 08d3f48..dc56fbd 100644
--- a/views/asset.ejs
+++ b/views/asset.ejs
@@ -1,7 +1,11 @@
<%- include('partials/head') %>
<main class="container">
<p class="crumbs"><a href="/">← Console</a></p>
- <h1><%= data.asset.brand %> <%= data.asset.family %> <%= data.asset.size || '' %></h1>
+ <h1>
+ <%= data.asset.brand %>
+ <a href="/family/<%= (data.asset.family || '').toLowerCase() %>"><%= data.asset.family %></a>
+ <%= data.asset.size || '' %>
+ </h1>
<p class="muted">
<%= data.asset.material || '—' %> · <%= data.asset.color || '—' %> · <%= data.asset.hardware || '—' %>
· <%= data.asset.construction || '—' %> · <%= data.asset.region %>
diff --git a/views/family.ejs b/views/family.ejs
new file mode 100644
index 0000000..c779fad
--- /dev/null
+++ b/views/family.ejs
@@ -0,0 +1,67 @@
+<%- include('partials/head') %>
+<main class="container">
+ <p class="crumbs"><a href="/">← Console</a></p>
+
+ <h1><%= out.brand.name %> <%= out.family.name %></h1>
+ <p class="muted mono"><%= out.brand.slug %> / <%= out.family.slug %></p>
+
+ <section class="family-summary">
+ <h2>Cohort summary</h2>
+ <ul class="kpis">
+ <li><span class="lbl">Assets</span><span class="val"><%= out.summary.asset_count %></span></li>
+ <li><span class="lbl">Avg Q50</span><span class="val">$<%= money(out.summary.avg_q50) %></span></li>
+ <li><span class="lbl">Total comps (24mo)</span><span class="val"><%= out.summary.total_comps %></span></li>
+ <li><span class="lbl">Avg liquidity</span><span class="val"><%= pct(out.summary.avg_liquidity) %></span></li>
+ </ul>
+ </section>
+
+ <% if (out.indices && out.indices.length) { %>
+ <section class="family-indices">
+ <h2>Related indices</h2>
+ <ul class="related-indices">
+ <% out.indices.forEach(function (i) { %>
+ <li>
+ <a href="/indices/<%= i.slug %>"><%= i.name %></a>
+ <span class="muted mono"> · <%= i.slug %> · <%= i.methodology_version %></span>
+ </li>
+ <% }); %>
+ </ul>
+ </section>
+ <% } %>
+
+ <section class="family-grid">
+ <h2>Assets in family (<%= out.assets.length %>)</h2>
+ <% if (!out.assets.length) { %>
+ <p class="muted">No assets in this family yet.</p>
+ <% } else { %>
+ <div class="grid" style="--cols: 4;">
+ <% out.assets.forEach(function (a) { %>
+ <a class="card" href="/asset/<%= a.slug %>">
+ <div class="card-head">
+ <span class="brand-tag"><%= a.brand_name %></span>
+ <span class="family-tag"><%= a.family_name %> <%= a.size || '' %></span>
+ </div>
+ <div class="card-body">
+ <div class="material"><%= a.material || '—' %><% if (a.color) { %> · <%= a.color %><% } %><% if (a.hardware) { %> · <%= a.hardware %><% } %></div>
+ <div class="value-band">
+ <span class="q10">$<%= money(a.q10) %></span>
+ <span class="q50">$<%= money(a.q50) %></span>
+ <span class="q90">$<%= money(a.q90) %></span>
+ </div>
+ <div class="meta">
+ <span title="Liquidity score">L <%= pct(a.liquidity_score) %></span>
+ <span title="Confidence">C <%= pct(a.confidence) %></span>
+ <span title="Comp count">n <%= a.comp_count || 0 %></span>
+ </div>
+ </div>
+ </a>
+ <% }); %>
+ </div>
+ <% } %>
+ </section>
+</main>
+<%
+function money(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'—'; }
+function pct(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?Math.round(n*100)+'%':'—'; }
+%>
+<%- include('partials/foot') %>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 0219e65..ccc2842 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -20,6 +20,8 @@
<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/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>
<a href="/portfolio" class="<%= path && path.indexOf('/portfolio') === 0 ? 'active' : '' %>">Portfolio</a>
<a href="/methodology" class="<%= path === '/methodology' ? 'active' : '' %>">Methodology</a>
← 25a6082 yolo tick #11: per-route Cache-Control discipline
·
back to Lifestyle Asset Intel
·
yolo tick #13: real Sotheby's auction-result data ingest d9aa2ad →