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,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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
|||||||
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
|
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
|
||||||
// paint ALWAYS proves the model (#1, #13, Part 6).
|
// paint ALWAYS proves the model (#1, #13, Part 6).
|
||||||
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
|
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 GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import { currentAccessToken } from '@/lib/authToken';
|
import { currentAccessToken } from '@/lib/authToken';
|
||||||
|
|
||||||
@@ -109,6 +112,98 @@ const SPORT_COLOR: Record<Sport, string> = {
|
|||||||
WNBA: '#FFB347',
|
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' }}>
|
||||||
|
✓ 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() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
|
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||||
@@ -431,21 +526,9 @@ export default function DashboardPage() {
|
|||||||
{games === null ? (
|
{games === null ? (
|
||||||
<SkeletonRow stacked />
|
<SkeletonRow stacked />
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
|
// Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA.
|
||||||
// an off-season sport says when it returns; in-season = off-day.
|
// Yesterday's proven receipts + tomorrow's real schedule fill the grid.
|
||||||
<div
|
<DashboardOutlook sport={sport} />
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{games.map((g, idx) => (
|
{games.map((g, idx) => (
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export default function DeskShowcase() {
|
|||||||
</div>
|
</div>
|
||||||
</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 }}>
|
<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="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
|
||||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
||||||
|
|||||||
+177
-12
@@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation';
|
|||||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
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.
|
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
|
||||||
// Grades never change in-game; these marks are tracking, labeled as such.
|
// Grades never change in-game; these marks are tracking, labeled as such.
|
||||||
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
||||||
@@ -165,7 +172,7 @@ interface StreakApiRow {
|
|||||||
interface StreaksResponse { streaks?: StreakApiRow[] }
|
interface StreaksResponse { streaks?: StreakApiRow[] }
|
||||||
|
|
||||||
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
|
// 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 SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
|
||||||
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
|
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 {
|
export interface SlateProps {
|
||||||
initialTab?: SlateTab;
|
initialTab?: SlateTab;
|
||||||
tier?: Tier;
|
tier?: Tier;
|
||||||
@@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
)) as SlateGame[];
|
)) as SlateGame[];
|
||||||
}, [filteredGames, gradeIndex, liveIndex]);
|
}, [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
|
// 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
|
// 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
|
// 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !fetchError && filteredGames.length === 0 && (
|
{/* A search miss keeps its own scan-it CTA. */}
|
||||||
|
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
|
||||||
<div
|
<div
|
||||||
className="surface"
|
className="surface"
|
||||||
style={{
|
style={{
|
||||||
@@ -976,8 +1122,6 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
color: 'var(--text-secondary, #8A8A9A)',
|
color: 'var(--text-secondary, #8A8A9A)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{searchQuery ? (
|
|
||||||
<>
|
|
||||||
<p style={{ marginBottom: 12 }}>
|
<p style={{ marginBottom: 12 }}>
|
||||||
No props found for “{searchQuery}”.
|
No props found for “{searchQuery}”.
|
||||||
</p>
|
</p>
|
||||||
@@ -997,11 +1141,29 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
>
|
>
|
||||||
Scan it manually →
|
Scan it manually →
|
||||||
</a>
|
</a>
|
||||||
</>
|
</div>
|
||||||
) : (
|
)}
|
||||||
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
|
|
||||||
// off-season sports name their return window; in-season = off-day.
|
{/* 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);
|
const { title, body } = emptyStateCopy(tab);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1009,13 +1171,16 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
<p>{body}</p>
|
<p>{body}</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()
|
})()}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
|
{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 }}>
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
{orderedGames.map((g, i) => (
|
{orderedGames.map((g, i) => (
|
||||||
<VyndrGameCard
|
<VyndrGameCard
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export { default as Card } from './Card';
|
|||||||
export { default as Sparkline } from './Sparkline';
|
export { default as Sparkline } from './Sparkline';
|
||||||
export { default as Ticker } from './Ticker';
|
export { default as Ticker } from './Ticker';
|
||||||
export { default as EmptyState } from './EmptyState';
|
export { default as EmptyState } from './EmptyState';
|
||||||
|
export { default as MarketBreadth } from './MarketBreadth';
|
||||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||||
export { default as GradeResultCard } from './GradeResultCard';
|
export { default as GradeResultCard } from './GradeResultCard';
|
||||||
export type { GradeResultData } from './GradeResultCard';
|
export type { GradeResultData } from './GradeResultCard';
|
||||||
|
|||||||
@@ -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,
|
isRelevantGame,
|
||||||
indexGrades,
|
indexGrades,
|
||||||
indexDeltas,
|
indexDeltas,
|
||||||
|
gradeKey,
|
||||||
statShort,
|
statShort,
|
||||||
gradedAgo,
|
gradedAgo,
|
||||||
buildPlayerStripsFromProps,
|
buildPlayerStripsFromProps,
|
||||||
|
|||||||
Reference in New Issue
Block a user