[object Object]

← back to Lifestyle Asset Intel

yolo tick #26: /api/index list endpoint with sort + latest-point summary

a2a7ff07a545adf40518ed18504bef5546a792e2 · 2026-05-13 22:51:33 -0700 · Steve Abrams

Returns one row per benchmark index: identity (slug/name/definition/
methodology_version), point_count, first_as_of, latest_as_of,
latest_value, latest_change_pct. Single SQL pass with two lateral
subqueries so it stays cheap as indices and index_points grow.

Sort modes: slug (default), name, value-desc, value-asc, change-desc,
change-asc, points-desc, recent-desc. Unknown sort values fall back to
default ordering instead of erroring.

OpenAPI 3.1 spec updated. Three new smoke tests cover the list shape,
value-desc ordering with nulls-last, and bogus-sort fallback.
58/58 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a2a7ff07a545adf40518ed18504bef5546a792e2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 22:51:33 2026 -0700

    yolo tick #26: /api/index list endpoint with sort + latest-point summary
    
    Returns one row per benchmark index: identity (slug/name/definition/
    methodology_version), point_count, first_as_of, latest_as_of,
    latest_value, latest_change_pct. Single SQL pass with two lateral
    subqueries so it stays cheap as indices and index_points grow.
    
    Sort modes: slug (default), name, value-desc, value-asc, change-desc,
    change-asc, points-desc, recent-desc. Unknown sort values fall back to
    default ordering instead of erroring.
    
    OpenAPI 3.1 spec updated. Three new smoke tests cover the list shape,
    value-desc ordering with nulls-last, and bogus-sort fallback.
    58/58 tests pass.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/openapi.js      | 40 ++++++++++++++++++++++
 lib/valuation.js    | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 routes/api.js       | 10 +++++-
 tests/smoke.test.js | 41 +++++++++++++++++++++-
 4 files changed, 187 insertions(+), 3 deletions(-)

diff --git a/lib/openapi.js b/lib/openapi.js
index 8d6a310..3ca66f8 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -397,6 +397,46 @@ function buildSpec({ version, gitSha }) {
         }
       }
     },
+    '/api/index': {
+      get: {
+        summary: 'List every benchmark index with latest point summary',
+        description:
+          'Returns one row per benchmark index: slug, name, definition, ' +
+          'methodology_version, point_count, first_as_of, plus the latest ' +
+          'point (latest_as_of, latest_value, latest_change_pct). Default ' +
+          'order is by slug. Pass ?sort= to switch — supported modes: ' +
+          'slug, name, value-desc, value-asc, change-desc, change-asc, ' +
+          'points-desc, recent-desc. Unknown sort values fall back to ' +
+          'default ordering.',
+        tags: ['indices'],
+        parameters: [{
+          name: 'sort', in: 'query', required: false,
+          schema: { type: 'string' }, example: 'value-desc'
+        }],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: {
+            type: 'object',
+            properties: {
+              count: { type: 'integer' },
+              sort: { type: 'string', nullable: true },
+              indices: { type: 'array', items: {
+                type: 'object',
+                properties: {
+                  slug: { type: 'string' }, name: { type: 'string' },
+                  definition: { type: 'object', additionalProperties: true },
+                  methodology_version: { type: 'string' },
+                  point_count: { type: 'integer' },
+                  first_as_of: { type: 'string', format: 'date', nullable: true },
+                  latest_as_of: { type: 'string', format: 'date', nullable: true },
+                  latest_value: { type: 'number', nullable: true },
+                  latest_change_pct: { type: 'number', nullable: true }
+                }
+              } }
+            }
+          } } } }
+        }
+      }
+    },
     '/api/index/{slug}': {
       get: {
         summary: 'Benchmark index time series',
diff --git a/lib/valuation.js b/lib/valuation.js
index 3fff123..c584ce0 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -665,6 +665,103 @@ async function getIndex(slug) {
   };
 }
 
+// listIndices() — index of every benchmark index with a one-row
+// summary: identity (slug/name/methodology_version/definition), point
+// counts, and the latest point's value + as_of + change_pct.
+// Single SQL pass with a lateral subquery for the latest point, so this
+// stays cheap as the indices table grows.
+//
+// opts.sort (string) — see INDEX_SORTS. Default ordering is by slug.
+async function listIndices(opts) {
+  const sort = opts && typeof opts.sort === 'string' ? opts.sort.trim() : '';
+  const rows = await many(
+    `SELECT i.slug, i.name, i.definition, i.methodology_version,
+            COALESCE(stats.point_count, 0)::int AS point_count,
+            stats.first_as_of, latest.as_of AS latest_as_of,
+            latest.value AS latest_value,
+            latest.change_pct AS latest_change_pct
+       FROM indices i
+  LEFT JOIN LATERAL (
+            SELECT COUNT(*)::int AS point_count,
+                   MIN(as_of) AS first_as_of
+              FROM index_points ip
+             WHERE ip.index_id = i.id
+       ) stats ON true
+  LEFT JOIN LATERAL (
+            SELECT as_of, value, change_pct
+              FROM index_points ip
+             WHERE ip.index_id = i.id
+             ORDER BY as_of DESC
+             LIMIT 1
+       ) latest ON true
+   ORDER BY i.slug`
+  );
+  const enriched = rows.map((r) => ({
+    slug: r.slug,
+    name: r.name,
+    definition: r.definition,
+    methodology_version: r.methodology_version,
+    point_count: r.point_count,
+    first_as_of: r.first_as_of,
+    latest_as_of: r.latest_as_of,
+    latest_value: r.latest_value != null ? +parseFloat(r.latest_value).toFixed(2) : null,
+    latest_change_pct: r.latest_change_pct != null ? +parseFloat(r.latest_change_pct).toFixed(4) : null
+  }));
+  return applyIndexSort(enriched, sort);
+}
+
+// Supported sort modes for /api/index. Default (slug ASC) matches the
+// SQL ORDER BY above; other modes are applied in JS post-aggregation so
+// computed columns sort against what callers actually see.
+const INDEX_SORTS = new Set([
+  'default', 'slug', 'name',
+  'value-desc', 'value-asc',
+  'change-desc', 'change-asc',
+  'points-desc', 'recent-desc'
+]);
+function applyIndexSort(rows, sort) {
+  if (!sort || !INDEX_SORTS.has(sort) || sort === 'default') return rows;
+  const sorted = rows.slice();
+  const cmpNullsLast = (av, bv, desc) => {
+    const an = av == null, bn = bv == null;
+    if (an && bn) return 0;
+    if (an) return 1;
+    if (bn) return -1;
+    return desc ? (bv - av) : (av - bv);
+  };
+  switch (sort) {
+    case 'slug':
+      sorted.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
+      break;
+    case 'name':
+      sorted.sort((a, b) => String(a.name).localeCompare(String(b.name)));
+      break;
+    case 'value-desc':
+      sorted.sort((a, b) => cmpNullsLast(a.latest_value, b.latest_value, true));
+      break;
+    case 'value-asc':
+      sorted.sort((a, b) => cmpNullsLast(a.latest_value, b.latest_value, false));
+      break;
+    case 'change-desc':
+      sorted.sort((a, b) => cmpNullsLast(a.latest_change_pct, b.latest_change_pct, true));
+      break;
+    case 'change-asc':
+      sorted.sort((a, b) => cmpNullsLast(a.latest_change_pct, b.latest_change_pct, false));
+      break;
+    case 'points-desc':
+      sorted.sort((a, b) => cmpNullsLast(a.point_count, b.point_count, true));
+      break;
+    case 'recent-desc':
+      sorted.sort((a, b) => {
+        const ad = a.latest_as_of ? new Date(a.latest_as_of).getTime() : null;
+        const bd = b.latest_as_of ? new Date(b.latest_as_of).getTime() : null;
+        return cmpNullsLast(ad, bd, true);
+      });
+      break;
+  }
+  return sorted;
+}
+
 function numberOrNull(v) {
   if (v === null || v === undefined) return null;
   const n = typeof v === 'string' ? parseFloat(v) : v;
@@ -693,7 +790,7 @@ function quantile(sorted, q) {
 }
 
 module.exports = {
-  valueAsset, listAssets, getIndex, getFamily, listFamilies,
+  valueAsset, listAssets, getIndex, getFamily, listFamilies, listIndices,
   computeBreakdown, computeLiquidity, computeExpectedDts, quantile,
   METHODOLOGY_VERSION
 };
diff --git a/routes/api.js b/routes/api.js
index 2f08be6..7fa5f61 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -4,7 +4,7 @@ const path = require('path');
 const { execSync } = require('child_process');
 const { pool } = require('../lib/db');
 const {
-  valueAsset, listAssets, getIndex, getFamily, listFamilies, METHODOLOGY_VERSION
+  valueAsset, listAssets, getIndex, listIndices, getFamily, listFamilies, METHODOLOGY_VERSION
 } = require('../lib/valuation');
 const {
   isValidEmail,
@@ -164,6 +164,14 @@ router.get('/family/:slug', setCache(PUB_300), async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+router.get('/index', setCache(PUB_300), async (req, res, next) => {
+  try {
+    const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
+    const indices = await listIndices({ sort });
+    res.json({ count: indices.length, sort: sort || null, indices });
+  } 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/tests/smoke.test.js b/tests/smoke.test.js
index e33664c..395039b 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -143,7 +143,7 @@ test('GET /api/openapi.json is valid OpenAPI 3.1 with all current routes', async
   const documented = Object.keys(r.json.paths);
   for (const p of [
     '/api/health', '/api/version', '/api/sources', '/api/assets',
-    '/api/valuation/{slug}', '/api/index/{slug}',
+    '/api/valuation/{slug}', '/api/index', '/api/index/{slug}',
     '/api/portfolio', '/api/portfolio/{id}', '/api/audit/recent'
   ]) {
     assert.ok(documented.includes(p), `OpenAPI missing path ${p}`);
@@ -304,6 +304,45 @@ test('GET /api/index/birkin-30-togo-neutral returns time series', async () => {
   assert.ok(r.json.points.length >= 4);
 });
 
+test('GET /api/index lists every benchmark index with latest-point summary', async () => {
+  const r = await get('/api/index');
+  assert.equal(r.status, 200);
+  assert.ok(typeof r.json.count === 'number');
+  assert.ok(Array.isArray(r.json.indices));
+  assert.ok(r.json.indices.length >= 4, 'at least the four seeded indices');
+  const slugs = r.json.indices.map((i) => i.slug);
+  assert.ok(slugs.includes('birkin-30-togo-neutral'));
+  assert.ok(slugs.includes('kelly-sellier-premium'));
+  const row = r.json.indices.find((i) => i.slug === 'birkin-30-togo-neutral');
+  assert.ok(row.methodology_version);
+  assert.ok(row.point_count >= 4);
+  assert.ok(typeof row.latest_value === 'number');
+  assert.ok(row.latest_as_of, 'latest_as_of is present');
+});
+
+test('GET /api/index?sort=value-desc orders by latest_value descending (nulls last)', async () => {
+  const r = await get('/api/index?sort=value-desc');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.sort, 'value-desc');
+  const vals = r.json.indices.map((i) => i.latest_value);
+  let sawNull = false;
+  for (let i = 0; i < vals.length; i++) {
+    if (vals[i] == null) { sawNull = true; continue; }
+    assert.ok(!sawNull, `non-null latest_value at idx ${i} appeared after a null`);
+    if (i > 0 && vals[i - 1] != null) {
+      assert.ok(vals[i] <= vals[i - 1],
+        `value-desc broken at idx ${i}: ${vals[i - 1]} < ${vals[i]}`);
+    }
+  }
+});
+
+test('GET /api/index?sort=bogus falls back to default ordering (no error)', async () => {
+  const r = await get('/api/index?sort=not-a-real-sort');
+  assert.equal(r.status, 200);
+  assert.ok(Array.isArray(r.json.indices));
+  assert.ok(r.json.indices.length >= 4);
+});
+
 test('GET / renders the console with sort and density controls', async () => {
   const r = await get('/');
   assert.equal(r.status, 200);

← 41093f5 yolo tick #25: family-detail grid gets sort + density + sear  ·  back to Lifestyle Asset Intel  ·  yolo tick #27: /api/sources ?sort= (weight/tier/name/slug/ki c3d5177 →