Wave 2B: reliable cross-sport headshots via ESPN athlete index

The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).

snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).

getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.

Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web 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 18:12:31 -04:00
parent 3b1aa9f265
commit fceb3707b5
12 changed files with 460 additions and 10 deletions
+3
View File
@@ -40,6 +40,8 @@ export interface PlayerStrip {
// Wave 2A — real headshot ids threaded from the snapshot grade.
playerId?: string | number | null;
espnId?: string | number | null;
// Wave 2B — a resolved absolute ESPN headshot URL (wins over a constructed one).
headshotUrl?: string | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -371,6 +373,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
sport={g.sport}
playerId={ps.playerId}
espnId={ps.espnId}
headshotUrl={ps.headshotUrl}
archetype={ps.archetype}
lineup={ps.lineup}
injury={ps.injury}
+9 -5
View File
@@ -22,6 +22,7 @@ export default function PlayerAvatar({
sport = 'mlb',
playerId,
espnId,
headshotUrl,
team,
size = 36,
}: {
@@ -31,15 +32,18 @@ export default function PlayerAvatar({
playerId?: string | number | null;
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
/** Wave 2B — a resolved absolute ESPN headshot URL; wins over a constructed one. */
headshotUrl?: string | null;
team?: string | null;
size?: number;
}) {
const [broken, setBroken] = useState(false);
// 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 })
// Wave 2A/2B — resolve from whichever real asset we have. A direct
// headshotUrl (ESPN's exact href) wins; else getHeadshotUrl routes by sport:
// MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. Nothing → null →
// team-colored monogram (never a gray silhouette).
const url = (headshotUrl || playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId, headshotUrl })
: null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
+4 -1
View File
@@ -199,6 +199,8 @@ interface StatStripProps {
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
playerId?: string | number | null;
espnId?: string | number | null;
// Wave 2B — a resolved absolute ESPN headshot URL (wins over a constructed one).
headshotUrl?: string | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -231,6 +233,7 @@ export default function StatStrip({
sport,
playerId,
espnId,
headshotUrl,
archetype,
lineup,
injury,
@@ -384,7 +387,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} playerId={playerId} espnId={espnId} team={team} size={30} />
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} headshotUrl={headshotUrl} 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" />}