Merge Wave 4A (wiring/data): outlook mode + market-breadth consensus
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
|
||||
// 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' }}>
|
||||
✓ 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) => (
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
+203
-38
@@ -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,46 +1122,65 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
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.
|
||||
(() => {
|
||||
const { title, body } = emptyStateCopy(tab);
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<p>{body}</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Scan it manually →
|
||||
</a>
|
||||
</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 (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<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
|
||||
|
||||
@@ -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 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';
|
||||
|
||||
@@ -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,
|
||||
indexGrades,
|
||||
indexDeltas,
|
||||
gradeKey,
|
||||
statShort,
|
||||
gradedAgo,
|
||||
buildPlayerStripsFromProps,
|
||||
|
||||
Reference in New Issue
Block a user