[object Object]

← back to Lifestyle Asset Intel

snapshot: 6 file(s) changed, ~6 modified

06d377fac03d1b223cb35ee9757d621cd3e0ff1f · 2026-05-13 08:57:56 -0700 · Steve

Files touched

Diff

commit 06d377fac03d1b223cb35ee9757d621cd3e0ff1f
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:57:56 2026 -0700

    snapshot: 6 file(s) changed, ~6 modified
---
 .yolo/run-tick.sh    |  7 +++++--
 .yolo/tick-prompt.md | 26 ++++++++++++++++++++++----
 lib/openapi.js       | 32 ++++++++++++++++++++++++++++++++
 lib/valuation.js     | 41 ++++++++++++++++++++++++++++++++++++++++-
 routes/api.js        |  9 ++++++++-
 tests/smoke.test.js  | 16 ++++++++++++++++
 6 files changed, 123 insertions(+), 8 deletions(-)

diff --git a/.yolo/run-tick.sh b/.yolo/run-tick.sh
index 40407e4..dbdc016 100755
--- a/.yolo/run-tick.sh
+++ b/.yolo/run-tick.sh
@@ -33,8 +33,11 @@ trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT
 START=$(date -u +%FT%TZ)
 echo "[$START] tick start" >> "$LOG"
 
-# Pipe the prompt to claude CLI, --print so it exits, --max-turns 12 to cap cost
-/Users/stevestudio2/.local/bin/claude --print --dangerously-skip-permissions --max-turns 12 < "$PROMPT" \
+# Pipe the prompt to claude CLI, --print so it exits, --max-turns 25.
+# Bumped from 12 → 25 after the 2026-05-10 08:14 fire blew its budget
+# mid-implementation. 25 leaves room for (read+plan) + (do work) +
+# (test+restart+commit) + (1-2 extra) without uncapped runaway.
+/Users/stevestudio2/.local/bin/claude --print --dangerously-skip-permissions --max-turns 25 < "$PROMPT" \
   >> "$LOG" 2>&1
 EXIT=$?
 
diff --git a/.yolo/tick-prompt.md b/.yolo/tick-prompt.md
index 668eb0b..ed80a80 100644
--- a/.yolo/tick-prompt.md
+++ b/.yolo/tick-prompt.md
@@ -43,11 +43,29 @@ TESTS:     `PGDATABASE=lifestyle_asset_intel npm test`  (currently 44/44 green)
 - Add an /admin/audit HTML view over /api/audit/recent
 - Methodology version bump trigger when breakdown weights change
 
+## Hard turn budget — BUDGET YOUR EFFORT
+
+You have 25 turns total. Burn-down plan:
+
+  turns 1-3   : read git log, ROADMAP, decide ONE small target
+  turns 4-15  : implement (read files, edit, write)
+  turns 16-19 : run npm test, restart pm2 if needed, fix small breakage
+  turns 20-22 : git add + git commit
+  turns 23-24 : log status, exit
+  turn 25     : EMERGENCY ONLY — already over budget, abort cleanly
+
+If you reach turn 18 without a working diff, REVERT and pick a smaller
+target. Do not keep digging.
+
+If tests are failing on entry to turn 16, revert your edits with
+`git checkout -- <files-you-edited>` (NEVER `git clean -fd` on tracked
+files). Better to ship nothing than to ship red. Other yolo ticks will
+pick the slack.
+
 ## Exit conditions
 
-- Tests fail and you can't fix in ≤6 turns → revert, log, exit.
-- Project in broken state → fix-first, no new features.
-- ≥8 turns elapsed → wrap up, commit if green, exit.
-- Done with the tick → exit cleanly so launchd can fire again next interval.
+- Tests fail after your changes and < 5 turns left → revert, log, exit.
+- Project in broken state on entry → fix-first only, no new features.
+- Done with one logical commit → exit cleanly so launchd can fire next.
 
 KEEP IT SMALL. Many small ticks > one large stuck tick.
diff --git a/lib/openapi.js b/lib/openapi.js
index d66b7e5..1eb7f8a 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -334,6 +334,38 @@ function buildSpec({ version, gitSha }) {
         }
       }
     },
+    '/api/family': {
+      get: {
+        summary: 'List every model family with cohort summary',
+        description:
+          'Returns one row per model family: brand identity, asset_count, ' +
+          'total_comps_24m (transactions in trailing 24 months), and avg_q50 ' +
+          '(unweighted mean of latest snapshot Q50 across the cohort). ' +
+          'Ordered by brand name, then family name.',
+        tags: ['catalog'],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: {
+            type: 'object',
+            properties: {
+              count: { type: 'integer' },
+              families: { type: 'array', items: {
+                type: 'object',
+                properties: {
+                  slug: { type: 'string' }, name: { type: 'string' },
+                  brand: {
+                    type: 'object',
+                    properties: { slug: { type: 'string' }, name: { type: 'string' } }
+                  },
+                  asset_count: { type: 'integer' },
+                  total_comps_24m: { type: 'integer' },
+                  avg_q50: { type: 'number', nullable: true }
+                }
+              } }
+            }
+          } } } }
+        }
+      }
+    },
     '/api/family/{slug}': {
       get: {
         summary: 'Cohort detail for a model family',
diff --git a/lib/valuation.js b/lib/valuation.js
index bee13f8..f9a4fc9 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -413,6 +413,45 @@ function computeExpectedDts(liquidity) {
   return Math.round(clamp(60 - l * 50, 7, 120));
 }
 
+// listFamilies() — index of every model family with a one-row cohort
+// summary (asset_count, total comps in trailing 24m, avg Q50). Powers
+// 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() {
+  const rows = await many(
+    `SELECT mf.slug, mf.name,
+            b.slug AS brand_slug, b.name AS brand_name,
+            COUNT(DISTINCT ca.id)::int AS asset_count,
+            COALESCE(SUM(stats.n_comps), 0)::int AS total_comps_24m,
+            AVG(vs.q50)::numeric AS avg_q50
+       FROM model_families mf
+       JOIN brands b ON b.id = mf.brand_id
+  LEFT JOIN canonical_assets ca ON ca.model_family_id = mf.id
+  LEFT JOIN LATERAL (
+            SELECT q50 FROM valuation_snapshots vs
+             WHERE vs.canonical_asset_id = ca.id
+             ORDER BY vs.as_of DESC LIMIT 1
+       ) vs ON true
+  LEFT JOIN LATERAL (
+            SELECT COUNT(*)::int AS n_comps
+              FROM transactions t
+             WHERE t.canonical_asset_id = ca.id
+               AND t.transacted_at > now() - interval '24 months'
+       ) stats ON true
+   GROUP BY mf.slug, mf.name, b.slug, b.name
+   ORDER BY b.name, mf.name`
+  );
+  return rows.map((r) => ({
+    slug: r.slug,
+    name: r.name,
+    brand: { slug: r.brand_slug, name: r.brand_name },
+    asset_count: r.asset_count,
+    total_comps_24m: r.total_comps_24m,
+    avg_q50: r.avg_q50 != null ? +parseFloat(r.avg_q50).toFixed(0) : null
+  }));
+}
+
 // 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,
@@ -521,6 +560,6 @@ function quantile(sorted, q) {
 }
 
 module.exports = {
-  valueAsset, listAssets, getIndex, getFamily,
+  valueAsset, listAssets, getIndex, getFamily, listFamilies,
   computeBreakdown, computeLiquidity, computeExpectedDts, quantile
 };
diff --git a/routes/api.js b/routes/api.js
index b2deee0..21dfb8a 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, getFamily } = require('../lib/valuation');
+const { valueAsset, listAssets, getIndex, getFamily, listFamilies } = require('../lib/valuation');
 const {
   isValidEmail,
   listHoldings,
@@ -145,6 +145,13 @@ router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+router.get('/family', setCache(PUB_300), async (req, res, next) => {
+  try {
+    const families = await listFamilies();
+    res.json({ count: families.length, families });
+  } catch (e) { next(e); }
+});
+
 router.get('/family/:slug', setCache(PUB_300), 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 1324e36..ba68505 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -179,6 +179,22 @@ test('GET /api/family/birkin returns cohort summary + assets', async () => {
   assert.ok(Array.isArray(r.json.indices));
 });
 
+test('GET /api/family lists every model family with cohort summary', async () => {
+  const r = await get('/api/family');
+  assert.equal(r.status, 200);
+  assert.ok(typeof r.json.count === 'number');
+  assert.ok(Array.isArray(r.json.families));
+  assert.ok(r.json.families.length >= 2, 'at least Birkin + Kelly seeded');
+  const slugs = r.json.families.map((f) => f.slug);
+  assert.ok(slugs.includes('birkin'), 'birkin family present');
+  assert.ok(slugs.includes('kelly'), 'kelly family present');
+  const birkin = r.json.families.find((f) => f.slug === 'birkin');
+  assert.equal(birkin.brand.slug, 'hermes');
+  assert.equal(birkin.brand.name, 'Hermès');
+  assert.ok(birkin.asset_count >= 6);
+  assert.ok(typeof birkin.total_comps_24m === 'number');
+});
+
 test('GET /api/family/<unknown> returns 404', async () => {
   const r = await get('/api/family/nonexistent');
   assert.equal(r.status, 404);

← 447537f yolo tick #16: exotic / special-edition badges + audit-test  ·  back to Lifestyle Asset Intel  ·  yolo tick #17: /api/assets sort param (value/liquidity/comps 1df0cc4 →