Files
vyndr/web/src/lib/playerHeadshot.ts
T
builtbykev 47ada9013c 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>
2026-07-13 13:18:59 -04:00

72 lines
2.7 KiB
TypeScript

/**
* Player headshot URL construction (Session 19; Wave 2A refactor).
*
* The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's
* requireable by the plain-JS Jest suite AND importable here (allowJs) — same
* single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This
* file adds the TypeScript surface (types + the `headshotFromPlayer` helper).
*
* Each league hosts its own CDN; we don't proxy through ESPN as the primary
* because (a) ESPN rate-limits image hotlinking and (b) the league CDNs are the
* same sources PrizePicks and Sleeper use, so coverage is closer to 100%.
*
* Fallback chain inside the resolver:
* 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct
* soccer URLs (no central CDN for player headshots).
* 2. League CDN with `playerId` — official source.
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
* 4. `/images/player-silhouette.svg` — the sentinel the consuming component
* swaps for a team-colored MONOGRAM (never a gray blob, never a broken
* image). `<img onError>` also degrades a 404 to the monogram.
*/
import {
getHeadshotUrl as coreGetHeadshotUrl,
PLAYER_SILHOUETTE as CORE_SILHOUETTE,
} from '@/lib/playerHeadshotUrl';
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
export interface HeadshotInput {
sport: HeadshotSport;
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
playerId?: string | number | null;
/** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
cachedPhotoUrl?: string | null;
}
export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
export function getHeadshotUrl(input: HeadshotInput): string {
return coreGetHeadshotUrl(input);
}
/**
* Convenience wrapper for the common case where the caller has a player object
* with mixed ID fields. Pulls the first non-empty ID out of the union before
* delegating to `getHeadshotUrl`.
*/
export function headshotFromPlayer(player: {
sport?: string;
nba_id?: string | number | null;
wnba_id?: string | number | null;
mlb_id?: string | number | null;
espn_id?: string | number | null;
photo_url?: string | null;
}): string {
const sport = String(player.sport || '').toLowerCase();
const leagueId =
sport === 'nba' ? player.nba_id :
sport === 'wnba' ? player.wnba_id :
sport === 'mlb' ? player.mlb_id :
null;
return getHeadshotUrl({
sport,
playerId: leagueId,
espnId: player.espn_id,
cachedPhotoUrl: player.photo_url,
});
}