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
+10 -11
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import type { HeadshotSport } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
@@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit
{visible.map((p) => (
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
<span style={rankStyle}>#{p.rank}</span>
<img
src={getHeadshotUrl({ sport, playerId: p.playerId })}
alt={p.name}
width={36}
height={36}
style={avatarStyle}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
{/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM);
team-colored monogram otherwise. Never a gray silhouette. */}
<PlayerAvatar
name={p.name}
sport={sport as HeadshotSport}
playerId={p.playerId}
team={p.team}
size={36}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
@@ -103,9 +105,6 @@ const rankStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
color: 'var(--text-tertiary, #6A6A78)',
};
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
+5
View File
@@ -37,6 +37,9 @@ export interface GameProp {
export interface PlayerStrip {
player: string;
team: string;
// Wave 2A — real headshot ids threaded from the snapshot grade.
playerId?: string | number | null;
espnId?: string | number | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
player={ps.player}
team={ps.team}
sport={g.sport}
playerId={ps.playerId}
espnId={ps.espnId}
archetype={ps.archetype}
lineup={ps.lineup}
injury={ps.injury}
+12 -1
View File
@@ -6,6 +6,8 @@ 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';
@@ -14,6 +16,10 @@ 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';
@@ -96,12 +102,17 @@ export default function GradeResultCard({
{/* 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>
<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} />
+10 -1
View File
@@ -21,17 +21,26 @@ export default function PlayerAvatar({
name,
sport = 'mlb',
playerId,
espnId,
team,
size = 36,
}: {
name: string;
sport?: HeadshotSport;
/** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */
playerId?: string | number | null;
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
team?: string | null;
size?: number;
}) {
const [broken, setBroken] = useState(false);
const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null;
// Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by
// sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at
// all → null → team-colored monogram (never a gray silhouette).
const url = (playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId })
: null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
+23 -3
View File
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { playerHref } from '@/lib/playerHref';
import { searchTeams, teamHref } from '@/lib/teams';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
/**
* SearchModal — S6 (A1 board). Global search over players + teams.
@@ -31,6 +33,8 @@ interface ResultItem {
sub: string; // team / abbr context
sport: string;
href: string;
// Wave 2A — MLBAM headshot id (MLB only; NBA/WNBA search ids are synthetic).
playerId?: string | number | null;
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
const seen = new Set<string>();
const merged: ResultItem[] = [];
for (const res of responses) {
for (const p of res.players as Array<{ full_name?: string; team?: string }>) {
for (const p of res.players as Array<{ full_name?: string; team?: string; id?: string | number }>) {
const name = String(p.full_name || '').trim();
if (!name) continue;
const key = `${name.toLowerCase()}|${res.sport}`;
if (seen.has(key)) continue;
seen.add(key);
// MLB's search id is the real MLBAM id → headshot. Other sports'
// ids are synthetic → guarded out → monogram.
const playerId = res.sport === 'mlb' && p.id != null && /^\d+$/.test(String(p.id)) ? p.id : null;
merged.push({
kind: 'player',
label: name,
sub: p.team ? String(p.team) : '',
sport: res.sport,
href: playerHref(name, res.sport),
playerId,
});
if (merged.length >= 9) break;
}
@@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
fontSize: 13,
}}
>
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */}
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
{/* ROW-GRAMMAR: never truncate a name — wrap instead. Wave 2A adds
the player identity avatar (real headshot / branded monogram). */}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
{it.kind === 'player' && (
<PlayerAvatar
name={it.label}
sport={it.sport as HeadshotSport}
playerId={it.playerId}
team={it.sub || null}
size={26}
/>
)}
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
+7 -1
View File
@@ -195,6 +195,10 @@ interface StatStripProps {
player: string;
team: string;
sport?: string;
// Wave 2A — real headshot ids threaded from the snapshot grade. MLB → MLBAM
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
playerId?: string | number | null;
espnId?: string | number | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -225,6 +229,8 @@ export default function StatStrip({
player,
team,
sport,
playerId,
espnId,
archetype,
lineup,
injury,
@@ -378,7 +384,7 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} team={team} size={30} />
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}