Wave 4A: Outlook Mode (never-empty grid) + Market-Breadth consensus strip

Step 3 — OUTLOOK MODE. The game grid no longer dead-ends in a "NO SLATE" CTA.
When there are no live games (and it's not a network failure) it shows REAL,
always-available data: yesterday's PROVEN A-tier receipts (/api/ledger/model)
+ tomorrow's date-pinned ESPN schedule preview (free/cached). A network
fetchError stays a distinct ERROR state — never a fabricated outlook.
- lib/outlook.js (new, CommonJS, unit-tested): buildOutlook selection +
  mapTomorrowPreview (upcoming-only, drops incomplete matchups, never invents).
- Slate.tsx: OutlookSurface replaces the empty-grid CTA (dateOffset 0 only).
- dashboard/page.tsx: DashboardOutlook replaces the "Today's games" NO-SLATE CTA.

Step 4 — MARKET-BREADTH / CONSENSUS vs MODEL. Makes the DeskShowcase
"consensus vs model" claim REAL. Consensus = median book line across a prop's
per-book rows; the model's position is model_value vs consensus, signed by the
graded side. <2 distinct books → null (never fabricate a consensus); a
non-numeric line is ignored, never coerced to 0.
- lib/marketBreadth.js (new, CommonJS, unit-tested): median/computeBreadth/
  collectBreadth (strict null guards).
- components/vyndr/MarketBreadth.tsx (new): mono/tabular strip, colored by sign
  via colorContract.edgeColor, self-hides when nothing has >=2 books.
- Slate.tsx renders it above the grid (joins books + snapshot model_value).
- slateAdapter.js exports gradeKey for the join.
- DeskShowcase.tsx: the consensus claim is now backed by the shipped feature.

Tests: tests/unit/outlook.test.js + tests/unit/marketBreadth.test.js (23 cases).
Full suite 2984 passing (245 suites); next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 14:59:44 -04:00
parent 35007cde22
commit bc8633466c
10 changed files with 824 additions and 53 deletions
+114
View File
@@ -0,0 +1,114 @@
/* ============================================================
VYNDR — MARKET BREADTH / CONSENSUS-vs-MODEL (Wave 4A, Step 4).
Makes the DeskShowcase "consensus vs model, live line moves" claim REAL.
Given a prop's per-book rows + the model's projected value, compute the
market CONSENSUS (median book line) and the model's position vs that
consensus, SIGNED by the graded side (so an over that the model projects
ABOVE the market and an under it projects BELOW both read as a positive
edge → signal-green via the color contract).
DATA-SEMANTICS RULE: VYNDR never invents a market number. A consensus is
only honest with ≥2 DISTINCT books posting a finite line → otherwise null
(absent beats invented). A non-numeric line is IGNORED, never coerced to
0 (the classic `Number(null) === 0` fabrication bug).
Plain CommonJS so the .tsx strip imports it (allowJs) AND Jest exercises
the logic directly — same pattern as slateAdapter.js / colorContract.js.
============================================================ */
/** Strict numeric parse — null (never 0) when a value isn't a real number. */
function numOrNull(v) {
const n = typeof v === 'number' ? v : parseFloat(v);
return Number.isFinite(n) ? n : null;
}
const round2 = (x) => Math.round(x * 100) / 100;
/** Median of the finite numbers in `nums`. Empty / all-non-finite → null. */
function median(nums) {
const sorted = (Array.isArray(nums) ? nums : [])
.map(numOrNull)
.filter((n) => n != null)
.sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : round2((sorted[mid - 1] + sorted[mid]) / 2);
}
/**
* computeBreadth(books, modelValue, side) → breadth | null.
*
* `books` = the prop's per-book rows ([{ book, line, over_odds, under_odds }])
* — the same grouped shape the slate threads onto each prop. One opinion per
* DISTINCT book (dedupe by book name). <2 distinct books with a finite line
* → null (no honest consensus).
*
* Returns:
* consensus — median book line (the market's line)
* bookCount — distinct books contributing a finite line
* model — the model's projected value (numOrNull) or null
* delta — model consensus (signed to the MARKET), or null
* signedEdge — signed to the GRADED SIDE (positive = model beats market),
* or null when the model value is absent
* position — 'above' | 'below' | 'inline' (model vs consensus), or null
* side — normalized 'over' | 'under'
*/
function computeBreadth(books, modelValue, side = 'over') {
const rows = Array.isArray(books) ? books : [];
const byBook = new Map();
for (const r of rows) {
if (!r || !r.book) continue;
const ln = numOrNull(r.line);
if (ln == null) continue;
// First finite line per distinct book wins (one opinion per book).
if (!byBook.has(r.book)) byBook.set(r.book, ln);
}
if (byBook.size < 2) return null; // <2 books → never fabricate a consensus
const consensus = median([...byBook.values()]);
const isUnder = String(side || 'over').toLowerCase().startsWith('u');
const model = numOrNull(modelValue);
let delta = null;
let signedEdge = null;
let position = null;
if (model != null && consensus != null) {
delta = round2(model - consensus);
signedEdge = round2(isUnder ? consensus - model : model - consensus);
position = delta > 0 ? 'above' : delta < 0 ? 'below' : 'inline';
}
return {
consensus,
bookCount: byBook.size,
model,
delta,
signedEdge,
position,
side: isUnder ? 'under' : 'over',
};
}
/**
* collectBreadth(items, limit) — compute breadth for a list of props and
* return the qualifying rows ranked by |signedEdge| desc (the biggest model
* disagreements with the market lead). Non-qualifying props (<2 books) are
* dropped, so an empty result means the strip self-hides.
*
* `items` = [{ player, stat, side, line, books, modelValue }].
*/
function collectBreadth(items, limit = 6) {
const out = [];
for (const it of Array.isArray(items) ? items : []) {
if (!it) continue;
const b = computeBreadth(it.books, it.modelValue, it.side);
if (!b) continue;
out.push({ player: it.player, stat: it.stat, line: numOrNull(it.line), ...b });
}
const abs = (x) => Math.abs(numOrNull(x) == null ? 0 : numOrNull(x));
out.sort((a, b) => abs(b.signedEdge) - abs(a.signedEdge));
return out.slice(0, Math.max(0, limit));
}
module.exports = { median, computeBreadth, collectBreadth, numOrNull };
+78
View File
@@ -0,0 +1,78 @@
/* ============================================================
VYNDR — OUTLOOK MODE (Wave 4A, Step 3): the never-empty slate grid.
The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no
live games (and it's not a network failure) the grid falls back to REAL,
always-available data — the terminal is never dark, but it is never
fabricated either:
• yesterday's PROVEN A-tier receipts (settled ledger hits), and/or
• tomorrow's date-pinned ESPN schedule preview (free / cached).
A network `fetchError` is DISTINCT and stays an ERROR state — a fetch
failure is never dressed up as a fake outlook.
Plain CommonJS so the .tsx surfaces import it (allowJs) AND Jest exercises
the selection logic directly.
============================================================ */
const { buildHeroReceipts } = require('./slateAdapter');
/** Statuses that mean a game is no longer a PREVIEW (already underway/done). */
const NON_PREVIEW = new Set(['in', 'post', 'final', 'live', 'completed']);
/**
* mapTomorrowPreview(scheduleGames, limit) — map real schedule games (either
* the ESPN `{ awayTeam, homeTeam, gameTime, status }` shape OR the flattened
* `{ away, home, start_time }` shape) → compact preview rows. Upcoming only;
* a game missing either team is DROPPED (never fabricate a matchup).
*/
function mapTomorrowPreview(scheduleGames, limit = 8) {
const list = Array.isArray(scheduleGames) ? scheduleGames : [];
const out = [];
for (const g of list) {
if (!g) continue;
const status = String(g.status || g.state || '').toLowerCase();
if (NON_PREVIEW.has(status)) continue;
const away = g.away || g.awayTeam?.name || g.awayTeam?.abbreviation || null;
const home = g.home || g.homeTeam?.name || g.homeTeam?.abbreviation || null;
if (!away || !home) continue; // absent beats a fabricated fixture
out.push({
id: g.id || `${away}-${home}`,
away,
home,
time: g.gameTime || g.start_time || g.time || null,
sport: g.sport ? String(g.sport).toUpperCase() : null,
});
if (out.length >= Math.max(0, limit)) break;
}
return out;
}
/**
* buildOutlook(opts) →
* { mode: 'error' } — a network failure
* { mode: 'live' } — real games on the board
* { mode: 'outlook', receipts, tomorrow, hasContent }
*
* The outlook surface is ALWAYS non-error / non-blank at the surface level:
* even with no receipts and no schedule, the caller still renders the
* month-aware empty-state header — the grid never dead-ends in a CTA.
*
* @param {{ gamesCount?: number, fetchError?: unknown, settledRows?: unknown[], tomorrow?: unknown[] }} [opts]
*/
function buildOutlook(opts = {}) {
const { gamesCount = 0, fetchError = null, settledRows = [], tomorrow = [] } = opts;
if (fetchError) return { mode: 'error' };
if (Number(gamesCount) > 0) return { mode: 'live' };
const receipts = buildHeroReceipts(settledRows, 6);
const preview = mapTomorrowPreview(tomorrow, 8);
return {
mode: 'outlook',
receipts,
tomorrow: preview,
hasContent: receipts.length > 0 || preview.length > 0,
};
}
module.exports = { buildOutlook, mapTomorrowPreview };
+1
View File
@@ -603,6 +603,7 @@ module.exports = {
isRelevantGame,
indexGrades,
indexDeltas,
gradeKey,
statShort,
gradedAgo,
buildPlayerStripsFromProps,