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
+23 -6
View File
@@ -298,6 +298,13 @@ async function runSnapshot(sport, opts = {}) {
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {};
// Wave 2A — the REAL athlete id from the SAME stats resolve, keyed by player.
// MLB → MLBAM id (mlbstatic headshot CDN); NBA/WNBA → ESPN athlete id
// (a.espncdn headshot CDN). Stored on the enriched grade so it flows free to
// grades:{sport} → slate strips → PlayerAvatar. Zero new I/O. Absent → the
// component falls to a team-colored monogram (never a fabricated face).
const playerIdByPlayer = {};
const espnIdByPlayer = {};
// Wave 1 (trust bug) — the prop's game participants become the resolve's
// teamHint: it disambiguates namesake collisions (two "James Wood") and, when
// the resolved player's real team isn't in the prop's game, the resolver drops
@@ -326,6 +333,9 @@ async function runSnapshot(sport, opts = {}) {
const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null;
if (stats.team) teamByPlayer[player] = stats.team;
// Wave 2A — capture the resolved athlete id (headshot thread).
if (stats.playerId != null) playerIdByPlayer[player] = stats.playerId;
if (stats.espnId != null) espnIdByPlayer[player] = stats.espnId;
if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
logEntries.push({
name: normalizeName(player).display || player,
@@ -341,12 +351,19 @@ async function runSnapshot(sport, opts = {}) {
});
await mergeRosterLogs(sp, logEntries, deps);
const enriched = graded.map((g) => ({
...g,
gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null,
team: teamByPlayer[g.player || g.player_name] || g.team || null,
}));
const enriched = graded.map((g) => {
const pn = g.player || g.player_name;
return {
...g,
gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[pn] || null,
team: teamByPlayer[pn] || g.team || null,
// Wave 2A — real headshot id (MLBAM for MLB, ESPN for NBA/WNBA), threaded
// from the stats resolve above. Absent → PlayerAvatar renders a monogram.
playerId: playerIdByPlayer[pn] ?? g.playerId ?? null,
espnId: espnIdByPlayer[pn] ?? g.espnId ?? null,
};
});
// Line deltas vs the previous snapshot's locked lines.
const prev = await deps.cacheGet(`snapshot:${sp}:latest`);