diff --git a/tests/unit/marketBreadth.test.js b/tests/unit/marketBreadth.test.js new file mode 100644 index 0000000..973b144 --- /dev/null +++ b/tests/unit/marketBreadth.test.js @@ -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/); + }); +}); diff --git a/tests/unit/outlook.test.js b/tests/unit/outlook.test.js new file mode 100644 index 0000000..c35be82 --- /dev/null +++ b/tests/unit/outlook.test.js @@ -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/); + }); +}); diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 49288af..d9682da 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -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 = { 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(null); + const [tomorrow, setTomorrow] = useState(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 ( +
+
+

OUTLOOK

+

{title}

+

{body}

+
+ + {receipts.length > 0 && ( +
+
YESTERDAY · PROVEN
+
+ {receipts.map((r: OutlookReceiptView, i: number) => ( +
+
+ {String(r.sport).toUpperCase()} + +
+
{r.player}
+

+ {String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')} +

+

+ ✓ HIT{r.actual != null ? ` (${r.actual})` : ''} +

+
+ ))} +
+
+ )} + + {preview.length > 0 && ( +
+
TOMORROW · SCHEDULE — lines post on the day
+
+ {preview.map((g: OutlookGameView) => ( +
+ {g.away} @ {g.home} + {g.time && {formatTime(g.time)}} +
+ ))} +
+
+ )} + +
+ View the Ledger → +
+
+ ); +} + +// 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 ? ( ) : 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. -
-

NO SLATE

-

{emptyStateCopy(sport.toLowerCase()).title}

-

- {emptyStateCopy(sport.toLowerCase()).body} -

- -
+ // Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA. + // Yesterday's proven receipts + tomorrow's real schedule fill the grid. + ) : (
{games.map((g, idx) => ( diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx index 9e9e236..31c0b8f 100644 --- a/web/src/app/pricing/DeskShowcase.tsx +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -83,6 +83,12 @@ export default function DeskShowcase() {
+ {/* 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. */}
diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index 2ab4b71..b3192f4 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -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(null); + const [tomorrow, setTomorrow] = useState(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 ( +
+
+

OUTLOOK

+

{title}

+

{body}

+
+ + {receipts.length > 0 && ( +
+
YESTERDAY · PROVEN
+
+ {receipts.map((r, i) => ( +
+
+ {String(r.sport || '').toUpperCase()} + {r.grade} +
+
{r.player}
+
+ {String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')} +
+
+ ✓ HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''} +
+
+ ))} +
+
+ )} + + {preview.length > 0 && ( +
+
+ TOMORROW · SCHEDULE — lines post on the day +
+
+ {preview.map((g) => ( +
+ {g.sport && {g.sport}} + {g.away} @ {g.home} + {g.time && {formatGameTime(g.time)}} +
+ ))} +
+
+ )} +
+ ); +} + 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)[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
)} - {!loading && !fetchError && filteredGames.length === 0 && ( + {/* A search miss keeps its own scan-it CTA. */} + {!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
- {searchQuery ? ( - <> -

- No props found for “{searchQuery}”. -

- - Scan it manually → - - - ) : ( - // Session 57 (Phase 0) — honest per-sport empty copy (spec §6): - // off-season sports name their return window; in-season = off-day. - (() => { - const { title, body } = emptyStateCopy(tab); - return ( - <> -

{title}

-

{body}

- - ); - })() - )} +

+ No props found for “{searchQuery}”. +

+ + Scan it manually → + +
+ )} + + {/* 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 && ( + + )} + {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && ( +
+ {(() => { + const { title, body } = emptyStateCopy(tab); + return ( + <> +

{title}

+

{body}

+ + ); + })()}
)} {dateOffset === -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 && } +
{orderedGames.map((g, i) => ( = { + 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 ( +
+ + {title} + + MEDIAN BOOK LINE · MODEL EDGE + + +
+ {rows.map((r, i) => { + const col = edgeColor(r.signedEdge); + const sideChar = r.side === 'under' ? 'u' : 'o'; + return ( +
+ {r.player && {r.player}} + + {statLabel(r.stat)} {sideChar}{r.line ?? r.consensus} + + + CONSENSUS {r.consensus} + · {r.bookCount} BOOKS + + {r.model != null ? ( + + MODEL {r.model} + {r.signedEdge != null && ( + · {fmtSigned(r.signedEdge)} + )} + + ) : ( + MODEL — + )} +
+ ); + })} +
+
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index abc0ddf..1dd53a3 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -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'; diff --git a/web/src/lib/marketBreadth.js b/web/src/lib/marketBreadth.js new file mode 100644 index 0000000..d392fa9 --- /dev/null +++ b/web/src/lib/marketBreadth.js @@ -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 }; diff --git a/web/src/lib/outlook.js b/web/src/lib/outlook.js new file mode 100644 index 0000000..2f2fa13 --- /dev/null +++ b/web/src/lib/outlook.js @@ -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 }; diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index f0ba6f4..82c1b20 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -603,6 +603,7 @@ module.exports = { isRelevantGame, indexGrades, indexDeltas, + gradeKey, statShort, gradedAgo, buildPlayerStripsFromProps,