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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -603,6 +603,7 @@ module.exports = {
|
||||
isRelevantGame,
|
||||
indexGrades,
|
||||
indexDeltas,
|
||||
gradeKey,
|
||||
statShort,
|
||||
gradedAgo,
|
||||
buildPlayerStripsFromProps,
|
||||
|
||||
Reference in New Issue
Block a user