DS2: Dashboard Slate Rebuild — one hero, pending collapse, never-empty hero
DESIGN-SPEC Parts 3 + 6 (audit #1, #13, #14). The founder's named #1 rebuild. slateAdapter.js — the testable engine: - selectTopGrades: rank tonight's grades by tier → confidence → edge so the row varies on a real signal, not identical-weight noise (#13). - buildHeroReceipts: yesterday's PROVEN A-tier settled HITS (misses excluded), carrying the real result — the never-empty proof source (#1, Part 6). - heroFallbackState: tonight wins, else receipts, else empty. - pendingSummary: collapse an all-awaiting card's six "Grades post …" rows to ONE line count (#14). - topReadForCard: the single best live graded read to promote (#2). GameCard.tsx — ONE bold hero per card (large mono/tabular grade + player, rest demoted); all-awaiting cards render one "N props pending · grade ~X ET" line via nextRunLabelET instead of repeated filler. Real team logos + team-colored accent already lead the card (DS0) — preserved. dashboard/page.tsx — Top grades tonight ranked via selectTopGrades (+ % CONF the varying signal); when tonight is empty, fetch /api/ledger/model and fall back to yesterday's PROVEN A-tier receipts (✓ HIT + actual + CLV) so first paint always proves the model. Honest nextRunLabelET copy kept for the truly-empty case (QA.22). Tests: tests/unit/ds2Dashboard.test.js (21) — pure-fn + source assertions, fail-before / pass-after. Full suite 237 suites / 2863 tests green (+21). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// DS2 (Design v2) — Dashboard Slate Rebuild. The founder's named #1 rebuild:
|
||||
// real entities per card, ONE bold hero, collapse pending-filler (#14), and the
|
||||
// NEVER-EMPTY hero (#1, Part 6) — first paint ALWAYS proves the model. The pure
|
||||
// engine lives in web/src/lib/slateAdapter.js (unit-tested directly); the .tsx
|
||||
// surfaces are asserted against source (plain-JS Jest) — same pattern as
|
||||
// ds4Billboards / vyndrParityQA.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const WEB = path.join(ROOT, 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const adapter = require('../../web/src/lib/slateAdapter');
|
||||
|
||||
// ── 1. selectTopGrades — ranking VARIES on a real signal (#13) ─────────────
|
||||
describe('selectTopGrades — tier first, then the varying signal (confidence/edge)', () => {
|
||||
test('orders by grade tier, then confidence desc, then edge desc', () => {
|
||||
const grades = [
|
||||
{ player: 'C-low', grade: 'B', confidence: 40, edge: 1 },
|
||||
{ player: 'A-hiconf', grade: 'A', confidence: 90, edge: 2 },
|
||||
{ player: 'A-loconf', grade: 'A', confidence: 55, edge: 9 },
|
||||
{ player: 'Aplus', grade: 'A+', confidence: 10, edge: 0 },
|
||||
];
|
||||
const out = adapter.selectTopGrades(grades, 10).map((g) => g.player);
|
||||
expect(out).toEqual(['Aplus', 'A-hiconf', 'A-loconf', 'C-low']);
|
||||
});
|
||||
|
||||
test('ties on grade break on confidence — rows are NOT identical-order noise', () => {
|
||||
const grades = [
|
||||
{ player: 'X', grade: 'B', confidence: 30 },
|
||||
{ player: 'Y', grade: 'B', confidence: 80 },
|
||||
{ player: 'Z', grade: 'B', confidence: 55 },
|
||||
];
|
||||
expect(adapter.selectTopGrades(grades, 10).map((g) => g.player)).toEqual(['Y', 'Z', 'X']);
|
||||
});
|
||||
|
||||
test('honors the limit and drops gradeless rows; empty in → empty out', () => {
|
||||
const grades = [{ grade: 'A', confidence: 1 }, { grade: 'A', confidence: 2 }, { confidence: 9 }];
|
||||
expect(adapter.selectTopGrades(grades, 1)).toHaveLength(1);
|
||||
expect(adapter.selectTopGrades([], 5)).toEqual([]);
|
||||
expect(adapter.selectTopGrades(null, 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. buildHeroReceipts — yesterday's A-tier settled HITS (#1) ────────────
|
||||
describe('buildHeroReceipts — proven A-tier receipts, misses excluded', () => {
|
||||
const rows = [
|
||||
{ player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, clv_result: 'beat', sport: 'mlb' },
|
||||
{ player_name: 'Missed Guy', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss', actual_value: 0 },
|
||||
{ player_name: 'B Guy', stat: 'runs', line: 0.5, side: 'over', grade: 'B', outcome: 'hit', actual_value: 1 },
|
||||
{ player_name: 'Aminus Hit', stat: 'rbi', line: 0.5, side: 'over', grade: 'A-', outcome: 'hit', actual_value: 2 },
|
||||
];
|
||||
|
||||
test('keeps only A-family grades that HIT (a miss never becomes proof)', () => {
|
||||
const out = adapter.buildHeroReceipts(rows, 8);
|
||||
const names = out.map((r) => r.player);
|
||||
expect(names).toContain('Aaron Judge');
|
||||
expect(names).toContain('Aminus Hit');
|
||||
expect(names).not.toContain('Missed Guy'); // A-tier but a MISS
|
||||
expect(names).not.toContain('B Guy'); // a hit but not A-tier
|
||||
});
|
||||
|
||||
test('carries the real result (actual + grade) so the receipt is verifiable', () => {
|
||||
const judge = adapter.buildHeroReceipts(rows, 8).find((r) => r.player === 'Aaron Judge');
|
||||
expect(judge).toMatchObject({ grade: 'A+', outcome: 'hit', actual: 3 });
|
||||
});
|
||||
|
||||
test('best-grade first, honors the limit, empty/absent → []', () => {
|
||||
expect(adapter.buildHeroReceipts(rows, 8)[0].grade).toBe('A+');
|
||||
expect(adapter.buildHeroReceipts(rows, 1)).toHaveLength(1);
|
||||
expect(adapter.buildHeroReceipts([], 5)).toEqual([]);
|
||||
expect(adapter.buildHeroReceipts(null, 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. heroFallbackState — the NEVER-EMPTY hero engine (#1, Part 6) ────────
|
||||
describe('heroFallbackState — tonight wins, else receipts, never empty when proof exists', () => {
|
||||
const settled = [{ player_name: 'Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A', outcome: 'hit', actual_value: 3 }];
|
||||
|
||||
test('tonight has graded props → mode tonight', () => {
|
||||
const s = adapter.heroFallbackState([{ player: 'P', grade: 'A', confidence: 70 }], settled, 10);
|
||||
expect(s.mode).toBe('tonight');
|
||||
expect(s.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('tonight EMPTY but yesterday has A-tier hits → mode receipts (proof, not empty)', () => {
|
||||
const s = adapter.heroFallbackState([], settled, 10);
|
||||
expect(s.mode).toBe('receipts');
|
||||
expect(s.items[0].player).toBe('Judge');
|
||||
});
|
||||
|
||||
test('only truly empty when BOTH are absent', () => {
|
||||
expect(adapter.heroFallbackState([], [], 10).mode).toBe('empty');
|
||||
expect(adapter.heroFallbackState(null, null, 10).mode).toBe('empty');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. pendingSummary — collapse the dead "Grades post" repetition (#14) ──
|
||||
describe('pendingSummary — six pending rows collapse to ONE line', () => {
|
||||
test('all-awaiting card → one summary counting props + players', () => {
|
||||
const strips = [
|
||||
{ player: 'One', props: [{ stat: 'TB', line: 1.5, awaiting: true }, { stat: 'HR', line: 0.5, awaiting: true }] },
|
||||
{ player: 'Two', props: [{ stat: 'Hits', line: 0.5, awaiting: true }] },
|
||||
];
|
||||
const s = adapter.pendingSummary(strips);
|
||||
expect(s).not.toBeNull();
|
||||
expect(s.count).toBe(3);
|
||||
expect(s.players).toBe(2);
|
||||
});
|
||||
|
||||
test('any graded prop present → no collapse (the card has real reads to show)', () => {
|
||||
const strips = [
|
||||
{ player: 'One', props: [{ stat: 'TB', line: 1.5, grade: 'A' }] },
|
||||
{ player: 'Two', props: [{ stat: 'Hits', line: 0.5, awaiting: true }] },
|
||||
];
|
||||
expect(adapter.pendingSummary(strips)).toBeNull();
|
||||
});
|
||||
|
||||
test('no awaiting props at all → null', () => {
|
||||
expect(adapter.pendingSummary([{ player: 'One', props: [] }])).toBeNull();
|
||||
expect(adapter.pendingSummary([])).toBeNull();
|
||||
expect(adapter.pendingSummary(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. topReadForCard — the ONE bold hero per card (#2) ────────────────────
|
||||
describe('topReadForCard — the single best graded prop leads the card', () => {
|
||||
const strips = [
|
||||
{ player: 'Mid', team: 'NYY', props: [{ stat: 'Hits', line: 0.5, side: 'O', grade: 'B' }] },
|
||||
{ player: 'Best', team: 'BOS', archetype: { primary: 'BOMBER' }, props: [{ stat: 'TB', line: 1.5, side: 'O', grade: 'A+' }] },
|
||||
{ player: 'Dead', team: 'LAD', props: [{ stat: 'HR', line: 0.5, side: 'O', grade: 'A+', dead: true }] },
|
||||
];
|
||||
|
||||
test('picks the highest-tier live grade (dead reads never lead)', () => {
|
||||
const top = adapter.topReadForCard(strips);
|
||||
expect(top).not.toBeNull();
|
||||
expect(top.player).toBe('Best');
|
||||
expect(top.grade).toBe('A+');
|
||||
expect(top.stat).toBe('TB');
|
||||
});
|
||||
|
||||
test('no graded props → null (nothing to promote)', () => {
|
||||
expect(adapter.topReadForCard([{ player: 'X', props: [{ stat: 'TB', line: 1, awaiting: true }] }])).toBeNull();
|
||||
expect(adapter.topReadForCard([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. GameCard.tsx — one hero + pending collapse + real entities ─────────
|
||||
describe('GameCard.tsx wires the DS2 rebuild', () => {
|
||||
const src = read('components/vyndr/GameCard.tsx');
|
||||
|
||||
test('leads with the real team entity (logo) + a team-colored accent edge', () => {
|
||||
expect(src).toContain('<TeamLogo');
|
||||
expect(src).toContain('accentColor(g.home.abbr');
|
||||
expect(src).toContain('borderLeft');
|
||||
});
|
||||
|
||||
test('promotes ONE bold hero read via topReadForCard (large mono/tabular grade)', () => {
|
||||
expect(src).toContain('topReadForCard');
|
||||
expect(src).toMatch(/fontVariantNumeric:\s*'tabular-nums'/);
|
||||
// the hero grade badge is the largest on the card (strips use size 'sm').
|
||||
expect(src).toMatch(/size=\{48\}|size="lg"/);
|
||||
});
|
||||
|
||||
test('collapses all-awaiting cards to ONE pending line (kills the #14 repetition)', () => {
|
||||
expect(src).toContain('pendingSummary');
|
||||
expect(src).toContain('nextRunLabelET');
|
||||
expect(src).toContain('props pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 7. dashboard/page.tsx — the never-empty hero + varying rankings ────────
|
||||
describe('dashboard Top grades tonight — proof-first, ranked, honest', () => {
|
||||
const src = read('app/dashboard/page.tsx');
|
||||
|
||||
test('uses the never-empty engine (heroFallbackState / buildHeroReceipts)', () => {
|
||||
expect(src).toMatch(/heroFallbackState|buildHeroReceipts/);
|
||||
});
|
||||
|
||||
test('ranks tonight grades by the varying signal (selectTopGrades) and shows confidence', () => {
|
||||
expect(src).toContain('selectTopGrades');
|
||||
expect(src).toContain('confidence');
|
||||
});
|
||||
|
||||
test('falls back to yesterday PROVEN A-tier receipts (with the settled result)', () => {
|
||||
expect(src).toContain('/api/ledger/model');
|
||||
expect(src).toMatch(/HIT|PROVEN|YESTERDAY/);
|
||||
});
|
||||
|
||||
test('still keeps the honest schedule-derived waiting copy for the truly-empty case (QA.22)', () => {
|
||||
expect(src).toContain('nextRunLabelET');
|
||||
});
|
||||
});
|
||||
+159
-47
@@ -15,6 +15,11 @@ import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
// DS2 (Design v2) — the never-empty hero engine: rank tonight's grades on a
|
||||
// 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';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { currentAccessToken } from '@/lib/authToken';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
@@ -61,6 +66,32 @@ interface ParlayLegStat {
|
||||
parlay_count: number;
|
||||
}
|
||||
|
||||
// DS2 — a PROVEN receipt (yesterday's settled A-tier hit) for the never-empty hero.
|
||||
interface HeroReceipt {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
side: string;
|
||||
grade: string;
|
||||
sport: string;
|
||||
outcome: string;
|
||||
actual: number | null;
|
||||
clvResult: string | null;
|
||||
}
|
||||
|
||||
// The /api/ledger/model settled-row shape (subset we read for receipts).
|
||||
interface ModelEntry {
|
||||
player_name?: string;
|
||||
sport?: string;
|
||||
stat?: string;
|
||||
line?: number;
|
||||
side?: string;
|
||||
grade?: string;
|
||||
outcome?: string | null;
|
||||
actual_value?: number | null;
|
||||
clv_result?: string | null;
|
||||
}
|
||||
|
||||
interface RecentScan {
|
||||
id: string;
|
||||
player_name: string;
|
||||
@@ -86,6 +117,9 @@ export default function DashboardPage() {
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [topGrades, setTopGrades] = useState<TopGrade[] | null>(null);
|
||||
// DS2 — yesterday's PROVEN receipts, fetched only when tonight has no grades
|
||||
// yet (the never-empty hero fallback). null = not yet loaded.
|
||||
const [heroReceipts, setHeroReceipts] = useState<HeroReceipt[] | null>(null);
|
||||
const [mostParlayed, setMostParlayed] = useState<ParlayLegStat[] | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[] | null>(null);
|
||||
// Session 49 — the user's primary sport tab + preferred books from prefs.
|
||||
@@ -127,6 +161,7 @@ export default function DashboardPage() {
|
||||
let cancelled = false;
|
||||
setGames(null);
|
||||
setTopGrades(null);
|
||||
setHeroReceipts(null);
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
|
||||
@@ -163,6 +198,24 @@ export default function DashboardPage() {
|
||||
};
|
||||
}, [sport]);
|
||||
|
||||
// DS2 (#1, Part 6) — the NEVER-EMPTY hero fallback. When tonight's slate has
|
||||
// no graded props yet, pull the model's most-recent settled reads and keep
|
||||
// only the PROVEN A-tier hits (buildHeroReceipts). First paint always proves
|
||||
// the model works (the real 55-19 / 74% record). Best-effort, self-hiding.
|
||||
useEffect(() => {
|
||||
if (topGrades === null || topGrades.length > 0) return; // tonight has grades → no fallback needed
|
||||
let cancelled = false;
|
||||
fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const entries: ModelEntry[] = Array.isArray(data?.entries) ? data.entries : [];
|
||||
setHeroReceipts(buildHeroReceipts(entries, 8) as HeroReceipt[]);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setHeroReceipts([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [topGrades, sport]);
|
||||
|
||||
// Most parlayed + recent scans don't depend on sport
|
||||
useEffect(() => {
|
||||
fetch('/api/props/most-parlayed')
|
||||
@@ -253,57 +306,116 @@ export default function DashboardPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top grades horizontal scroll */}
|
||||
<Section
|
||||
title="Top grades tonight"
|
||||
subtitle="The A-tier picks. Tap to read or add to parlay."
|
||||
>
|
||||
{topGrades === null ? (
|
||||
<SkeletonRow />
|
||||
) : topGrades.length === 0 ? (
|
||||
// Session 59 (work-order 2.3) — the real schedule, not passive waiting.
|
||||
<p style={emptyCopy}>No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
padding: '4px 4px 12px',
|
||||
scrollSnapType: 'x mandatory',
|
||||
}}
|
||||
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
|
||||
the varying signal (selectTopGrades: tier → confidence → edge) so the
|
||||
row isn't identical-weight noise; when tonight is empty we fall back to
|
||||
yesterday's PROVEN A-tier receipts so first paint ALWAYS proves the
|
||||
model. Only the truly-empty case shows the honest schedule copy. */}
|
||||
{(() => {
|
||||
const ranked = topGrades ? selectTopGrades(topGrades, 10) as TopGrade[] : [];
|
||||
const proofMode = topGrades !== null && ranked.length === 0 && (heroReceipts?.length ?? 0) > 0;
|
||||
return (
|
||||
<Section
|
||||
title="Top grades tonight"
|
||||
subtitle={proofMode ? 'No slate graded yet — the model’s most recent proven A-tier reads.' : 'The A-tier picks, ranked by confidence. Tap to read or add to parlay.'}
|
||||
>
|
||||
{topGrades.map((g, i) => (
|
||||
<button
|
||||
key={`${g.player}-${g.stat}-${i}`}
|
||||
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
|
||||
className="surface diagonal-cut surface-hover"
|
||||
{topGrades === null ? (
|
||||
<SkeletonRow />
|
||||
) : ranked.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
minWidth: 200,
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
scrollSnapAlign: 'start',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 16,
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
padding: '4px 4px 12px',
|
||||
scrollSnapType: 'x mandatory',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<SportPill sport={g.sport} />
|
||||
<GradePill grade={g.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
{ranked.map((g, i) => (
|
||||
<button
|
||||
key={`${g.player}-${g.stat}-${i}`}
|
||||
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
|
||||
className="surface diagonal-cut surface-hover"
|
||||
style={{
|
||||
minWidth: 200,
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
scrollSnapAlign: 'start',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 16,
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<SportPill sport={g.sport} />
|
||||
<GradePill grade={g.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
{/* #13 — surface the VARYING signal so ranked rows read as a
|
||||
real ranking, not identical noise. */}
|
||||
{g.confidence != null && (
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.04em', marginTop: 6, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{g.confidence}% CONF
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : proofMode ? (
|
||||
// The never-empty proof: yesterday's PROVEN A-tier receipts.
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
padding: '4px 4px 12px',
|
||||
scrollSnapType: 'x mandatory',
|
||||
}}
|
||||
>
|
||||
{(heroReceipts || []).map((r, i) => (
|
||||
<div
|
||||
key={`${r.player}-${r.stat}-${i}`}
|
||||
className="surface diagonal-cut"
|
||||
style={{
|
||||
minWidth: 210,
|
||||
padding: 16,
|
||||
scrollSnapAlign: 'start',
|
||||
border: '1px solid var(--grade-a)',
|
||||
borderRadius: 16,
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--grade-a)' }}>YESTERDAY · PROVEN</span>
|
||||
<GradeBadge grade={r.grade} size={30} glow />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>{r.player}</h3>
|
||||
<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: 8, fontVariantNumeric: 'tabular-nums' }}>
|
||||
✓ HIT{r.actual != null ? ` (${r.actual})` : ''}
|
||||
{r.clvResult === 'beat' ? <span style={{ color: 'var(--text-tertiary)', fontWeight: 700 }}> · CLV BEAT</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : heroReceipts === null ? (
|
||||
// tonight empty, fallback still loading → skeleton, never a text wall.
|
||||
<SkeletonRow />
|
||||
) : (
|
||||
// Truly nothing yet — the honest schedule-derived waiting copy (QA.22).
|
||||
<p style={emptyCopy}>No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.</p>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Tonight's games */}
|
||||
<Section title={`Today's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} game${games?.length === 1 ? '' : 's'} today`}>
|
||||
|
||||
@@ -10,6 +10,8 @@ import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||
import { accentColor } from '@/lib/teamMeta';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { isPreferredBook } from '@/lib/books';
|
||||
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||
|
||||
export interface GameLine {
|
||||
@@ -182,6 +184,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
const [linesExpanded, setLinesExpanded] = useState(false);
|
||||
const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]);
|
||||
const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible;
|
||||
// DS2 (#2) — the ONE bold hero per card: the single best live graded read,
|
||||
// promoted large + mono + tabular. Everything else in the strips below stays
|
||||
// demoted context. (#14) — when the card has NO graded reads yet, the awaiting
|
||||
// props collapse to ONE pending line instead of six identical rows.
|
||||
const topRead = useMemo(() => topReadForCard(g.playerStrips || []), [g.playerStrips]);
|
||||
const pending = useMemo(() => pendingSummary(g.playerStrips || []), [g.playerStrips]);
|
||||
// A1 S11 — LIVE SLATE MODE: any strip prop carrying live tracking shows the
|
||||
// once-per-card label. Grades locked pre-game NEVER change in-game.
|
||||
const isTracking = useMemo(
|
||||
@@ -321,7 +329,36 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
</span>
|
||||
)}
|
||||
</SectionHead>
|
||||
{g.playerStrips && g.playerStrips.length > 0 ? (
|
||||
{/* DS2 (#2) — ONE bold hero: the card's best read, large + mono +
|
||||
tabular. The failure mode was every value at identical weight; this
|
||||
promotes the single number that matters and demotes the rest. */}
|
||||
{topRead && !pending && (
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 11, padding: '10px 12px', borderRadius: 9, background: 'var(--bg-2)', border: '1px solid var(--border-hi)' }}
|
||||
>
|
||||
<GradeBadge grade={topRead.grade} size={48} glow />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{topRead.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{topRead.stat} {String(topRead.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{topRead.line}
|
||||
{topRead.team ? <span style={{ color: 'var(--text-2)' }}> · {topRead.team}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', flexShrink: 0 }}>TOP READ</span>
|
||||
</div>
|
||||
)}
|
||||
{/* DS2 (#14) — collapse the dead "Grades post …" repetition. An
|
||||
all-awaiting card renders ONE compact line, not six identical rows. */}
|
||||
{pending ? (
|
||||
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--text-1)', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 800, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums' }}>{pending.count}</span>
|
||||
<span>props pending</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>·</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>{pending.players} player{pending.players === 1 ? '' : 's'}</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>·</span>
|
||||
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{nextRunLabelET() ? `grade ${nextRunLabelET()}` : 'grade on the next pipeline run'}</span>
|
||||
</div>
|
||||
) : g.playerStrips && g.playerStrips.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{stripsToRender.map((ps, i) => (
|
||||
<StatStrip
|
||||
|
||||
@@ -421,6 +421,139 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
});
|
||||
}
|
||||
|
||||
// ── DS2 (Design v2) — Dashboard Slate Rebuild engine ────────────────
|
||||
// Pure, testable functions behind the founder's #1 rebuild: rank the top
|
||||
// grades on a VARYING signal (#13), promote ONE bold hero per card (#2),
|
||||
// collapse the dead "Grades post …" repetition (#14), and the NEVER-EMPTY
|
||||
// hero (#1, Part 6) — first paint ALWAYS proves the model.
|
||||
|
||||
const DS2_GRADE_RANK = {
|
||||
'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10,
|
||||
};
|
||||
/** Grade → sortable tier rank (lower = better). Unknown → 99. */
|
||||
function gradeRankOf(g) {
|
||||
const k = String(g == null ? '' : g).trim().toUpperCase();
|
||||
return DS2_GRADE_RANK[k] !== undefined ? DS2_GRADE_RANK[k] : 99;
|
||||
}
|
||||
const numOr = (v, fallback) => {
|
||||
const n = typeof v === 'number' ? v : parseFloat(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* #13 — rank tonight's grades by TIER, then the VARYING signal so a leaderboard
|
||||
* of near-identical rows stops being noise: confidence desc, then |edge| desc,
|
||||
* stable by input order. Drops gradeless rows. Returns at most `limit`.
|
||||
*/
|
||||
function selectTopGrades(grades, limit = 10) {
|
||||
const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade);
|
||||
const scored = arr.map((g, idx) => ({
|
||||
g,
|
||||
idx,
|
||||
rank: gradeRankOf(g.grade),
|
||||
conf: numOr(g.confidence, -1),
|
||||
edge: Math.abs(numOr(g.edge, -Infinity)),
|
||||
}));
|
||||
scored.sort((a, b) => a.rank - b.rank || b.conf - a.conf || b.edge - a.edge || a.idx - b.idx);
|
||||
return scored.slice(0, Math.max(0, limit)).map((s) => s.g);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1 / Part 6 — build PROVEN receipts from settled ledger rows: only A-tier
|
||||
* grades that HIT (a miss never becomes proof). Carries the real result so the
|
||||
* receipt is verifiable. Best-grade first. `settledRows` = /api/ledger/model
|
||||
* entries (player_name, stat, line, side, grade, outcome, actual_value, …).
|
||||
*/
|
||||
function buildHeroReceipts(settledRows, limit = 8) {
|
||||
const rows = (Array.isArray(settledRows) ? settledRows : []).filter(
|
||||
(r) => r && String(r.outcome || '').toLowerCase() === 'hit' && gradeRankOf(r.grade) <= 2,
|
||||
);
|
||||
const mapped = rows.map((r) => ({
|
||||
player: displayName(r.player_name || r.player || ''),
|
||||
stat: r.stat,
|
||||
line: r.line,
|
||||
side: String(r.side || 'over'),
|
||||
grade: r.grade,
|
||||
sport: String(r.sport || '').toUpperCase(),
|
||||
outcome: 'hit',
|
||||
actual: r.actual_value != null ? r.actual_value : null,
|
||||
clvResult: r.clv_result || null,
|
||||
}));
|
||||
mapped.sort((a, b) => gradeRankOf(a.grade) - gradeRankOf(b.grade));
|
||||
return mapped.slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* #1 / Part 6 — the never-empty hero engine. Tonight's ranked top grades win;
|
||||
* when tonight is empty, fall back to yesterday's PROVEN A-tier receipts; only
|
||||
* `empty` when both are absent. First paint ALWAYS proves the model when any
|
||||
* settled proof exists.
|
||||
*/
|
||||
function heroFallbackState(tonightGrades, settledRows, limit = 10) {
|
||||
const tonight = selectTopGrades(tonightGrades, limit);
|
||||
if (tonight.length > 0) return { mode: 'tonight', items: tonight };
|
||||
const receipts = buildHeroReceipts(settledRows, Math.min(limit, 8));
|
||||
if (receipts.length > 0) return { mode: 'receipts', items: receipts };
|
||||
return { mode: 'empty', items: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* #14 — collapse the dead per-prop "Grades post …" repetition. When a card has
|
||||
* NO graded reads yet (every prop awaiting), returns a single summary
|
||||
* ({ count, players, statLabels }) so the UI renders ONE compact line instead
|
||||
* of six identical rows. Any graded prop present → null (there are real reads
|
||||
* to show). No awaiting props → null.
|
||||
*/
|
||||
function pendingSummary(strips) {
|
||||
const list = Array.isArray(strips) ? strips : [];
|
||||
let awaiting = 0;
|
||||
let graded = 0;
|
||||
const players = new Set();
|
||||
const statLabels = [];
|
||||
for (const s of list) {
|
||||
for (const p of s.props || []) {
|
||||
if (p.grade) graded += 1;
|
||||
else if (p.awaiting) {
|
||||
awaiting += 1;
|
||||
players.add(s.player);
|
||||
if (statLabels.length < 6) statLabels.push(`${p.stat} ${p.line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (graded > 0 || awaiting === 0) return null;
|
||||
return { count: awaiting, players: players.size, statLabels };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2 — the ONE bold hero per card: the single highest-tier LIVE graded prop
|
||||
* (dead/not-in reads never lead). Returns { player, team, archetype, stat,
|
||||
* line, side, grade } or null when the card has no graded reads.
|
||||
*/
|
||||
function topReadForCard(strips) {
|
||||
let best = null;
|
||||
for (const s of Array.isArray(strips) ? strips : []) {
|
||||
for (const p of s.props || []) {
|
||||
if (!p.grade || p.dead) continue;
|
||||
const rank = gradeRankOf(p.grade);
|
||||
if (!best || rank < best.rank) {
|
||||
best = {
|
||||
rank,
|
||||
player: s.player,
|
||||
team: s.team || '',
|
||||
archetype: s.archetype || null,
|
||||
stat: p.stat,
|
||||
line: p.line,
|
||||
side: p.side || 'O',
|
||||
grade: p.grade,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
const { rank, ...rest } = best; // eslint-disable-line no-unused-vars
|
||||
return rest;
|
||||
}
|
||||
|
||||
// ── MLB probable pitchers (Session 46) ──────────────────────────────
|
||||
const teamToken = (name) => String(name == null ? '' : name).toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
|
||||
const teamMascot = (name) => { const t = teamToken(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; };
|
||||
@@ -468,4 +601,11 @@ module.exports = {
|
||||
buildPlayerStripsFromProps,
|
||||
buildPitcherMap,
|
||||
pitchersForGameTeams,
|
||||
// DS2 — dashboard rebuild engine.
|
||||
gradeRankOf,
|
||||
selectTopGrades,
|
||||
buildHeroReceipts,
|
||||
heroFallbackState,
|
||||
pendingSummary,
|
||||
topReadForCard,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user