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
+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 };