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:
+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`}>
|
||||
|
||||
Reference in New Issue
Block a user