Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)

Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections).

- Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5
  WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend.
  Frontend visual map web/src/lib/archetypes.js (colors verified == backend).
  ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar).
- StatStrip (compact/expanded): player name once, horizontal mono stats,
  inline GradeBadge props, onPlayerClick -> profile.
- Stats API: extended src/routes/stats.js with /player/:name, /leaders,
  /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes
  name param; grades cache; graceful on cold cache). Next proxies added.
- Player Profile /player/[name]: all 9 design sections, graceful empty states.
- Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard
  (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via
  gradeAdapter.buildIntelFields). Player-name links wired everywhere.
- Settings page replaces the S41 redirect (account/subscription/notifications/
  display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the
  real /settings/security MFA page — does not replace it. + BookChip.
- Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added
  Explore + Settings to Nav MORE.

Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate
redesign, Stats Explorer sub-panels.

Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 11:12:24 -04:00
parent 32069863dc
commit 8bc79f3c38
33 changed files with 2655 additions and 22 deletions
+128
View File
@@ -0,0 +1,128 @@
'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';
/**
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
* tonight's league leaderboard (top graded props by confidence) with grade +
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
* a Session-43 follow-up — see BUILD-STATE.
*/
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 ExplorePage() {
const [sport, setSport] = useState('nba');
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();
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
}, [leaders, query]);
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&apos;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>
{/* 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&apos;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 check back when tonight&apos;s slate posts.
</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' }}>{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>
</section>
);
}