47ada9013c
Threads a REAL athlete id from the snapshot's per-player stats resolve (zero
new I/O) → enriched grade → grades:{sport} → slate strip → PlayerAvatar. Real
photo where an id resolves; team-colored monogram (never a gray silhouette,
never a broken image) where it can't. Ids are never fabricated.
Ingestion (Addition 1):
- espnStatsAdapter.getSeasonAverages now RETURNS the resolved ESPN athlete id
(was discarded) as espnId; non-numeric uid degrades to null.
- playerIntelService surfaces MLBAM playerId (MLB) / ESPN espnId (NBA/WNBA).
- snapshotService captures both per player and stores them on the enriched
grade beside archetype/team (null when unresolved → monogram path).
Thread → component:
- slateAdapter.buildPlayerStripsFromProps carries playerId/espnId onto each
strip; StatStrip → PlayerAvatar (accepts both ids; getHeadshotUrl routes by
sport: MLB→mlbstatic, NBA/WNBA→a.espncdn).
- Silhouette surfaces rewired to PlayerAvatar (branded monogram on null):
scan search dropdown (guarded MLBAM p.id) + tonight chips, SearchModal,
HotListPanel, GradeResultCard header. Scan grade card feeds the picked
MLBAM id through gradeAdapter.
- playerHeadshot pure URL logic extracted to CommonJS playerHeadshotUrl.js
(unit-testable; the .ts re-exports it). nfl/nhl added to ESPN_SPORT_PATH.
Tests: tests/unit/headshotThread.test.js (per-league URL + id thread + monogram
null path) + extended snapshotService/espnStatsAdapter suites. Full suite
241 suites / 2915 green; next build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
329 lines
18 KiB
TypeScript
329 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import SportBadge from '@/components/vyndr/SportBadge';
|
||
import SectionHead from '@/components/vyndr/SectionHead';
|
||
import VBtn from '@/components/vyndr/VBtn';
|
||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
||
import { edgeColor, gradeGlows } from '@/lib/colorContract';
|
||
import { playerHref } from '@/lib/playerHref';
|
||
|
||
export interface GradeResultData {
|
||
player: string;
|
||
team: string;
|
||
sport: string;
|
||
// Wave 2A — real headshot ids (optional; self-hide to a team-colored
|
||
// monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId.
|
||
playerId?: string | number | null;
|
||
espnId?: string | number | null;
|
||
stat: string;
|
||
line: number;
|
||
side: 'Over' | 'Under';
|
||
grade: string;
|
||
confidence: number;
|
||
edge: number | null;
|
||
projection: number | null;
|
||
phosphorConfirmed?: boolean;
|
||
signals: string[];
|
||
killConditions?: string[];
|
||
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
|
||
altLadder?: Array<{ line: number; grade: string; edge?: number | null; base?: boolean }>;
|
||
// Session 62 (A1-S1) — quarter-Kelly sizing (Desk only; real p × real odds).
|
||
kelly?: { pct: number; quarter: number; odds: string };
|
||
// Session 42 — Player Intelligence additions (all optional; self-hide).
|
||
archetypeBlend?: Array<{ archetype: string; weight: number }>;
|
||
propDNA?: { reliable: string[]; volatile: string[] };
|
||
statContext?: { season?: string; last10?: string; vsOpp?: string };
|
||
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
|
||
}
|
||
|
||
interface GradeResultCardProps {
|
||
data: GradeResultData;
|
||
replayKey?: number;
|
||
compact?: boolean;
|
||
onShare?: (d: GradeResultData) => void;
|
||
onAddToParlay?: (d: GradeResultData) => void;
|
||
onReadAnother?: () => void;
|
||
}
|
||
|
||
/**
|
||
* The product's core moment (§7). The grade letter is the largest, first-read
|
||
* element (92–116px) on the intel-surface "VYNDR is speaking" zone, revealing
|
||
* with grade-reveal + a CRT sweep. Data is sacred — nothing here glitches.
|
||
* Sections (kill conditions, books, alt ladder) self-hide when empty.
|
||
*/
|
||
export default function GradeResultCard({
|
||
data,
|
||
replayKey = 0,
|
||
compact = false,
|
||
onShare,
|
||
onAddToParlay,
|
||
onReadAnother,
|
||
}: GradeResultCardProps) {
|
||
const d = data;
|
||
const c = gradeColor(d.grade);
|
||
const hex = gradeHex(d.grade);
|
||
const [sweep, setSweep] = useState(true);
|
||
|
||
useEffect(() => {
|
||
setSweep(true);
|
||
const t = setTimeout(() => setSweep(false), 700);
|
||
return () => clearTimeout(t);
|
||
}, [replayKey]);
|
||
|
||
const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)';
|
||
// COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN via the centralized
|
||
// colorContract.edgeColor helper (imported) — negative = var(--miss). DS4
|
||
// introduced a local const that DS3 superseded with the shared enforcer.
|
||
const hasKill = !!d.killConditions && d.killConditions.length > 0;
|
||
const hasBooks = Array.isArray(d.books) && d.books.length > 0;
|
||
const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
maxWidth: 640,
|
||
margin: '0 auto',
|
||
position: 'relative',
|
||
background: 'var(--bg-1)',
|
||
border: '1px solid var(--border-hi)',
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
boxShadow: `0 0 0 1px color-mix(in srgb, ${c} 22%, transparent), 0 24px 70px -28px ${hex}55, 0 18px 50px -20px rgba(0,0,0,.7)`,
|
||
}}
|
||
aria-label={`VYNDR grade for ${d.player}`}
|
||
>
|
||
{sweep && <div className="crt-sweep-local" />}
|
||
|
||
{/* 1. HEADER — player name links to the full profile (Session 42) */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||
{/* Wave 2A — player identity block: real headshot / team-colored
|
||
monogram (never a gray silhouette). */}
|
||
<PlayerAvatar name={d.player} sport={d.sport as HeadshotSport} playerId={d.playerId} espnId={d.espnId} team={d.team} size={40} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
|
||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
||
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||
<SportBadge sport={d.sport} />
|
||
{d.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{d.team}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 1b. ARCHETYPE STRIP (Session 42) — between header and grade hero */}
|
||
{Array.isArray(d.archetypeBlend) && d.archetypeBlend.length > 0 && (
|
||
<div style={{ padding: '13px 20px', background: '#0C0E0D', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<ArchetypeBlend blend={d.archetypeBlend} size="sm" showLegend caption="Why this grade: the prop sits in this player's PRIMARY lane, so the model weights it heavily." />
|
||
{d.propDNA && (
|
||
<div className="mono" style={{ fontSize: 11, color: '#7E8A86' }}>
|
||
<span style={{ color: 'var(--text-2)' }}>PROP DNA</span>
|
||
{(d.propDNA.reliable || []).map((s) => (
|
||
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--g-a)' }}>● reliable</span></span>
|
||
))}
|
||
{(d.propDNA.volatile || []).map((s) => (
|
||
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--amber)' }}>● volatile</span></span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 2. GRADE HERO — intel surface */}
|
||
<div className="intel-surface" style={{ padding: '26px 20px 22px', textAlign: 'center' }}>
|
||
<div className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
|
||
<div
|
||
key={replayKey}
|
||
className="grade-reveal grade-hero"
|
||
style={{
|
||
position: 'relative',
|
||
zIndex: 2,
|
||
fontSize: compact ? 92 : 116,
|
||
fontWeight: 800,
|
||
lineHeight: 0.95,
|
||
color: hex,
|
||
letterSpacing: '-0.04em',
|
||
// GLOW = A/A+ ONLY (color contract #4): a glowing C/D devalues the cue.
|
||
textShadow: gradeGlows(d.grade) ? `0 0 28px ${hex}aa, 0 0 60px ${hex}55` : 'none',
|
||
fontFamily: 'var(--sans)',
|
||
}}
|
||
>
|
||
{d.grade}
|
||
</div>
|
||
|
||
{/* 3. CONFIDENCE STRIP */}
|
||
<div className="mono" style={{ position: 'relative', zIndex: 2, marginTop: 8, fontSize: 15, fontWeight: 600, color: 'var(--text-0)', display: 'flex', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<span style={{ color: hex, fontWeight: 800 }}>{d.grade}</span>
|
||
{d.edge != null && (
|
||
<>
|
||
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
|
||
<span style={{ color: edgeColor(d.edge) }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
|
||
</>
|
||
)}
|
||
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
|
||
<span>{d.confidence}% confidence</span>
|
||
</div>
|
||
|
||
{d.phosphorConfirmed && (
|
||
<div style={{ position: 'relative', zIndex: 2, marginTop: 13, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '5px 12px', border: '1px solid rgba(0,255,184,.45)', borderRadius: 100, background: 'rgba(0,255,184,.08)' }}>
|
||
<span className="phosphor-cursor" style={{ width: 7, height: 13, margin: 0 }} />
|
||
<span className="mono amber-glow" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-ap)', textShadow: '0 0 10px rgba(0,255,184,.7)' }}>PHOSPHOR CONFIRMED</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 4. PROJECTION ROW — DATA SEMANTICS (Session 58): LINE is a real
|
||
market number (fact); MODEL/EDGE are model output. When the model
|
||
has no projection they render an absent state ("—"), never the
|
||
line or a fake +0%. */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', borderBottom: '1px solid var(--border)' }}>
|
||
{[
|
||
{ l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' },
|
||
{ l: 'LINE', v: d.line, col: 'var(--text-0)' },
|
||
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor(d.edge) : 'var(--text-2)' },
|
||
].map((x, i) => (
|
||
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
|
||
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
|
||
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col, fontVariantNumeric: 'tabular-nums' }}>{x.v}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 5. SIGNAL BREAKDOWN */}
|
||
{d.signals.length > 0 && (
|
||
<div style={{ padding: '16px 20px' }}>
|
||
<SectionHead style={{ marginBottom: 12 }}>SIGNAL BREAKDOWN · {d.signals.length} FACTORS SHOWN</SectionHead>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||
{d.signals.map((s, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 7px rgba(0,212,160,.7)', flexShrink: 0 }} />
|
||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-0)', letterSpacing: '0.01em' }}>{s}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 5b. STAT CONTEXT (Session 42) */}
|
||
{d.statContext && (d.statContext.season || d.statContext.last10 || d.statContext.vsOpp) && (
|
||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--border)' }}>
|
||
<SectionHead style={{ marginBottom: 12 }}>STAT CONTEXT</SectionHead>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'var(--border)', border: '1px solid var(--border)', borderRadius: 9, overflow: 'hidden' }}>
|
||
{[
|
||
{ l: 'SEASON', v: d.statContext.season, col: 'var(--text-0)' },
|
||
{ l: 'LAST 10', v: d.statContext.last10, col: 'var(--g-a)' },
|
||
{ l: 'vs OPP', v: d.statContext.vsOpp, col: 'var(--g-a)' },
|
||
].map((x, i) => (
|
||
<div key={i} style={{ background: 'var(--bg-1)', padding: '11px 12px' }}>
|
||
<div className="mono" style={{ fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.06em', marginBottom: 5 }}>{x.l}</div>
|
||
<div className="mono" style={{ fontSize: 16, fontWeight: 600, color: x.v ? x.col : 'var(--text-2)' }}>{x.v || '—'}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 5c. VYNDR INTELLIGENCE (Session 42). Color contract #6: the panel is
|
||
MUTED (neutral border) with a single green label as its identity
|
||
accent. Form/Rest are CONTEXT data, not "edge" — neutral-bright,
|
||
never green. */}
|
||
{d.vyndrIntel && (
|
||
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid var(--border)' }}>
|
||
<div className="mono" style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 12 }}>VYNDR INTELLIGENCE</div>
|
||
<div className="mono" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', fontSize: 12 }}>
|
||
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
|
||
{d.vyndrIntel.usage && (<><span style={{ color: 'var(--text-2)' }}>Usage</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.usage}</span><span style={{ color: '#2A3531' }}>·</span></>)}
|
||
{d.vyndrIntel.matchup && (<><span style={{ color: 'var(--text-2)' }}>Matchup</span><GradeBadge grade={d.vyndrIntel.matchup} size="sm" /><span style={{ color: '#2A3531' }}>·</span></>)}
|
||
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 6. KILL CONDITIONS */}
|
||
{hasKill && (
|
||
<div style={{ margin: '0 20px 16px', border: '1px solid rgba(255,179,71,.4)', borderRadius: 8, background: 'rgba(255,179,71,.06)', padding: '13px 15px' }}>
|
||
<div className="label amber-glow" style={{ color: 'var(--amber)', marginBottom: 9, display: 'flex', alignItems: 'center', gap: 7 }}>
|
||
<span style={{ fontSize: 13 }}>⚠</span> KILL CONDITIONS
|
||
</div>
|
||
{d.killConditions!.map((k, i) => (
|
||
<div key={i} className="mono cb-neg" style={{ fontSize: 12.5, color: '#ffd9a8', letterSpacing: '0.01em' }}>{k}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 7. BOOK COMPARISON */}
|
||
{hasBooks && (
|
||
<div style={{ padding: '0 20px 16px' }}>
|
||
<SectionHead style={{ marginBottom: 10 }}>BOOK COMPARISON</SectionHead>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
{d.books.map((b, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 13px', borderRadius: 6, background: b.best ? 'rgba(0,212,160,.13)' : 'var(--bg-2)', borderLeft: b.best ? '2px solid var(--g-a)' : '2px solid transparent' }}>
|
||
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: b.best ? 'var(--g-a)' : 'var(--text-0)' }}>{b.name}</span>
|
||
<div className="mono" style={{ fontSize: 13, display: 'flex', gap: 14, alignItems: 'center' }}>
|
||
<span style={{ color: 'var(--text-1)' }}>{d.side === 'Under' ? 'U' : 'O'}{b.line}</span>
|
||
<span style={{ color: b.best ? 'var(--g-a)' : 'var(--text-0)', fontWeight: 700, minWidth: 44, textAlign: 'right' }}>{b.odds}</span>
|
||
{b.best && <span className="label" style={{ color: 'var(--g-a)', fontSize: 9 }}>BEST</span>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 8. ALT LINE LADDER (Desk) */}
|
||
{hasAlt && (
|
||
<div style={{ padding: '0 20px 16px' }}>
|
||
<SectionHead style={{ marginBottom: 10 }}>
|
||
ALT LINE LADDER <span style={{ color: 'var(--amber)', fontSize: 9, border: '1px solid rgba(255,179,71,.4)', borderRadius: 3, padding: '1px 5px', marginLeft: 4 }}>DESK</span>
|
||
</SectionHead>
|
||
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
|
||
{d.altLadder!.map((a, i) => (
|
||
<div key={i} style={{ flex: '1 1 0', minWidth: 64, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: a.base ? '1px solid var(--border-hi)' : '1px solid var(--border)', borderRadius: 6 }}>
|
||
<div className="mono" style={{ fontSize: 12, color: a.base ? 'var(--text-0)' : 'var(--text-1)', marginBottom: 5 }}>{a.line}{a.base ? ' •' : ''}</div>
|
||
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeColor(a.grade) }}>{a.grade}</div>
|
||
{a.edge != null && (
|
||
<div className="mono" style={{ fontSize: 10, color: Number(a.edge) >= 0 ? 'var(--g-a)' : 'var(--miss)', marginTop: 3 }}>{Number(a.edge) >= 0 ? '+' : ''}{a.edge}%</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Quarter-Kelly (Desk) — real probability × the real captured odds. */}
|
||
{d.kelly && (
|
||
<div className="mono" style={{ marginTop: 10, fontSize: 12, color: 'var(--text-1)' }}>
|
||
<span className="label" style={{ fontSize: 9.5, marginRight: 8 }}>QTR-KELLY</span>
|
||
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{d.kelly.pct}% of bankroll</span>
|
||
<span style={{ color: 'var(--text-2)' }}> · at {d.kelly.odds}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 9. ACTION ROW */}
|
||
<div style={{ display: 'flex', gap: 10, padding: '16px 20px', borderTop: '1px solid var(--border)', background: 'var(--bg-2)', alignItems: 'center' }}>
|
||
<VBtn variant="primary" style={{ flex: 1 }} onClick={() => onShare && onShare(d)}>↗ Share This Grade</VBtn>
|
||
<VBtn variant="outline" style={{ flex: 1 }} onClick={() => onAddToParlay && onAddToParlay(d)}>+ Add to Parlay</VBtn>
|
||
{onReadAnother && <VBtn variant="ghost" small onClick={onReadAnother}>Read Another →</VBtn>}
|
||
</div>
|
||
|
||
{/* 10. PUSH-TO-BOOK teaser (Session 52) — feature not live yet. */}
|
||
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 20px 16px', textAlign: 'center', background: 'var(--bg-2)' }}>
|
||
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 11, letterSpacing: '0.06em' }}>
|
||
PUSH-TO-BOOK · COMING SOON
|
||
</span>
|
||
<div style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 4 }}>
|
||
One tap from grade to bet slip. Connect DraftKings, FanDuel, BetMGM.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|