[object Object]

← back to Nationalrealestate

M4: deterministic opportunity scorer (5-component percentile model, weight renormalization, dense rank, 3994 regions scored), score-config.json, /api/markets endpoint, markets.html ranking dashboard on adapted CRCP grid (sort select + th-click sort + density slider persisted, thin-data dimming, county/metro toggle)

effc2fde2a48963af6ecfecdbc5789715e57f28d · 2026-07-21 13:01:26 -0700 · Steve Abrams

Files touched

Diff

commit effc2fde2a48963af6ecfecdbc5789715e57f28d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 13:01:26 2026 -0700

    M4: deterministic opportunity scorer (5-component percentile model, weight renormalization, dense rank, 3994 regions scored), score-config.json, /api/markets endpoint, markets.html ranking dashboard on adapted CRCP grid (sort select + th-click sort + density slider persisted, thin-data dimming, county/metro toggle)
---
 public/col-resize.js     | 309 +++++++++++++++++++++++++++++++++++++++++++++++
 public/crcp-grid.css     |  65 ++++++++++
 public/crcp-grid.js      | 247 +++++++++++++++++++++++++++++++++++++
 public/markets.html      | 126 +++++++++++++++++++
 score-config.json        |  15 +++
 src/score/opportunity.ts | 182 ++++++++++++++++++++++++++++
 6 files changed, 944 insertions(+)

diff --git a/public/col-resize.js b/public/col-resize.js
new file mode 100644
index 0000000..66ba0c1
--- /dev/null
+++ b/public/col-resize.js
@@ -0,0 +1,309 @@
+/*!
+ * col-resize.js — spreadsheet-style draggable column resizing for any <table>.
+ * Zero dependencies. Drop-in: <script src="/col-resize.js" defer></script>
+ *
+ * Behaviour:
+ *   • Hover a column's right edge → col-resize cursor → drag to widen/narrow.
+ *   • Only the dragged column changes; the rest hold still (table-layout:fixed).
+ *   • Double-click a handle → auto-fit that column to its content.
+ *   • Widths persist in localStorage, keyed by page + table signature + column,
+ *     so they survive reloads AND JS re-renders of the same table.
+ *   • Auto-enhances tables added later (fetch-rendered grids) via MutationObserver.
+ *
+ * Opt out per table with <table data-no-resize>. Re-scan manually: ColResize.refresh().
+ */
+(function () {
+  'use strict';
+  if (window.__colResizeLoaded) return;
+  window.__colResizeLoaded = true;
+
+  var MIN = 40;                 // px, smallest a column may shrink to
+  var LS = 'cr:';               // localStorage key prefix
+  var HANDLE_W = 8;             // px hit-area of the drag handle
+  var lastResizeUp = 0;         // timestamp of the last resize drag end (sort ignores that click)
+
+  // ---- persistence ----------------------------------------------------------
+  function tableSig(headers) {
+    // Stable id from header text + count — survives re-render & column reorder.
+    var s = headers.map(function (h) { return (h.textContent || '').trim(); }).join('|') + '#' + headers.length;
+    var hash = 0;
+    for (var i = 0; i < s.length; i++) { hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0; }
+    return 't' + (hash >>> 0).toString(36);
+  }
+  function keyFor(sig, idx) { return LS + location.pathname + ':' + sig + ':' + idx; }
+  function save(sig, idx, w) { try { localStorage.setItem(keyFor(sig, idx), String(Math.round(w))); } catch (e) {} }
+  function load(sig, idx) { try { var v = localStorage.getItem(keyFor(sig, idx)); return v ? parseFloat(v) : null; } catch (e) { return null; } }
+  function clear(sig, idx) { try { localStorage.removeItem(keyFor(sig, idx)); } catch (e) {} }
+
+  // ---- header-cell detection ------------------------------------------------
+  function headerCells(table) {
+    var thead = table.tHead;
+    if (thead && thead.rows.length) {
+      var row = thead.rows[thead.rows.length - 1];
+      if (row && row.cells.length) return Array.prototype.slice.call(row.cells);
+    }
+    // No <thead> — treat the first row's cells as headers.
+    var first = table.rows[0];
+    return first ? Array.prototype.slice.call(first.cells) : [];
+  }
+
+  // ---- colgroup: one <col> per column ---------------------------------------
+  function ensureColgroup(table, n) {
+    var cg = table.querySelector('colgroup[data-cr]');
+    if (cg && cg.children.length === n) return cg;
+    if (cg) cg.remove();
+    cg = document.createElement('colgroup');
+    cg.setAttribute('data-cr', '1');
+    for (var i = 0; i < n; i++) cg.appendChild(document.createElement('col'));
+    table.insertBefore(cg, table.firstChild);
+    return cg;
+  }
+
+  // ---- wrap table in a horizontal-scroll container --------------------------
+  function ensureScroll(table) {
+    var p = table.parentElement;
+    if (p && p.classList.contains('cr-scroll')) return p;
+    var wrap = document.createElement('div');
+    wrap.className = 'cr-scroll';
+    p.insertBefore(wrap, table);
+    wrap.appendChild(table);
+    return wrap;
+  }
+
+  // ---- enhance one table ----------------------------------------------------
+  function enhance(table) {
+    if (table.dataset.crDone === '1' || table.hasAttribute('data-no-resize')) return;
+    var headers = headerCells(table);
+    if (headers.length < 2) return;              // nothing meaningful to resize
+    table.dataset.crDone = '1';
+
+    var sig = tableSig(headers);
+    var cols = ensureColgroup(table, headers.length).children;
+    ensureScroll(table);
+
+    // Snapshot current widths → explicit px, THEN switch to fixed layout so the
+    // browser stops redistributing. Saved widths override the snapshot.
+    var measured = headers.map(function (h) { return Math.max(MIN, Math.round(h.getBoundingClientRect().width)); });
+    table.style.tableLayout = 'fixed';
+    table.style.width = 'auto';
+    for (var i = 0; i < headers.length; i++) {
+      var saved = load(sig, i);
+      cols[i].style.width = (saved != null ? Math.max(MIN, saved) : measured[i]) + 'px';
+    }
+
+    // Add a drag handle to each header cell.
+    headers.forEach(function (th, idx) {
+      var cs = getComputedStyle(th);
+      if (cs.position === 'static') th.style.position = 'relative';
+      var handle = document.createElement('span');
+      handle.className = 'cr-handle';
+      handle.setAttribute('role', 'separator');
+      handle.setAttribute('aria-orientation', 'vertical');
+      handle.title = 'Drag to resize • double-click to auto-fit';
+      th.appendChild(handle);
+      wireHandle(handle, table, cols, headers, sig, idx);
+    });
+
+    addSorting(table, headers);
+  }
+
+  // ---- click-a-header-to-sort (asc → desc → original) -----------------------
+  function addSorting(table, headers) {
+    if (table.hasAttribute('data-no-sort')) return;
+    var headerRow = headers[0].parentNode;
+
+    headers.forEach(function (th, idx) {
+      if (th.querySelector('.cr-sort')) return;            // already wired
+      th.classList.add('cr-sortable');
+      var ind = document.createElement('span');
+      ind.className = 'cr-sort';
+      th.appendChild(ind);
+
+      th.addEventListener('click', function (e) {
+        if (e.target.closest && e.target.closest('.cr-handle')) return;   // it was a resize grab
+        if (Date.now() - lastResizeUp < 300) return;                      // a drag just ended
+        var dir = th.dataset.crSort === 'asc' ? 'desc' : th.dataset.crSort === 'desc' ? 'none' : 'asc';
+        sortBy(table, headers, headerRow, idx, dir);
+        headers.forEach(function (o) { o.dataset.crSort = ''; o.querySelector('.cr-sort').textContent = ''; });
+        th.dataset.crSort = dir;
+        th.querySelector('.cr-sort').textContent = dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : '';
+      });
+    });
+  }
+
+  function bodyRows(table, headerRow) {
+    var rows = [];
+    for (var b = 0; b < table.tBodies.length; b++) {
+      var tb = table.tBodies[b];
+      for (var i = 0; i < tb.rows.length; i++) if (tb.rows[i] !== headerRow) rows.push(tb.rows[i]);
+    }
+    return rows;
+  }
+
+  function cellText(row, idx) {
+    var c = row.cells[idx];
+    return c ? (c.textContent || '').trim() : '';
+  }
+  // Parse "$1,234.56", "45%", "-12" → number; non-numeric → NaN.
+  function num(s) {
+    if (!s) return NaN;
+    var n = parseFloat(s.replace(/[^0-9.\-]/g, ''));
+    return isNaN(n) ? NaN : n;
+  }
+  function isNumericColumn(rows, idx) {
+    var seen = 0;
+    for (var i = 0; i < rows.length && seen < 30; i++) {
+      var t = cellText(rows[i], idx);
+      if (!t) continue;
+      seen++;
+      if (isNaN(num(t))) return false;
+    }
+    return seen > 0;
+  }
+
+  function sortBy(table, headers, headerRow, idx, dir) {
+    var rows = bodyRows(table, headerRow);
+    if (!rows.length) return;
+    if (!table.__crOrig) table.__crOrig = rows.slice();   // remember natural order once
+
+    if (dir === 'none') {
+      var order = table.__crOrig.filter(function (r) { return r.parentNode; });
+      var dest0 = rows[0].parentNode;
+      order.forEach(function (r) { dest0.appendChild(r); });
+      return;
+    }
+
+    var numeric = isNumericColumn(rows, idx);
+    var mul = dir === 'asc' ? 1 : -1;
+    // Decorate → sort → undecorate keeps blanks last and the sort stable.
+    rows.map(function (r, i) { return { r: r, i: i, t: cellText(r, idx) }; })
+        .sort(function (a, b) {
+          var ae = a.t === '', be = b.t === '';
+          if (ae && be) return a.i - b.i;
+          if (ae) return 1;                                // blanks always sink
+          if (be) return -1;
+          var cmp;
+          if (numeric) cmp = num(a.t) - num(b.t);
+          else cmp = a.t.localeCompare(b.t, undefined, { numeric: true, sensitivity: 'base' });
+          return cmp !== 0 ? cmp * mul : a.i - b.i;         // stable tiebreak
+        })
+        .forEach(function (o) { o.r.parentNode.appendChild(o.r); });
+  }
+
+  function wireHandle(handle, table, cols, headers, sig, idx) {
+    var startX = 0, startW = 0, dragging = false;
+
+    function down(e) {
+      dragging = true;
+      startX = (e.touches ? e.touches[0].clientX : e.clientX);
+      startW = cols[idx].getBoundingClientRect().width;
+      document.body.classList.add('cr-dragging');
+      window.addEventListener('pointermove', move);
+      window.addEventListener('pointerup', up);
+      window.addEventListener('touchmove', move, { passive: false });
+      window.addEventListener('touchend', up);
+      e.preventDefault();
+      e.stopPropagation();
+    }
+    function move(e) {
+      if (!dragging) return;
+      var x = (e.touches ? e.touches[0].clientX : e.clientX);
+      var w = Math.max(MIN, startW + (x - startX));
+      cols[idx].style.width = w + 'px';
+      if (e.cancelable) e.preventDefault();
+    }
+    function up() {
+      if (!dragging) return;
+      dragging = false;
+      document.body.classList.remove('cr-dragging');
+      window.removeEventListener('pointermove', move);
+      window.removeEventListener('pointerup', up);
+      window.removeEventListener('touchmove', move);
+      window.removeEventListener('touchend', up);
+      lastResizeUp = Date.now();
+      save(sig, idx, cols[idx].getBoundingClientRect().width);
+    }
+    // Double-click → auto-fit this column to its content.
+    function autofit(e) {
+      e.preventDefault(); e.stopPropagation();
+      clear(sig, idx);
+      // Remember every column's current width, let this one go auto, measure, restore.
+      var widths = [];
+      for (var i = 0; i < cols.length; i++) widths[i] = cols[i].style.width;
+      table.style.tableLayout = 'auto';
+      cols[idx].style.width = '';
+      var natural = Math.max(MIN, Math.round(headers[idx].getBoundingClientRect().width));
+      for (var j = 0; j < cols.length; j++) cols[j].style.width = widths[j];
+      cols[idx].style.width = natural + 'px';
+      table.style.tableLayout = 'fixed';
+      save(sig, idx, natural);
+    }
+
+    handle.addEventListener('pointerdown', down);
+    handle.addEventListener('dblclick', autofit);
+    // Fallback for browsers without Pointer Events.
+    if (!window.PointerEvent) handle.addEventListener('touchstart', down, { passive: false });
+  }
+
+  // ---- inject the small stylesheet once -------------------------------------
+  function injectCSS() {
+    if (document.getElementById('cr-style')) return;
+    var css = ''
+      + '.cr-scroll{overflow-x:auto;max-width:100%;}'
+      + '.cr-handle{position:absolute;top:0;right:-' + (HANDLE_W / 2) + 'px;width:' + HANDLE_W + 'px;height:100%;'
+      +   'cursor:col-resize;user-select:none;touch-action:none;z-index:5;}'
+      + '.cr-handle:hover::after,.cr-dragging .cr-handle::after{content:"";position:absolute;top:0;left:50%;'
+      +   'width:2px;height:100%;transform:translateX(-50%);background:currentColor;opacity:.55;}'
+      + 'body.cr-dragging{cursor:col-resize!important;user-select:none!important;}'
+      + 'body.cr-dragging *{cursor:col-resize!important;}'
+      + 'th.cr-sortable{cursor:pointer;}'
+      + 'th.cr-sortable:hover{background:rgba(127,127,127,.12);}'
+      + '.cr-sort{font-size:.85em;opacity:.8;white-space:nowrap;}';
+    var s = document.createElement('style');
+    s.id = 'cr-style';
+    s.textContent = css;
+    document.head.appendChild(s);
+  }
+
+  // ---- scan + observe -------------------------------------------------------
+  function scan(root) {
+    var tables = (root || document).querySelectorAll('table');
+    for (var i = 0; i < tables.length; i++) {
+      try { enhance(tables[i]); } catch (e) { /* never let one bad table break the page */ }
+    }
+  }
+
+  var pending = false;
+  function scheduleScan() {
+    if (pending) return;
+    pending = true;
+    (window.requestAnimationFrame || setTimeout)(function () { pending = false; scan(document); });
+  }
+
+  function start() {
+    injectCSS();
+    scan(document);
+    if (window.MutationObserver) {
+      new MutationObserver(function (muts) {
+        for (var i = 0; i < muts.length; i++) {
+          if (muts[i].addedNodes && muts[i].addedNodes.length) { scheduleScan(); break; }
+        }
+      }).observe(document.body, { childList: true, subtree: true });
+    }
+  }
+
+  window.ColResize = {
+    refresh: function () { scan(document); },
+    reset: function () {                     // wipe all saved widths for this page
+      try {
+        Object.keys(localStorage).forEach(function (k) {
+          if (k.indexOf(LS + location.pathname + ':') === 0) localStorage.removeItem(k);
+        });
+      } catch (e) {}
+      location.reload();
+    }
+  };
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
+  else start();
+})();
diff --git a/public/crcp-grid.css b/public/crcp-grid.css
new file mode 100644
index 0000000..b8078be
--- /dev/null
+++ b/public/crcp-grid.css
@@ -0,0 +1,65 @@
+/* crcp-grid.css — styles for the reusable CrcpGrid engine. Namespaced .cg-* so it never
+ * collides with a host page. Colors read the host's CSS vars with safe fallbacks, so the
+ * engine inherits each page's theme (condos dark, fha-loans glass, sales gold) automatically. */
+.cg-shell{display:flex;align-items:flex-start;}
+.cg-rail{flex:0 0 250px;align-self:stretch;border-right:1px solid var(--cg-line,#2a313c);background:var(--cg-railbg,rgba(0,0,0,.18));position:sticky;top:0;max-height:100vh;overflow-y:auto;padding-bottom:30px;}
+.cg-main{flex:1 1 auto;min-width:0;--cg-cols:3;}
+.cg-sec{padding:12px 15px;border-bottom:1px solid var(--cg-line,#2a313c);}
+.cg-sec h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--cg-mut,#8b949e);cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;}
+.cg-sec h4::after{content:'▾';font-size:11px;margin-left:8px;transition:transform .15s;}
+.cg-sec.collapsed h4::after{transform:rotate(-90deg);}
+.cg-sec.collapsed > *:not(h4){display:none !important;}
+.cg-chips{display:flex;flex-wrap:wrap;gap:6px;}
+.cg-chip{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
+.cg-chip:hover{color:var(--cg-ink,#e6edf3);}
+.cg-chip.active{border-color:var(--cg-acc,#58a6ff);color:var(--cg-acc,#58a6ff);background:var(--cg-accbg,rgba(88,166,255,.10));}
+.cg-ct{color:var(--cg-mut,#8b949e);font-size:10px;margin-left:4px;}
+.cg-row2{display:flex;gap:8px;}
+.cg-fld{flex:1;}
+.cg-fld label{display:block;font-size:10px;color:var(--cg-mut,#8b949e);margin-bottom:3px;}
+.cg-fld input{width:100%;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:7px 9px;font-size:13px;}
+.cg-ftogs{display:flex;flex-direction:column;gap:1px;max-height:300px;overflow-y:auto;}
+.cg-ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--cg-ink,#e6edf3);cursor:pointer;padding:3px 4px;border-radius:6px;}
+.cg-ftog:hover{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-mini{margin-top:8px;display:flex;gap:5px;}
+.cg-mini button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;}
+.cg-mini button:hover{border-color:var(--cg-acc,#58a6ff);color:var(--cg-ink,#e6edf3);}
+.cg-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:13px 18px;border-bottom:1px solid var(--cg-line,#2a313c);position:sticky;top:0;background:var(--cg-topbg,rgba(14,17,22,.94));backdrop-filter:blur(6px);z-index:6;}
+.cg-q{min-width:240px;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 11px;font-size:13px;}
+.cg-seg{display:inline-flex;border:1px solid var(--cg-line,#2a313c);border-radius:8px;overflow:hidden;}
+.cg-seg button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:0;border-right:1px solid var(--cg-line,#2a313c);padding:7px 12px;font-size:12px;cursor:pointer;}
+.cg-seg button:last-child{border-right:0;}
+.cg-seg button.active{background:var(--cg-acc,#1f6feb);color:#fff;}
+.cg-dens{font-size:12px;color:var(--cg-mut,#8b949e);display:flex;align-items:center;gap:7px;}
+.cg-dens input{accent-color:var(--cg-acc,#58a6ff);}
+.cg-count{margin-left:auto;font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-count b{color:var(--cg-ink,#e6edf3);}
+.cg-grid{display:grid;gap:16px;padding:18px;grid-template-columns:repeat(var(--cg-cols,3),minmax(0,1fr));}
+.cg-grid.cg-listhost{display:block;}
+.cg-card{background:var(--cg-card,#161b22);border:1px solid var(--cg-line,#2a313c);border-radius:12px;padding:15px;display:flex;flex-direction:column;gap:5px;}
+.cg-card.red{border-color:var(--cg-red,#f85149);}
+.cg-name{font-weight:600;font-size:14px;color:var(--cg-ink,#e6edf3);}
+.cg-sub{font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-r{display:flex;justify-content:space-between;font-size:12px;gap:10px;}
+.cg-r .cg-k{color:var(--cg-mut,#8b949e);}
+.cg-empty{padding:50px;text-align:center;color:var(--cg-mut,#8b949e);}
+.cg-listwrap{overflow-x:auto;margin:18px;border:1px solid var(--cg-line,#2a313c);border-radius:12px;}
+.cg-tbl{width:100%;border-collapse:collapse;font-size:12.5px;white-space:nowrap;}
+.cg-tbl th,.cg-tbl td{text-align:right;padding:9px 13px;border-top:1px solid var(--cg-line,#2a313c);}
+.cg-tbl th{position:sticky;top:0;background:var(--cg-railbg,#0b0e13);color:var(--cg-mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:0;}
+.cg-tbl td.cg-stick,.cg-tbl th.cg-stick{text-align:left;position:sticky;left:0;background:var(--cg-card,#161b22);}
+.cg-tbl th.cg-stick{background:var(--cg-railbg,#0b0e13);}
+.cg-tbl tr:hover td{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-tbl tr.red td{background:rgba(248,81,73,.07);}
+.cg-la{font-weight:600;color:var(--cg-ink,#e6edf3);}
+.cg-lc{color:var(--cg-mut,#8b949e);font-size:11px;}
+@media(max-width:820px){.cg-rail{display:none;}}
+/* --- USRE adaptations (nationalrealestate) --- */
+.cg-sort{background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 10px;font-size:13px;}
+.cg-sortth{cursor:pointer;user-select:none;}
+.cg-sortth:hover{color:var(--cg-ink,#e6edf3);}
+/* density slider drives list-view row padding + font size via --cg-dens (1..12) */
+.cg-tbl{font-size:calc(15.5px - var(--cg-dens,3)*0.45px);font-variant-numeric:tabular-nums;}
+.cg-tbl th,.cg-tbl td{padding:calc(14px - var(--cg-dens,3)*1px) calc(16px - var(--cg-dens,3)*0.8px);}
+.cg-tbl tr[data-ri]{cursor:pointer;}
+.cg-tbl tr.dim td,.cg-card.dim{opacity:.45;}
diff --git a/public/crcp-grid.js b/public/crcp-grid.js
new file mode 100644
index 0000000..38cae2a
--- /dev/null
+++ b/public/crcp-grid.js
@@ -0,0 +1,247 @@
+/* crcp-grid.js — reusable Amazon-style faceted grid engine for the CRE fleet.
+ * Config-driven, theme-agnostic (inherits the host page's CSS vars via cg-grid.css).
+ * One page = one CrcpGrid.mount(opts) call. Mirrors the proven condos.html engine so
+ * every list page gets the SAME behavior: left-rail facets, per-field show/hide toggles,
+ * grid/list view toggle, density slider, live search, localStorage persistence.
+ *
+ * opts = {
+ *   data:      [row,...],            // all rows (client-side faceting)
+ *   fields:    [{k,l,def,col,badge,money,num,date,calc,suf,pct}],  // every data point (card rows + list cols)
+ *   facets:    [{k,l,type:'chip'|'range',calc,order,max}],         // rail filters (auto-derived if omitted)
+ *   mount:     '#cgMain',            // container for topbar + grid
+ *   rail:      '#cgRail',            // container for the left rail
+ *   title:     row => ({name, sub}),// card/list title + subline
+ *   search:    row => 'text blob',   // searchable string
+ *   badgeHtml: row => '<span>...'|'',// optional status badge html (card + list)
+ *   storageKey:'fha',               // localStorage namespace
+ *   cols:      3,                   // default density
+ *   onRow:     row => ({cls}),      // optional per-row class hook (e.g. red flag)
+ *   onRowClick:row => {},           // optional whole-row click handler (list + cards)
+ *   sort:      {k:'score',dir:-1},  // default sort (USRE adaptation; persisted, select + th-click)
+ * }
+ * USRE adaptations (nationalrealestate): sort <select> in the topbar + clickable list-view
+ * column headers (both persisted), density slider also drives list row padding/font via
+ * --cg-dens, onRowClick hook. Engine otherwise identical to the CRCP original.
+ */
+(function (global) {
+  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+  const $ = (sel, root) => (root || document).querySelector(sel);
+
+  function fmt(v, f) {
+    if (v == null || v === '') return '—';
+    if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
+    if (f.pct)   return (+v).toFixed(2) + '%';
+    if (f.num)   return (+v).toLocaleString() + (f.suf || '');
+    if (f.date)  { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
+    return esc(v) + (f.suf || '');
+  }
+
+  function CrcpGrid(opts) {
+    const O = opts;
+    const KEY = O.storageKey || 'cg';
+    const fields = O.fields;
+    const data = O.data || [];
+    const fval = (row, f) => f.calc ? f.calc(row) : row[f.k];
+
+    // ---- field visibility (persisted) ----
+    const VIS = (function () {
+      let s = {}; try { s = JSON.parse(localStorage.getItem(KEY + 'Fields') || '{}'); } catch (e) {}
+      const o = {}; fields.forEach(f => o[f.k] = (f.k in s) ? !!s[f.k] : !!f.def); return o;
+    })();
+    const saveVis = () => { try { localStorage.setItem(KEY + 'Fields', JSON.stringify(VIS)); } catch (e) {} };
+
+    // ---- auto-derive facets if not provided ----
+    let facets = O.facets;
+    if (!facets) {
+      facets = [];
+      fields.forEach(f => {
+        if (f.badge || f.k === 'title') return;
+        const vals = data.map(r => fval(r, f)).filter(v => v != null && v !== '');
+        if (!vals.length) return;
+        const numeric = f.money || f.num || f.pct || vals.every(v => typeof v === 'number' || /^\d+(\.\d+)?$/.test(v));
+        const uniq = new Set(vals.map(String));
+        if (!numeric && uniq.size <= 24) facets.push({ k: f.k, l: f.l, type: 'chip', calc: f.calc });
+        else if (numeric) facets.push({ k: f.k, l: f.l, type: 'range', calc: f.calc, money: f.money });
+      });
+    }
+
+    // ---- sort state (persisted) ----
+    let SORT = O.sort || null; // {k, dir:1|-1}; k='' = source order
+    try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
+    const saveSort = () => { try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e) {} };
+    function sortRows(rows) {
+      if (!SORT || !SORT.k) return rows;
+      const f = fields.find(x => x.k === SORT.k);
+      if (!f) return rows;
+      const dir = SORT.dir || 1;
+      return [...rows].sort((a, b) => {
+        const va = fval(a, f), vb = fval(b, f);
+        const na = va == null || va === '', nb = vb == null || vb === '';
+        if (na && nb) return 0; if (na) return 1; if (nb) return -1; // nulls last either dir
+        if (typeof va === 'number' || typeof vb === 'number' || f.money || f.num || f.pct)
+          return ((+va) - (+vb)) * dir;
+        return String(va).localeCompare(String(vb)) * dir;
+      });
+    }
+
+    // ---- filter state ----
+    const F = { q: '', chips: {}, ranges: {} };
+    facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k] = new Set(); else F.ranges[fc.k] = { min: null, max: null }; });
+
+    const gv = (row, fc) => fc.calc ? fc.calc(row) : row[fc.k];
+    function pass(row) {
+      for (const fc of facets) {
+        if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set.size && !set.has(String(gv(row, fc)))) return false; }
+        else { const r = F.ranges[fc.k], v = gv(row, fc);
+          if (r.min != null && !(+v >= r.min)) return false;
+          if (r.max != null && !(+v <= r.max)) return false; }
+      }
+      if (F.q) { const blob = (O.search ? O.search(row) : fields.map(f => fval(row, f)).join(' ')).toLowerCase(); if (!blob.includes(F.q)) return false; }
+      return true;
+    }
+
+    // ---- rail ----
+    function buildRail() {
+      const rail = $(O.rail);
+      let html = '';
+      facets.forEach(fc => {
+        if (fc.type === 'chip') {
+          const counts = {};
+          data.forEach(r => { const v = gv(r, fc); if (v != null && v !== '') counts[v] = (counts[v] || 0) + 1; });
+          const ent = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, fc.max || 20);
+          if (!ent.length) return;
+          html += `<div class="cg-sec collapsed"><h4>${esc(fc.l)}</h4><div class="cg-chips" data-facet="${esc(fc.k)}">` +
+            ent.map(([k, c]) => `<span class="cg-chip" data-v="${esc(k)}">${esc(k)}<span class="cg-ct">${c}</span></span>`).join('') + `</div></div>`;
+        } else {
+          html += `<div class="cg-sec collapsed"><h4>${esc(fc.l)}</h4><div class="cg-row2" data-facet="${esc(fc.k)}">` +
+            `<div class="cg-fld"><label>Min</label><input type="number" class="cg-min" placeholder="any"></div>` +
+            `<div class="cg-fld"><label>Max</label><input type="number" class="cg-max" placeholder="any"></div></div></div>`;
+        }
+      });
+      html += `<div class="cg-sec"><h4>Fields — show / hide</h4><div class="cg-ftogs">` +
+        fields.map(f => `<label class="cg-ftog"><input type="checkbox" data-fk="${esc(f.k)}" ${VIS[f.k] ? 'checked' : ''}><span>${esc(f.l)}</span>${f.col ? '<span class="cg-ct" title="List column">▤</span>' : ''}</label>`).join('') +
+        `</div><div class="cg-mini"><button data-all="1">All</button><button data-none="1">None</button><button data-def="1">Default</button></div></div>`;
+      rail.innerHTML = html;
+
+      // wire facets
+      rail.querySelectorAll('.cg-chips').forEach(box => box.addEventListener('click', e => {
+        const c = e.target.closest('.cg-chip'); if (!c) return;
+        const set = F.chips[box.dataset.facet]; const v = c.dataset.v;
+        set.has(v) ? set.delete(v) : set.add(v); c.classList.toggle('active'); render();
+      }));
+      rail.querySelectorAll('.cg-row2').forEach(box => {
+        const k = box.dataset.facet;
+        box.querySelector('.cg-min').addEventListener('input', e => { F.ranges[k].min = e.target.value ? +e.target.value : null; render(); });
+        box.querySelector('.cg-max').addEventListener('input', e => { F.ranges[k].max = e.target.value ? +e.target.value : null; render(); });
+      });
+      // field toggles
+      rail.querySelector('.cg-ftogs').addEventListener('change', e => { const cb = e.target.closest('input[data-fk]'); if (!cb) return; VIS[cb.dataset.fk] = cb.checked; saveVis(); render(); });
+      const mini = rail.querySelector('.cg-mini');
+      mini.addEventListener('click', e => {
+        const b = e.target.closest('button'); if (!b) return;
+        if (b.dataset.all) fields.forEach(f => VIS[f.k] = true);
+        else if (b.dataset.none) fields.forEach(f => VIS[f.k] = false);
+        else if (b.dataset.def) fields.forEach(f => VIS[f.k] = !!f.def);
+        saveVis(); buildRail(); render();
+      });
+      // collapsible headers
+      rail.querySelectorAll('.cg-sec > h4').forEach(h => h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed')));
+    }
+
+    // ---- renderers ----
+    function cards(rows) {
+      const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
+      return rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<div class="cg-card ${rc}" data-ri="${i}">` +
+          `<div class="cg-name">${nm}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
+          (O.badgeHtml ? O.badgeHtml(r) : '') +
+          vis.map(f => `<div class="cg-r"><span class="cg-k">${esc(f.l)}</span><span>${fmt(fval(r, f), f)}</span></div>`).join('') +
+          `</div>`;
+      }).join('');
+    }
+    function list(rows) {
+      const cols = fields.filter(f => f.col && VIS[f.k] && !f.badge);
+      const arrow = k => (SORT && SORT.k === k) ? (SORT.dir < 0 ? ' ▼' : ' ▲') : '';
+      const titleK = O.titleSortK || '';
+      const head = `<th class="cg-stick cg-sortth" data-k="${esc(titleK)}">${esc((O.titleCol || 'Name'))}${arrow(titleK)}</th>` +
+        cols.map(f => `<th class="cg-sortth" data-k="${esc(f.k)}">${esc(f.l)}${arrow(f.k)}</th>`).join('');
+      const body = rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<tr class="${rc}" data-ri="${i}"><td class="cg-stick"><span class="cg-la">${nm}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
+          cols.map(f => `<td>${fmt(fval(r, f), f)}</td>`).join('') + `</tr>`;
+      }).join('');
+      return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+    }
+
+    let VIEW = localStorage.getItem(KEY + 'View') || O.view || 'grid';
+    const CAP = O.cap || 1500; // DOM render cap for perf; count still reflects the full match set
+    let lastShown = [];
+    function render() {
+      const rows = sortRows(data.filter(pass));
+      const shown = rows.length > CAP ? rows.slice(0, CAP) : rows;
+      lastShown = shown;
+      const g = $(O.mount + ' .cg-grid');
+      if (!rows.length) { g.className = 'cg-grid'; g.innerHTML = '<div class="cg-empty">no matches for these filters.</div>'; }
+      else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = list(shown); }
+      else { g.className = 'cg-grid'; g.innerHTML = cards(shown); }
+      const cnt = $(O.mount + ' .cg-count');
+      if (cnt) cnt.innerHTML = `<b>${rows.length.toLocaleString()}</b> of ${data.length.toLocaleString()}` + (rows.length > CAP ? ` <span style="opacity:.7">(first ${CAP.toLocaleString()} shown)</span>` : '');
+      const sel = $(O.mount + ' .cg-sort');
+      if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+    }
+
+    // ---- topbar ----
+    function buildTop() {
+      const m = $(O.mount);
+      const sortable = fields.filter(f => f.col && !f.badge);
+      const sortOpts = `<option value="">Sort…</option>` + sortable.map(f =>
+        `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
+      m.insertAdjacentHTML('afterbegin',
+        `<div class="cg-top">` +
+        `<input type="text" class="cg-q" placeholder="search…">` +
+        `<select class="cg-sort" title="Sort">${sortOpts}</select>` +
+        `<span class="cg-seg"><button data-view="grid" class="${VIEW === 'grid' ? 'active' : ''}">▦ Grid</button><button data-view="list" class="${VIEW === 'list' ? 'active' : ''}">☰ List</button></span>` +
+        `<label class="cg-dens">Density <input type="range" class="cg-cols" min="1" max="12" step="1" value="${O.cols || 3}"></label>` +
+        `<span class="cg-count"></span></div><div class="cg-grid"></div>`);
+      const q = m.querySelector('.cg-q');
+      q.addEventListener('input', () => { clearTimeout(q._t); q._t = setTimeout(() => { F.q = q.value.trim().toLowerCase(); render(); }, 160); });
+      m.querySelector('.cg-sort').addEventListener('change', e => {
+        const v = e.target.value;
+        if (!v) SORT = null;
+        else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
+        saveSort(); render();
+      });
+      m.querySelector('.cg-seg').addEventListener('click', e => { const b = e.target.closest('button[data-view]'); if (!b) return; VIEW = b.dataset.view; localStorage.setItem(KEY + 'View', VIEW); m.querySelectorAll('.cg-seg button').forEach(x => x.classList.toggle('active', x.dataset.view === VIEW)); render(); });
+      const cols = m.querySelector('.cg-cols');
+      const setCols = v => { m.style.setProperty('--cg-cols', v); m.style.setProperty('--cg-dens', v); localStorage.setItem(KEY + 'Cols', v); };
+      cols.addEventListener('input', e => setCols(e.target.value));
+      const sc = localStorage.getItem(KEY + 'Cols'); if (sc) { cols.value = sc; }
+      setCols(cols.value);
+      // th-click sorting + whole-row click (delegated; grid re-renders under us)
+      m.addEventListener('click', e => {
+        const th = e.target.closest('.cg-sortth');
+        if (th && th.dataset.k) {
+          const k = th.dataset.k;
+          SORT = (SORT && SORT.k === k) ? { k, dir: -SORT.dir } : { k, dir: -1 };
+          saveSort(); render(); return;
+        }
+        if (O.onRowClick && !e.target.closest('a')) {
+          const tr = e.target.closest('tr[data-ri]');
+          if (tr) { O.onRowClick(lastShown[+tr.dataset.ri]); return; }
+          const card = e.target.closest('.cg-card[data-ri]');
+          if (card) O.onRowClick(lastShown[+card.dataset.ri]);
+        }
+      });
+    }
+
+    buildTop(); buildRail(); render();
+    return { render, setData(d) { O.data.length = 0; d.forEach(x => O.data.push(x)); buildRail(); render(); } };
+  }
+
+  global.CrcpGrid = { mount: opts => new CrcpGrid(opts) };
+})(window);
diff --git a/public/markets.html b/public/markets.html
new file mode 100644
index 0000000..60be745
--- /dev/null
+++ b/public/markets.html
@@ -0,0 +1,126 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>USRealEstate — Markets Ranking</title>
+<link rel="stylesheet" href="/crcp-grid.css">
+<style>
+  :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;
+    /* map CrcpGrid theme vars onto the USRE dark/gold system */
+    --cg-card:#11151d;--cg-line:#222936;--cg-ink:#e8ecf2;--cg-mut:#8a93a3;
+    --cg-acc:#c8a24b;--cg-accbg:rgba(200,162,75,.12);--cg-railbg:#0a0d13;--cg-topbg:rgba(17,21,29,.94);}
+  *{box-sizing:border-box;}
+  html,body{margin:0;background:var(--bg);color:var(--fg);
+    font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
+  #topbar{position:sticky;top:0;z-index:1000;display:flex;align-items:center;gap:18px;
+    padding:10px 18px;background:rgba(17,21,29,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
+  #topbar .brand{font-size:16px;font-weight:700;white-space:nowrap;}
+  #topbar .brand b{color:var(--gold);}
+  nav{display:flex;gap:14px;font-size:13px;}
+  nav a{color:var(--muted);text-decoration:none;} nav a:hover,nav a.active{color:var(--gold);}
+  #typeSeg{margin-left:auto;display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
+  #typeSeg button{background:var(--panel);color:var(--muted);border:0;border-right:1px solid var(--line);
+    padding:7px 14px;font-size:12px;cursor:pointer;}
+  #typeSeg button:last-child{border-right:0;}
+  #typeSeg button.active{background:var(--gold);color:var(--bg);font-weight:700;}
+  #loading{padding:60px;text-align:center;color:var(--muted);}
+  .score{font-weight:700;color:var(--gold);}
+</style>
+</head>
+<body>
+<div id="topbar">
+  <div class="brand"><b>USRealEstate</b> — Markets Ranking</div>
+  <nav>
+    <a href="/">Map</a>
+    <a href="/markets.html" class="active">Markets</a>
+    <a href="/watchlist.html">Watchlist</a>
+    <a href="/admin.html">Admin</a>
+  </nav>
+  <span id="typeSeg">
+    <button data-type="county">Counties</button>
+    <button data-type="metro">Metros</button>
+  </span>
+</div>
+<div class="cg-shell">
+  <div class="cg-rail" id="cgRail"></div>
+  <div class="cg-main" id="cgMain"><div id="loading">loading markets…</div></div>
+</div>
+
+<script src="/crcp-grid.js"></script>
+<script src="/col-resize.js"></script>
+<script>
+const THIN = 0.6; // data_completeness below this = dimmed row
+let TYPE = localStorage.getItem('usreMkt.type');
+if (TYPE !== 'county' && TYPE !== 'metro') TYPE = 'county';
+
+const FIELDS = [
+  { k: 'state',                 l: 'State',           def: 1, col: 1 },
+  { k: 'opportunity_score',     l: 'Score',           def: 1, col: 1, num: 1 },
+  { k: 'rank',                  l: 'Rank',            def: 1, col: 1, num: 1 },
+  { k: 'momentum',              l: 'Momentum',        def: 0, col: 1, calc: r => sub100(r.momentum), num: 1 },
+  { k: 'affordability_score',   l: 'Afford. Score',   def: 0, col: 1, calc: r => sub100(r.affordability_score), num: 1 },
+  { k: 'rent_yield_score',      l: 'Yield Score',     def: 0, col: 1, calc: r => sub100(r.rent_yield_score), num: 1 },
+  { k: 'inventory_trend',       l: 'Inv. Trend',      def: 0, col: 1, calc: r => sub100(r.inventory_trend), num: 1 },
+  { k: 'velocity',              l: 'Velocity',        def: 0, col: 1, calc: r => sub100(r.velocity), num: 1 },
+  { k: 'data_completeness',     l: 'Data %',          def: 1, col: 1, calc: r => r.data_completeness == null ? null : Math.round(r.data_completeness * 100), num: 1, suf: '%' },
+  { k: 'zhvi',                  l: 'ZHVI',            def: 1, col: 1, money: 1, calc: r => r.zhvi == null ? null : Math.round(r.zhvi) },
+  { k: 'zhvi_yoy',              l: 'ZHVI YoY',        def: 1, col: 1, calc: r => pctv(r.zhvi_yoy), pct: 1 },
+  { k: 'median_sale_price',     l: 'Med. Sale',       def: 1, col: 1, money: 1 },
+  { k: 'median_sale_price_yoy', l: 'Sale YoY',        def: 0, col: 1, calc: r => pctv(r.median_sale_price_yoy), pct: 1 },
+  { k: 'inventory',             l: 'Inventory',       def: 0, col: 1, num: 1 },
+  { k: 'dom',                   l: 'DOM',             def: 1, col: 1, num: 1 },
+  { k: 'months_of_supply',      l: 'Mo. Supply',      def: 0, col: 1, num: 1 },
+  { k: 'sale_to_list',          l: 'Sale/List',       def: 0, col: 1, calc: r => pctv(r.sale_to_list), pct: 1 },
+  { k: 'median_hh_income',      l: 'HH Income',       def: 1, col: 1, money: 1 },
+  { k: 'rent_yield',            l: 'Rent Yield',      def: 1, col: 1, calc: r => pctv(r.rent_yield), pct: 1 },
+  { k: 'affordability',         l: 'ZHVI/Income',     def: 0, col: 1, calc: r => r.affordability == null ? null : +(+r.affordability).toFixed(2), num: 1 },
+  { k: 'population',            l: 'Population',      def: 1, col: 1, num: 1 },
+  { k: 'name',                  l: 'Name',            def: 0, col: 0 }, // title-column sort target only
+];
+function sub100(v){ return v == null ? null : Math.round(v * 100); }
+function pctv(v){ return v == null ? null : +(v * 100).toFixed(2); }
+
+let grid = null;
+async function load(type){
+  TYPE = type;
+  localStorage.setItem('usreMkt.type', type);
+  document.querySelectorAll('#typeSeg button').forEach(b => b.classList.toggle('active', b.dataset.type === type));
+  const d = await fetch('/api/markets?type=' + type).then(r => r.json());
+  const rows = d.rows || [];
+  const loading = document.getElementById('loading');
+  if (loading) loading.remove();
+  if (grid){ grid.setData(rows); return; }
+  grid = CrcpGrid.mount({
+    data: rows,
+    fields: FIELDS,
+    mount: '#cgMain',
+    rail: '#cgRail',
+    storageKey: 'usreMkt',
+    cols: 4,
+    view: 'list',
+    cap: 3500, // render all ~3.2k counties
+    title: r => ({ name: r.name, sub: (r.state || '') + ' · ' + r.key }),
+    titleCol: 'Market',
+    titleSortK: 'name',
+    search: r => `${r.name} ${r.state || ''} ${r.key}`,
+    sort: { k: 'opportunity_score', dir: -1 },
+    onRow: r => ({ cls: (r.data_completeness != null && r.data_completeness < THIN) ? 'dim' : '' }),
+    onRowClick: r => { location.href = '/market.html?key=' + encodeURIComponent(r.key); },
+    facets: [
+      { k: 'state', l: 'State', type: 'chip', max: 60 },
+      { k: 'opportunity_score', l: 'Score', type: 'range' },
+      { k: 'zhvi', l: 'ZHVI', type: 'range', money: 1 },
+      { k: 'population', l: 'Population', type: 'range' },
+      { k: 'dom', l: 'DOM', type: 'range' },
+    ],
+  });
+}
+document.getElementById('typeSeg').addEventListener('click', e => {
+  const b = e.target.closest('button[data-type]');
+  if (b && b.dataset.type !== TYPE) load(b.dataset.type);
+});
+load(TYPE);
+</script>
+</body>
+</html>
diff --git a/score-config.json b/score-config.json
new file mode 100644
index 0000000..1874792
--- /dev/null
+++ b/score-config.json
@@ -0,0 +1,15 @@
+{
+  "weights": {
+    "momentum": 0.25,
+    "affordability": 0.20,
+    "rent_yield": 0.20,
+    "inventory_trend": 0.15,
+    "velocity": 0.20
+  },
+  "constants": {
+    "inventory_trend_window_months": 12,
+    "score_scale": 100,
+    "clamp": [0, 100],
+    "thin_data_threshold": 0.6
+  }
+}
diff --git a/src/score/opportunity.ts b/src/score/opportunity.ts
new file mode 100644
index 0000000..264e823
--- /dev/null
+++ b/src/score/opportunity.ts
@@ -0,0 +1,182 @@
+/**
+ * Opportunity scorer — deterministic, NO LLM. Counties and metros scored separately
+ * (percentiles within region_type). ONE frozen SQL snapshot read, then pure TS math,
+ * so same-day re-runs produce byte-identical components.
+ *
+ * Components (each a 0-1 sub-score):
+ *   momentum        = percentile of blend(zhvi_yoy, redfin median_sale_price_yoy fallback)
+ *   affordability   = INVERSE percentile of zhvi / median_hh_income
+ *   rent_yield      = percentile of derived rent_yield
+ *   inventory_trend = INVERSE percentile of 12-mo % change in months_of_supply (tightening = higher)
+ *   velocity        = mean(inverse DOM percentile, sale_to_list percentile)
+ * Null component -> dropped + weights renormalized; data_completeness = fraction of 5 present.
+ * opportunity_score = renormalized weighted sum x 100, clamped 0-100.
+ * Rank = dense by score desc within region_type.
+ */
+import { readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { query, pool } from '../../db/pool.ts';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const CONFIG = JSON.parse(readFileSync(join(__dirname, '..', '..', 'score-config.json'), 'utf8'));
+const WEIGHTS: Record<string, number> = CONFIG.weights;
+const COMPONENTS = ['momentum', 'affordability', 'rent_yield', 'inventory_trend', 'velocity'] as const;
+
+interface SnapRow { region_id: number; region_type: string; metric: string; value: string }
+
+/** Mid-rank percentile in [0,1] over a sorted array. Deterministic under ties. */
+function percentiler(values: number[]): (v: number) => number {
+  const sorted = [...values].sort((a, b) => a - b);
+  const n = sorted.length;
+  if (n <= 1) return () => 0.5;
+  const lowerBound = (v: number) => { let lo = 0, hi = n; while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] < v) lo = m + 1; else hi = m; } return lo; };
+  const upperBound = (v: number) => { let lo = 0, hi = n; while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] <= v) lo = m + 1; else hi = m; } return lo; };
+  return (v: number) => ((lowerBound(v) + upperBound(v) - 1) / 2) / (n - 1);
+}
+
+function round6(x: number): number { return Math.round(x * 1e6) / 1e6; }
+
+async function main() {
+  // ---- single frozen snapshot read ----
+  const snap = await query<SnapRow>(
+    `WITH latest AS (
+       SELECT DISTINCT ON (ms.region_id, ms.metric)
+              ms.region_id, rg.region_type, ms.metric, ms.value, ms.period
+         FROM metric_series ms
+         JOIN region rg ON rg.id = ms.region_id AND rg.region_type IN ('county','metro')
+        WHERE ms.metric IN ('zhvi_yoy','median_sale_price_yoy','affordability','rent_yield',
+                            'months_of_supply','dom','sale_to_list')
+        ORDER BY ms.region_id, ms.metric, ms.period DESC
+     )
+     SELECT region_id, region_type, metric, value::text AS value FROM latest
+     UNION ALL
+     SELECT l.region_id, l.region_type, 'months_of_supply_prior' AS metric, ms.value::text AS value
+       FROM latest l
+       JOIN metric_series ms
+         ON ms.region_id = l.region_id AND ms.metric = 'months_of_supply'
+        AND ms.period = l.period - INTERVAL '12 months'
+      WHERE l.metric = 'months_of_supply'
+      ORDER BY region_id, metric`,
+  );
+
+  const byRegion = new Map<number, { type: string; m: Record<string, number> }>();
+  for (const r of snap.rows) {
+    let o = byRegion.get(r.region_id);
+    if (!o) { o = { type: r.region_type, m: {} }; byRegion.set(r.region_id, o); }
+    o.m[r.metric] = Number(r.value);
+  }
+
+  const scoreDateR = await query<{ d: string }>(`SELECT CURRENT_DATE::text AS d`);
+  const scoreDate = scoreDateR.rows[0].d;
+
+  let totalUpserts = 0;
+  for (const regionType of ['county', 'metro']) {
+    const regions = [...byRegion.entries()]
+      .filter(([, o]) => o.type === regionType)
+      .sort((a, b) => a[0] - b[0]); // deterministic order
+
+    // raw inputs per region
+    type Raw = {
+      momentum_raw: number | null; affordability_raw: number | null; rent_yield_raw: number | null;
+      mos_change: number | null; dom: number | null; sale_to_list: number | null;
+      zhvi_yoy: number | null; msp_yoy: number | null; mos_latest: number | null; mos_prior: number | null;
+    };
+    const raws = new Map<number, Raw>();
+    for (const [id, o] of regions) {
+      const g = (k: string) => (k in o.m && Number.isFinite(o.m[k]) ? o.m[k] : null);
+      const zhviYoy = g('zhvi_yoy'), mspYoy = g('median_sale_price_yoy');
+      const mosL = g('months_of_supply'), mosP = g('months_of_supply_prior');
+      raws.set(id, {
+        momentum_raw: zhviYoy ?? mspYoy,
+        affordability_raw: g('affordability'),
+        rent_yield_raw: g('rent_yield'),
+        mos_change: mosL != null && mosP != null && mosP > 0 ? (mosL - mosP) / mosP : null,
+        dom: g('dom'), sale_to_list: g('sale_to_list'),
+        zhvi_yoy: zhviYoy, msp_yoy: mspYoy, mos_latest: mosL, mos_prior: mosP,
+      });
+    }
+
+    const pool_ = (pick: (r: Raw) => number | null) =>
+      [...raws.values()].map(pick).filter((v): v is number => v != null);
+    const pMomentum = percentiler(pool_(r => r.momentum_raw));
+    const pAfford = percentiler(pool_(r => r.affordability_raw));
+    const pYield = percentiler(pool_(r => r.rent_yield_raw));
+    const pMos = percentiler(pool_(r => r.mos_change));
+    const pDom = percentiler(pool_(r => r.dom));
+    const pStl = percentiler(pool_(r => r.sale_to_list));
+
+    const results: { regionId: number; score: number; components: any }[] = [];
+    for (const [id] of regions) {
+      const r = raws.get(id)!;
+      const sub: Record<string, number | null> = {
+        momentum: r.momentum_raw != null ? round6(pMomentum(r.momentum_raw)) : null,
+        affordability: r.affordability_raw != null ? round6(1 - pAfford(r.affordability_raw)) : null,
+        rent_yield: r.rent_yield_raw != null ? round6(pYield(r.rent_yield_raw)) : null,
+        inventory_trend: r.mos_change != null ? round6(1 - pMos(r.mos_change)) : null,
+        velocity: null,
+      };
+      const velParts: number[] = [];
+      if (r.dom != null) velParts.push(1 - pDom(r.dom));
+      if (r.sale_to_list != null) velParts.push(pStl(r.sale_to_list));
+      if (velParts.length) sub.velocity = round6(velParts.reduce((a, b) => a + b, 0) / velParts.length);
+
+      const present = COMPONENTS.filter(c => sub[c] != null);
+      if (!present.length) continue;
+      const wSum = present.reduce((a, c) => a + WEIGHTS[c], 0);
+      const weightsUsed: Record<string, number> = {};
+      for (const c of present) weightsUsed[c] = round6(WEIGHTS[c] / wSum);
+      const score = Math.min(100, Math.max(0,
+        present.reduce((a, c) => a + weightsUsed[c] * (sub[c] as number), 0) * 100));
+
+      results.push({
+        regionId: id,
+        score: Math.round(score * 100) / 100,
+        components: {
+          sub_scores: sub,
+          weights_used: weightsUsed,
+          data_completeness: round6(present.length / COMPONENTS.length),
+          raw: {
+            zhvi_yoy: r.zhvi_yoy, median_sale_price_yoy: r.msp_yoy,
+            affordability: r.affordability_raw, rent_yield: r.rent_yield_raw,
+            months_of_supply_latest: r.mos_latest, months_of_supply_prior: r.mos_prior,
+            months_of_supply_change_12mo: r.mos_change != null ? round6(r.mos_change) : null,
+            dom: r.dom, sale_to_list: r.sale_to_list,
+          },
+        },
+      });
+    }
+
+    // dense rank by score desc within region_type
+    results.sort((a, b) => b.score - a.score || a.regionId - b.regionId);
+    let rank = 0, prevScore: number | null = null;
+    for (const res of results) {
+      if (res.score !== prevScore) { rank++; prevScore = res.score; }
+      (res as any).rank = rank;
+    }
+
+    for (let i = 0; i < results.length; i += 500) {
+      const batch = results.slice(i, i + 500);
+      const params: unknown[] = [];
+      const values = batch.map((res: any, j) => {
+        params.push(res.regionId, scoreDate, res.score, JSON.stringify(res.components), res.rank);
+        const b = j * 5;
+        return `($${b + 1},$${b + 2}::date,$${b + 3},$${b + 4}::jsonb,$${b + 5})`;
+      });
+      await query(
+        `INSERT INTO region_scores (region_id, score_date, opportunity_score, components, rank)
+         VALUES ${values.join(',')}
+         ON CONFLICT (region_id, score_date)
+         DO UPDATE SET opportunity_score = EXCLUDED.opportunity_score,
+                       components = EXCLUDED.components, rank = EXCLUDED.rank`,
+        params,
+      );
+    }
+    totalUpserts += results.length;
+    console.log(`[score] ${regionType}: ${results.length} scored`);
+  }
+  console.log(`[score] ok: ${totalUpserts} region_scores rows for ${scoreDate}`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('[score] FAILED:', e); process.exit(1); });

← aae13b5 M3: ACS 2023 5-yr county demographics (19.3k rows), FHFA HPI  ·  back to Nationalrealestate  ·  CCK review fixes: legacy CT counties seeded (CT choropleth h c188ec3 →