Wave 2A: real player headshots — sport-agnostic id threaded from ingestion

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>
This commit is contained in:
Kev
2026-07-13 13:18:59 -04:00
parent b48dc2ed15
commit 47ada9013c
17 changed files with 381 additions and 121 deletions
+26 -40
View File
@@ -16,7 +16,8 @@ import {
trackScanLimitHit,
trackUpgradeClicked,
} from '@/lib/analytics';
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -119,6 +120,9 @@ export default function ScanPage() {
const [playerQuery, setPlayerQuery] = useState('');
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
// Wave 2A — the MLBAM id of the player picked from search (MLB only; numeric).
// Feeds the grade card's real headshot. null → team-colored monogram.
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [stat, setStat] = useState<string>('points');
const [line, setLine] = useState<string>('');
const [direction, setDirection] = useState<'over' | 'under'>('over');
@@ -306,6 +310,7 @@ export default function ScanPage() {
setError('');
setPlayerQuery('');
setSelectedPlayer('');
setSelectedPlayerId(null);
setLine('');
};
@@ -442,7 +447,6 @@ export default function ScanPage() {
}}
>
{tonightsPlayers.map((p) => {
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
const selected = selectedPlayer === p.name;
return (
<button
@@ -450,6 +454,7 @@ export default function ScanPage() {
type="button"
onClick={() => {
setSelectedPlayer(p.name);
setSelectedPlayerId(null); // tonight chips carry no id → monogram
setPlayerQuery(p.name);
setPlayerSuggestions([]);
// If the player has exactly one stat type with
@@ -475,22 +480,9 @@ export default function ScanPage() {
minWidth: 0,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={headshot}
alt=""
width={24}
height={24}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
objectFit: 'cover',
}}
/>
{/* Wave 2A — tonight's players carry no id → team-colored
monogram (branded, never a gray silhouette). */}
<PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
{p.name}
</span>
@@ -511,6 +503,7 @@ export default function ScanPage() {
onChange={(e) => {
setPlayerQuery(e.target.value);
setSelectedPlayer('');
setSelectedPlayerId(null);
}}
autoComplete="off"
/>
@@ -551,33 +544,25 @@ export default function ScanPage() {
onMouseDown={(e) => {
e.preventDefault();
setSelectedPlayer(p.full_name);
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
setPlayerQuery(p.full_name);
setPlayerSuggestions([]);
}}
style={suggestionStyle}
>
{/* Session 19 — headshot in search suggestions. The
/api/players/search response doesn't (yet) include
league IDs, so we hit ESPN-CDN fallback or
silhouette. onError swaps in the silhouette when
the league hasn't published one. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })}
alt=""
width={28}
height={28}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
marginRight: 10,
objectFit: 'cover',
}}
/>
{/* Wave 2A — real headshot in search suggestions. MLB's
/api/players/search carries the MLBAM id on p.id (numeric);
NBA/WNBA ids are synthetic → guarded out → team-colored
monogram. Never a gray silhouette. */}
<span style={{ marginRight: 10, display: 'inline-flex' }}>
<PlayerAvatar
name={p.full_name}
sport={sport.toLowerCase() as HeadshotSport}
playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
team={p.team}
size={28}
/>
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
{p.team && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
@@ -731,6 +716,7 @@ export default function ScanPage() {
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
data={mapScanToGradeResult({
player: selectedPlayer,
playerId: selectedPlayerId,
sport,
stat,
line: Number(line),