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
+115
View File
@@ -0,0 +1,115 @@
// Wave 4A — MARKET-BREADTH / CONSENSUS strip (Step 4). Makes the DeskShowcase
// "consensus vs model" claim REAL: the consensus is the MEDIAN book line across
// the prop's per-book rows; the model's position is model_value vs that
// consensus, signed by the graded side. Doctrine: never fabricate a consensus —
// <2 distinct books → null (absent beats invented); a non-numeric line is
// ignored, never coerced to 0 (the Number(null)===0 trap).
const { computeBreadth, collectBreadth, median } = require('../../web/src/lib/marketBreadth');
describe('median', () => {
it('odd length → middle', () => { expect(median([2.5, 1.5, 3.5])).toBe(2.5); });
it('even length → mean of the two middles', () => { expect(median([1.5, 2.5])).toBe(2); });
it('ignores non-finite, empty → null', () => {
expect(median([NaN, 1.5, 2.5, null])).toBe(2);
expect(median([])).toBeNull();
expect(median(null)).toBeNull();
});
});
describe('computeBreadth — median consensus from ≥2 books', () => {
const books = [
{ book: 'draftkings', line: 2.5, over_odds: -115 },
{ book: 'fanduel', line: 2.5, over_odds: -110 },
{ book: 'betmgm', line: 1.5, over_odds: -120 },
];
it('consensus is the median book LINE; model above → OVER edge (positive, green)', () => {
const b = computeBreadth(books, 3.0, 'over');
expect(b.consensus).toBe(2.5);
expect(b.bookCount).toBe(3);
expect(b.model).toBe(3.0);
expect(b.delta).toBeCloseTo(0.5);
expect(b.signedEdge).toBeCloseTo(0.5); // model projects ABOVE market → supports OVER
expect(b.position).toBe('above');
expect(b.side).toBe('over');
});
it('UNDER read: model BELOW consensus is the edge (signed positive)', () => {
const b = computeBreadth(books, 2.0, 'under');
expect(b.consensus).toBe(2.5);
expect(b.delta).toBeCloseTo(-0.5); // raw model - consensus stays signed to the market
expect(b.signedEdge).toBeCloseTo(0.5); // consensus - model, favors UNDER
expect(b.position).toBe('below');
expect(b.side).toBe('under');
});
it('model behind the market on an OVER → negative signed edge (amber/red)', () => {
const b = computeBreadth(books, 2.0, 'over');
expect(b.signedEdge).toBeCloseTo(-0.5);
expect(b.position).toBe('below');
});
it('<2 DISTINCT books → null (never fabricate a consensus)', () => {
expect(computeBreadth([{ book: 'dk', line: 2.5 }], 3, 'over')).toBeNull();
// same book twice is still one opinion → not a consensus
expect(computeBreadth([{ book: 'dk', line: 2.5 }, { book: 'dk', line: 1.5 }], 3, 'over')).toBeNull();
expect(computeBreadth([], 3)).toBeNull();
expect(computeBreadth(null, 3)).toBeNull();
});
it('absent model → real consensus present, comparison null (no invented model)', () => {
const b = computeBreadth(books, null, 'over');
expect(b.consensus).toBe(2.5);
expect(b.bookCount).toBe(3);
expect(b.model).toBeNull();
expect(b.delta).toBeNull();
expect(b.signedEdge).toBeNull();
expect(b.position).toBeNull();
});
it('a non-numeric book line is IGNORED, not coerced to 0', () => {
const mixed = [{ book: 'dk', line: 2.5 }, { book: 'fd', line: null }, { book: 'mgm', line: 2.5 }];
const b = computeBreadth(mixed, 2.5, 'over');
expect(b.bookCount).toBe(2);
expect(b.consensus).toBe(2.5);
expect(b.position).toBe('inline'); // model == consensus
expect(b.signedEdge).toBe(0);
});
});
describe('collectBreadth — ranked list, self-hiding', () => {
it('drops <2-book props and ranks by |signedEdge| desc', () => {
const items = [
{ player: 'A', stat: 'hits', side: 'over', line: 1.5, modelValue: 2.0, books: [{ book: 'dk', line: 1.5 }, { book: 'fd', line: 1.5 }] },
{ player: 'B', stat: 'tb', side: 'over', line: 2.5, modelValue: 2.6, books: [{ book: 'dk', line: 2.5 }, { book: 'fd', line: 2.5 }] },
{ player: 'C', stat: 'ks', side: 'over', line: 5.5, modelValue: 6, books: [{ book: 'dk', line: 5.5 }] }, // 1 book → dropped
];
const out = collectBreadth(items, 6);
expect(out).toHaveLength(2);
expect(out[0].player).toBe('A'); // |0.5| edge beats |0.1|
expect(out[1].player).toBe('B');
});
it('empty when nothing qualifies (component self-hides)', () => {
expect(collectBreadth([{ player: 'C', stat: 'ks', side: 'over', books: [{ book: 'dk', line: 5.5 }] }], 6)).toEqual([]);
expect(collectBreadth(null)).toEqual([]);
expect(collectBreadth([])).toEqual([]);
});
});
describe('source: MarketBreadth component self-hides + colors by sign', () => {
const fs = require('fs');
const path = require('path');
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
it('MarketBreadth.tsx colors the edge via the color contract (edgeColor), not raw green', () => {
const src = read('components/vyndr/MarketBreadth.tsx');
expect(src).toContain('edgeColor');
});
it('MarketBreadth.tsx self-hides when there is nothing to show', () => {
const src = read('components/vyndr/MarketBreadth.tsx');
expect(src).toMatch(/return null/);
});
});
+95
View File
@@ -0,0 +1,95 @@
// Wave 4A — OUTLOOK MODE (never-empty slate grid, Step 3).
// The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no
// live games it falls back to REAL always-available data: yesterday's PROVEN
// A-tier receipts + tomorrow's date-pinned schedule preview. A network
// fetchError stays an ERROR state (a fetch failure is never a fake outlook).
const { buildOutlook, mapTomorrowPreview } = require('../../web/src/lib/outlook');
describe('mapTomorrowPreview — real schedule → preview rows', () => {
const games = [
{ id: 'g1', awayTeam: { name: 'Yankees' }, homeTeam: { name: 'Red Sox' }, gameTime: '2026-07-14T23:00:00Z', status: 'pre', sport: 'mlb' },
{ id: 'g2', awayTeam: { abbreviation: 'LAD' }, homeTeam: { abbreviation: 'SF' }, status: 'in' }, // live → dropped (a preview is upcoming only)
{ id: 'g3', awayTeam: {}, homeTeam: { name: 'X' } }, // missing away team → dropped (never fabricate a matchup)
{ id: 'g4', awayTeam: { name: 'Cubs' }, homeTeam: { name: 'Cards' }, status: 'post' }, // finished → dropped
];
it('maps upcoming games only, dropping live / finished / incomplete', () => {
const out = mapTomorrowPreview(games, 8);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ id: 'g1', away: 'Yankees', home: 'Red Sox', sport: 'MLB' });
});
it('caps at the limit and never fabricates a matchup from bad input', () => {
const many = Array.from({ length: 10 }, (_, i) => ({ id: `g${i}`, awayTeam: { name: `A${i}` }, homeTeam: { name: `H${i}` }, status: 'pre' }));
expect(mapTomorrowPreview(many, 3)).toHaveLength(3);
expect(mapTomorrowPreview(null, 5)).toEqual([]);
expect(mapTomorrowPreview(undefined, 5)).toEqual([]);
});
it('accepts the flat {away,home} shape too (dashboard schedule mapping)', () => {
const out = mapTomorrowPreview([{ id: 'z', away: 'Mets', home: 'Phillies', start_time: '2026-07-14T18:00:00Z' }], 8);
expect(out[0]).toMatchObject({ away: 'Mets', home: 'Phillies' });
});
});
describe('buildOutlook — never-empty selection', () => {
const settled = [
{ player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, sport: 'mlb' },
{ player_name: 'Low Grade', stat: 'hits', line: 0.5, side: 'over', grade: 'C', outcome: 'hit' }, // not A-tier → not a receipt
{ player_name: 'The Miss', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss' }, // a miss never becomes proof
];
const tomorrow = [{ id: 't1', awayTeam: { name: 'Mets' }, homeTeam: { name: 'Braves' }, status: 'pre', sport: 'mlb' }];
it('a network fetchError stays an ERROR state — never a fabricated outlook', () => {
expect(buildOutlook({ gamesCount: 0, fetchError: 'No games available right now.', settledRows: settled, tomorrow })).toEqual({ mode: 'error' });
});
it('live games on the board → live mode (outlook not shown)', () => {
expect(buildOutlook({ gamesCount: 3, settledRows: settled, tomorrow })).toEqual({ mode: 'live' });
});
it('no games → outlook carrying PROVEN receipts AND tomorrow preview (never blank)', () => {
const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow });
expect(o.mode).toBe('outlook');
expect(o.receipts).toHaveLength(1);
expect(o.receipts[0].player).toBe('Aaron Judge');
expect(o.receipts[0].outcome).toBe('hit');
expect(o.tomorrow).toHaveLength(1);
expect(o.tomorrow[0].home).toBe('Braves');
expect(o.hasContent).toBe(true);
});
it('no games, no proof, no schedule → STILL an outlook surface (the header carries it), never error / never blank', () => {
const o = buildOutlook({ gamesCount: 0, settledRows: [], tomorrow: [] });
expect(o.mode).toBe('outlook');
expect(o.receipts).toEqual([]);
expect(o.tomorrow).toEqual([]);
expect(o.hasContent).toBe(false);
});
it('receipts alone (no tomorrow schedule) still fills the grid', () => {
const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow: [] });
expect(o.mode).toBe('outlook');
expect(o.receipts).toHaveLength(1);
expect(o.tomorrow).toEqual([]);
expect(o.hasContent).toBe(true);
});
});
// The dead-end CTA cards must be gone: the grid now renders an Outlook surface.
describe('source: the slate + dashboard render the Outlook surface (not a NO-SLATE dead-end)', () => {
const fs = require('fs');
const path = require('path');
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
it('Slate.tsx wires buildOutlook / the Outlook surface into the empty branch', () => {
const src = read('components/Slate.tsx');
expect(src).toMatch(/OutlookSurface|buildOutlook/);
});
it('dashboard renders the Outlook surface instead of the "NO SLATE" CTA', () => {
const src = read('app/dashboard/page.tsx');
expect(src).toMatch(/OutlookSurface|buildOutlook|mapTomorrowPreview/);
});
});
+98 -15
View File
@@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule';
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
// paint ALWAYS proves the model (#1, #13, Part 6).
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
// Wave 4A (Step 3) — OUTLOOK MODE: the "Today's games" empty branch is never a
// dead-end CTA; it shows yesterday's proven receipts + tomorrow's real schedule.
import { buildOutlook } from '@/lib/outlook';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { currentAccessToken } from '@/lib/authToken';
@@ -109,6 +112,98 @@ const SPORT_COLOR: Record<Sport, string> = {
WNBA: '#FFB347',
};
/**
* Wave 4A (Step 3) — the dashboard OUTLOOK surface for an empty slate. Replaces
* the old "NO SLATE" dead-end CTA: the month-aware header + yesterday's PROVEN
* A-tier receipts + tomorrow's date-pinned schedule preview (all REAL,
* always-available data — never an invented line). Self-fetches both; the
* header always renders, so the grid is never blank.
*/
function DashboardOutlook({ sport }: { sport: Sport }) {
const [settled, setSettled] = useState<ModelEntry[] | null>(null);
const [tomorrow, setTomorrow] = useState<ScheduleApiGame[] | null>(null);
useEffect(() => {
let active = true;
fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
.catch(() => { if (active) setSettled([]); });
return () => { active = false; };
}, [sport]);
useEffect(() => {
let active = true;
// Tomorrow, ET — the schedule route is date-pinned (free / cached).
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(Date.now() + 86_400_000));
fetch(`/api/schedule/${sport.toLowerCase()}?date=${date}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active) setTomorrow((Array.isArray(d?.games) ? d.games : []).map((g: ScheduleApiGame) => ({ ...g, sport: sport.toLowerCase() }))); })
.catch(() => { if (active) setTomorrow([]); });
return () => { active = false; };
}, [sport]);
const outlook = buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceiptView[]; tomorrow?: OutlookGameView[] };
const receipts: OutlookReceiptView[] = outlook.receipts ?? [];
const preview: OutlookGameView[] = outlook.tomorrow ?? [];
const { title, body } = emptyStateCopy(sport.toLowerCase());
return (
<div className="surface diagonal-cut tex-scan" style={{ padding: 24, display: 'grid', gap: 16 }}>
<div>
<p className="mono lbl" style={{ color: 'var(--amber)' }}>OUTLOOK</p>
<h3 style={{ fontSize: 18, fontWeight: 700, marginTop: 4 }}>{title}</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>{body}</p>
</div>
{receipts.length > 0 && (
<div>
<div className="mono lbl" style={{ color: 'var(--grade-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
{receipts.map((r: OutlookReceiptView, i: number) => (
<div key={`${r.player}-${r.stat}-${i}`} className="surface" style={{ minWidth: 190, padding: 14, border: '1px solid var(--grade-a)', borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--grade-a)' }}>{String(r.sport).toUpperCase()}</span>
<GradeBadge grade={r.grade} size={28} glow />
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.player}</div>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', fontVariantNumeric: 'tabular-nums' }}>
{String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, color: 'var(--grade-a)', marginTop: 6, fontVariantNumeric: 'tabular-nums' }}>
&#10003; HIT{r.actual != null ? ` (${r.actual})` : ''}
</p>
</div>
))}
</div>
</div>
)}
{preview.length > 0 && (
<div>
<div className="mono lbl" style={{ color: 'var(--text-tertiary)', marginBottom: 10 }}>TOMORROW · SCHEDULE lines post on the day</div>
<div style={{ display: 'grid', gap: 8 }}>
{preview.map((g: OutlookGameView) => (
<div key={g.id} className="mono" style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 13, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700 }}>{g.away} @ {g.home}</span>
{g.time && <span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{formatTime(g.time)}</span>}
</div>
))}
</div>
</div>
)}
<div>
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger </a>
</div>
</div>
);
}
// Render-only views of the buildOutlook output (the JS adapter has no TS types).
interface OutlookReceiptView { player: string; stat: string; line: number; side: string; grade: string; sport: string; actual: number | null }
interface OutlookGameView { id: string; away: string; home: string; time: string | null }
export default function DashboardPage() {
const router = useRouter();
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
@@ -431,21 +526,9 @@ export default function DashboardPage() {
{games === null ? (
<SkeletonRow stacked />
) : games.length === 0 ? (
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
// an off-season sport says when it returns; in-season = off-day.
<div
className="surface diagonal-cut tex-scan"
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>{emptyStateCopy(sport.toLowerCase()).title}</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
{emptyStateCopy(sport.toLowerCase()).body}
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger </a>
</div>
</div>
// Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA.
// Yesterday's proven receipts + tomorrow's real schedule fill the grid.
<DashboardOutlook sport={sport} />
) : (
<div style={{ display: 'grid', gap: 12 }}>
{games.map((g, idx) => (
+6
View File
@@ -83,6 +83,12 @@ export default function DeskShowcase() {
</div>
</div>
{/* Wave 4A (Step 4) — this claim is now BACKED by a real feature: the
CONSENSUS vs MODEL strip (components/vyndr/MarketBreadth, fed by
lib/marketBreadth.collectBreadth) ships on the live slate/dashboard,
computing the median book line vs the model's projection. "live
line moves" is the existing snapshot line-deltas / LineSparkline.
No longer an empty promise — do not remove without removing those. */}
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
+177 -12
View File
@@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation';
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter';
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
// live games (and it's not a fetch failure) the grid shows REAL data —
// yesterday's proven receipts + tomorrow's date-pinned schedule.
import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook';
// Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model.
import { collectBreadth } from '@/lib/marketBreadth';
import MarketBreadth from '@/components/vyndr/MarketBreadth';
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
// Grades never change in-game; these marks are tracking, labeled as such.
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
@@ -165,7 +172,7 @@ interface StreakApiRow {
interface StreaksResponse { streaks?: StreakApiRow[] }
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
@@ -419,6 +426,119 @@ function YesterdaySettle({ date }: { date: string }) {
);
}
// The /api/ledger/model settled-row shape (subset the receipts read).
interface ModelReceiptRow {
player_name?: string; player?: string; sport?: string; stat?: string;
line?: number; side?: string; grade?: string;
outcome?: string | null; actual_value?: number | null; clv_result?: string | null;
}
// buildHeroReceipts output (proven yesterday hit).
interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null }
// mapTomorrowPreview output.
interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null }
/**
* Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in
* place of the old dead-end CTA: yesterday's PROVEN A-tier receipts +
* tomorrow's date-pinned schedule preview (both REAL, always-available data —
* never an invented line). The month-aware header ALWAYS shows, so the grid is
* never blank. Distinct from the network `fetchError` state.
*/
function OutlookSurface({ tab }: { tab: SlateTab }) {
const [settled, setSettled] = useState<ModelReceiptRow[] | null>(null);
const [tomorrow, setTomorrow] = useState<ScheduleGame[] | null>(null);
useEffect(() => {
let active = true;
const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : '';
fetch(`/api/ledger/model?limit=80${sportQ}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
.catch(() => { if (active) setSettled([]); });
return () => { active = false; };
}, [tab]);
useEffect(() => {
let active = true;
const date = etDateWithOffset(1); // tomorrow, ET — the schedule route is date-pinned
const SPORTS: SlateSport[] = tab === 'all'
? ['mlb', 'nba', 'wnba']
: (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : [];
if (SPORTS.length === 0) { setTomorrow([]); return; }
Promise.all(SPORTS.map(async (sport) => {
try {
const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' });
if (!r.ok) return [] as ScheduleGame[];
const d = (await r.json()) as ScheduleResponse;
return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport }));
} catch { return [] as ScheduleGame[]; }
})).then((lists) => { if (active) setTomorrow(lists.flat()); });
return () => { active = false; };
}, [tab]);
const outlook = useMemo(
() => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] },
[settled, tomorrow],
);
const receipts: OutlookReceipt[] = outlook.receipts ?? [];
const preview: OutlookGame[] = outlook.tomorrow ?? [];
const { title, body } = emptyStateCopy(tab);
return (
<section className="surface" style={{ border: '1px solid var(--border, #1A1A24)', borderRadius: 8, padding: 20, marginBottom: 16 }}>
<div style={{ marginBottom: receipts.length || preview.length ? 18 : 0 }}>
<p className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.16em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>OUTLOOK</p>
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 4 }}>{title}</p>
<p style={{ color: 'var(--text-secondary, #8A8A9A)', fontSize: 13 }}>{body}</p>
</div>
{receipts.length > 0 && (
<div style={{ marginBottom: preview.length ? 18 : 0 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
{receipts.map((r, i) => (
<div
key={`${r.player}-${r.stat}-${i}`}
className="mono"
style={{ minWidth: 178, padding: 12, border: '1px solid var(--g-a)', borderRadius: 10, background: 'var(--bg-surface, #101018)', fontVariantNumeric: 'tabular-nums' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)' }}>{String(r.sport || '').toUpperCase()}</span>
<span style={{ fontSize: 13, fontWeight: 800, color: 'var(--g-a)' }}>{r.grade}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', marginBottom: 3 }}>{r.player}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-1)' }}>
{String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
</div>
<div style={{ fontSize: 11.5, fontWeight: 800, color: 'var(--g-a)', marginTop: 6 }}>
HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''}
</div>
</div>
))}
</div>
</div>
)}
{preview.length > 0 && (
<div>
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 10 }}>
TOMORROW · SCHEDULE <span style={{ color: 'var(--text-2)', fontWeight: 600 }}> lines post on the day</span>
</div>
<div style={{ display: 'grid', gap: 7 }}>
{preview.map((g) => (
<div key={g.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
{g.sport && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', minWidth: 30 }}>{g.sport}</span>}
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{g.away} @ {g.home}</span>
{g.time && <span style={{ color: 'var(--text-2)' }}>{formatGameTime(g.time)}</span>}
</div>
))}
</div>
</div>
)}
</section>
);
}
export interface SlateProps {
initialTab?: SlateTab;
tier?: Tier;
@@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
)) as SlateGame[];
}, [filteredGames, gradeIndex, liveIndex]);
// Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that
// carries ≥2 book lines, compare the market's median line to the model's
// projection (signed by the graded side). collectBreadth drops <2-book props
// and ranks by |edge| — an empty result self-hides the strip. This is the
// REAL data behind the DeskShowcase "consensus vs model" claim.
const breadthItems = useMemo(() => {
const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = [];
for (const g of filteredGames) {
for (const p of g.props) {
if (!Array.isArray(p.books) || p.books.length < 2) continue;
const grade = (gradeIndex as Record<string, SnapshotGrade>)[gradeKey(p.player, p.stat_type)];
const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null;
items.push({
player: p.player,
stat: p.stat_type,
side: (grade && grade.direction) || p.direction || 'over',
line: p.line,
books: p.books,
modelValue: mv == null ? null : Number(mv),
});
}
}
return collectBreadth(items, 6);
}, [filteredGames, gradeIndex]);
// Session 25 — per-sport game counts for the tab labels, derived from
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
// free ESPN schedule even when odds are empty. Counts only appear for
@@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
</div>
)}
{!loading && !fetchError && filteredGames.length === 0 && (
{/* A search miss keeps its own scan-it CTA. */}
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
<div
className="surface"
style={{
@@ -976,8 +1122,6 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
color: 'var(--text-secondary, #8A8A9A)',
}}
>
{searchQuery ? (
<>
<p style={{ marginBottom: 12 }}>
No props found for &ldquo;{searchQuery}&rdquo;.
</p>
@@ -997,11 +1141,29 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
>
Scan it manually
</a>
</>
) : (
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
// off-season sports name their return window; in-season = off-day.
(() => {
</div>
)}
{/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA:
when there are no live games (and no search, no fetch failure) it shows
yesterday's proven receipts + tomorrow's real schedule. Yesterday/
Tomorrow date nav keep the plain honest copy (those are explicit date
surfaces; -1 already has THE SETTLE below). */}
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && (
<OutlookSurface tab={tab} />
)}
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && (
<div
className="surface"
style={{
padding: 28,
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
{(() => {
const { title, body } = emptyStateCopy(tab);
return (
<>
@@ -1009,13 +1171,16 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
<p>{body}</p>
</>
);
})()
)}
})()}
</div>
)}
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
{/* Wave 4A (Step 4) — the CONSENSUS vs MODEL strip. Self-hides unless a
graded prop has a real ≥2-book median to compare the model against. */}
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
<div style={{ display: 'grid', gap: 16 }}>
{orderedGames.map((g, i) => (
<VyndrGameCard
+113
View File
@@ -0,0 +1,113 @@
'use client';
/**
* MarketBreadth (Wave 4A · Step 4) — the CONSENSUS-vs-MODEL strip that makes
* the DeskShowcase "consensus vs model, live line moves" claim REAL.
*
* Fed pre-computed breadth rows (from `lib/marketBreadth.collectBreadth`): each
* row carries the market CONSENSUS (median book line across ≥2 books) and the
* model's position vs it, SIGNED by the graded side. Colored by the ONE color
* contract (`edgeColor`): model-beats-market → signal-green, model-behind →
* muted red, inline → neutral. Mono, tabular — it's data.
*
* SELF-HIDING: no rows (nothing has ≥2 books) → renders null. It never shows an
* empty promise; the honest surface only appears when there's a real consensus.
*/
import SectionHead from './SectionHead';
import { edgeColor } from '@/lib/colorContract';
export interface BreadthRow {
player?: string;
stat?: string;
line?: number | null;
consensus: number | null;
bookCount: number;
model: number | null;
delta: number | null;
signedEdge: number | null;
// Widened to string|null: collectBreadth is untyped JS, so its inferred
// return widens these; the values are only ever 'above'|'below'|'inline'.
position: string | null;
side: string;
}
const STAT_LABEL: Record<string, string> = {
total_bases: 'TB', home_runs: 'HR', hits: 'H', rbi: 'RBI', runs: 'R',
strikeouts: 'K', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP',
stolen_bases: 'SB', points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT',
};
function statLabel(stat?: string): string {
if (!stat) return '';
return STAT_LABEL[stat] || String(stat).replace(/_/g, ' ').toUpperCase();
}
function fmtSigned(n: number | null): string {
if (n == null) return '';
const s = n > 0 ? '+' : n < 0 ? '' : '±';
return `${s}${n}`;
}
export default function MarketBreadth({
items,
title = 'CONSENSUS vs MODEL',
max = 6,
}: {
items?: BreadthRow[] | null;
title?: string;
max?: number;
}) {
const rows = (Array.isArray(items) ? items : []).slice(0, Math.max(0, max));
if (rows.length === 0) return null; // self-hide — no honest consensus to show
return (
<section
style={{
background: 'var(--bg-1)',
border: '1px solid var(--border)',
borderRadius: 10,
padding: 16,
marginBottom: 16,
}}
>
<SectionHead style={{ marginBottom: 12 }}>
{title}
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 9, marginLeft: 8, letterSpacing: '0.14em' }}>
MEDIAN BOOK LINE · MODEL EDGE
</span>
</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r, i) => {
const col = edgeColor(r.signedEdge);
const sideChar = r.side === 'under' ? 'u' : 'o';
return (
<div
key={`${r.player}-${r.stat}-${i}`}
className="mono"
style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap', fontVariantNumeric: 'tabular-nums' }}
>
{r.player && <span style={{ color: 'var(--text-0)', fontWeight: 700, minWidth: 96 }}>{r.player}</span>}
<span style={{ color: 'var(--text-1)' }}>
{statLabel(r.stat)} {sideChar}{r.line ?? r.consensus}
</span>
<span style={{ color: 'var(--text-2)' }}>
CONSENSUS <span style={{ color: 'var(--text-0)' }}>{r.consensus}</span>
<span style={{ color: 'var(--text-2)' }}> · {r.bookCount} BOOKS</span>
</span>
{r.model != null ? (
<span style={{ color: 'var(--text-2)' }}>
MODEL <span style={{ color: 'var(--text-0)' }}>{r.model}</span>
{r.signedEdge != null && (
<span style={{ color: col, fontWeight: 800 }}> · {fmtSigned(r.signedEdge)}</span>
)}
</span>
) : (
<span style={{ color: 'var(--text-2)' }}>MODEL </span>
)}
</div>
);
})}
</div>
</section>
);
}
+1
View File
@@ -9,6 +9,7 @@ export { default as Card } from './Card';
export { default as Sparkline } from './Sparkline';
export { default as Ticker } from './Ticker';
export { default as EmptyState } from './EmptyState';
export { default as MarketBreadth } from './MarketBreadth';
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
export { default as GradeResultCard } from './GradeResultCard';
export type { GradeResultData } from './GradeResultCard';
+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,