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,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 };
|
||||
Reference in New Issue
Block a user