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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user