77e8937a56
DISPLAY FIX (shipped): the league leaderboard rendered raw snake_case
("stolen_bases U0.5", "earned_runs U2.5"). New canonical short-label lib
web/src/lib/statAbbrev.js (one source, CommonJS + unit-tested) maps stat_type
to SB/ER/TB/HR/K/PTS/… and ExploreHub routes through it. Unknown ids upper-case
their words so raw snake_case can never leak again.
FLAG (reported, NOT silently changed — per the audit's instruction): the "B at
45% confidence" is a BACKEND grading issue, diagnosed against live snapshot:
- 25/25 grades mismatch their own confidence vs grade_thresholds.json (B shown
at conf 55 = the B- band; a systematic one-sub-tier gap on every prop). The
surfaced `confidence` is not the probability that derived the letter (likely
the data-sufficiency penalty applied to display-only).
- 9/25 have projection=0 — the MLB feature path feeds 0 instead of refusing
(S58 insufficient_data), which also produces the P1-7 broken edge_pct.
Full write-up + do-not list: specs/audit-data/mlb-grade-degradation.md. NOT
re-lettering or shifting thresholds on the frontend — that would hide the bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
8.8 KiB
TypeScript
171 lines
8.8 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import SportBadge from '@/components/vyndr/SportBadge';
|
||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||
import { playerHref } from '@/lib/playerHref';
|
||
import { dedupeLeaders } from '@/lib/playerGrouping';
|
||
import { statAbbrev } from '@/lib/statAbbrev';
|
||
|
||
/**
|
||
* ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR).
|
||
* The "entire picture" page: leaders + full streaks + hot lists, one sport
|
||
* selector, everything free and interpreted through the lens. The page shell
|
||
* (app/explore/page.tsx) is a server component carrying the SEO metadata.
|
||
*/
|
||
import StreaksPanel from '@/components/StreaksPanel';
|
||
import HotListPanel from '@/components/HotListPanel';
|
||
import NewsWire from '@/components/vyndr/NewsWire';
|
||
import FuturesBoard from '@/components/vyndr/FuturesBoard';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||
import { OFF_SEASON } from '@/lib/emptyState';
|
||
|
||
interface Leader {
|
||
player: string;
|
||
team: string;
|
||
stat: string;
|
||
line: number | string;
|
||
side: string;
|
||
grade: string;
|
||
confidence: number;
|
||
}
|
||
|
||
const SPORTS = [
|
||
{ key: 'nba', label: 'NBA' },
|
||
{ key: 'mlb', label: 'MLB' },
|
||
{ key: 'wnba', label: 'WNBA' },
|
||
];
|
||
|
||
export default function ExploreHub() {
|
||
const { tier } = useAuth();
|
||
const [sport, setSport] = useState('mlb');
|
||
const [leaders, setLeaders] = useState<Leader[]>([]);
|
||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||
const [query, setQuery] = useState('');
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setState('loading');
|
||
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
|
||
.then((r) => r.json())
|
||
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
|
||
.catch(() => { if (active) setState('error'); });
|
||
return () => { active = false; };
|
||
}, [sport]);
|
||
|
||
const rows = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
const filtered = q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
||
// P0-3 — ONE row per player+market family + a per-market cap, so a player's
|
||
// alt-line ladder (Bohm ×3) collapses to one entry and no single prop type
|
||
// (9× stolen_bases) can flood the ranked board.
|
||
return dedupeLeaders(filtered, 4) as Leader[];
|
||
}, [leaders, query]);
|
||
|
||
// Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the
|
||
// hub LEADS with futures + the wire (real, always-available data) instead of
|
||
// a dark leaderboard; in-season those sections COMPLEMENT the live board
|
||
// below. Each self-hides independently on an empty feed — no empty boxes.
|
||
const off = OFF_SEASON[sport as keyof typeof OFF_SEASON];
|
||
const isOffseason = !!(off && off.months.includes(new Date().getMonth()));
|
||
|
||
// Both sections self-hide (return null) when their feeds are empty.
|
||
const hubSections = (
|
||
<>
|
||
<FuturesBoard sport={sport} />
|
||
<NewsWire sport={sport} />
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
||
<div>
|
||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
|
||
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
|
||
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
|
||
Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
|
||
</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
|
||
{SPORTS.map((s) => (
|
||
<button
|
||
key={s.key}
|
||
onClick={() => setSport(s.key)}
|
||
className="mono"
|
||
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* OFF-SEASON LEAD — futures + wire come FIRST when the board is dark. */}
|
||
{isOffseason && hubSections}
|
||
|
||
{/* FILTER BAR */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Search players"
|
||
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
|
||
/>
|
||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
|
||
</div>
|
||
|
||
{/* LEADERBOARD */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
|
||
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
|
||
<SportBadge sport={sport} />
|
||
</div>
|
||
|
||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
|
||
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
|
||
</div>
|
||
|
||
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight's slate…</div>}
|
||
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
|
||
{state === 'ready' && rows.length === 0 && (
|
||
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
|
||
No graded props for {sport.toUpperCase()} yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.
|
||
</div>
|
||
)}
|
||
{state === 'ready' && rows.map((r, i) => (
|
||
<a
|
||
key={i}
|
||
href={playerHref(r.player, sport)}
|
||
className="game-lines-grid"
|
||
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
|
||
>
|
||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
|
||
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
|
||
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
|
||
</div>
|
||
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{statAbbrev(r.stat)} {r.side}{r.line}</div>
|
||
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
|
||
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
|
||
{/* Session 60 (night2/C) — THE AGGREGATOR. Full streaks + hot lists
|
||
for the selected sport, every row through the lens. The picture is
|
||
free; the grade is the paid layer. Panels self-hide when cold. */}
|
||
<div style={{ marginTop: 28 }}>
|
||
<StreaksPanel sport={sport} tier={tier} stat="all" />
|
||
<HotListPanel sport={sport} tier={tier} stat="all" />
|
||
</div>
|
||
|
||
{/* IN-SEASON — futures + wire COMPLEMENT the live board below it. */}
|
||
{!isOffseason && hubSections}
|
||
</section>
|
||
);
|
||
}
|