[object Object]

← 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

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="/">&larr; 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 →