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:
@@ -1,82 +1,52 @@
|
||||
/**
|
||||
* Player headshot URL construction (Session 19).
|
||||
* Player headshot URL construction (Session 19; Wave 2A refactor).
|
||||
*
|
||||
* 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%.
|
||||
* 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` — if our backend has a stored URL (soccer,
|
||||
* where API-Football returns the photo in player responses), use
|
||||
* that directly. We DO NOT construct soccer URLs because no
|
||||
* central CDN exists for player headshots.
|
||||
* 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 fallback — only when `espnId` is present and no
|
||||
* league ID is available. Helpful while the roster table is
|
||||
* being backfilled with league-specific IDs.
|
||||
* 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette.
|
||||
*
|
||||
* `<img onError>` in the consuming component handles 404s from the
|
||||
* CDN (e.g. a player who's in our roster but the league hasn't
|
||||
* uploaded a headshot yet) by swapping to the silhouette.
|
||||
* 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;
|
||||
/** Optional ESPN ID as a fallback when no league ID is known. */
|
||||
/** 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 = '/images/player-silhouette.svg';
|
||||
|
||||
const ESPN_SPORT_PATH: Record<string, string> = {
|
||||
nba: 'nba',
|
||||
wnba: 'wnba',
|
||||
mlb: 'mlb',
|
||||
// ESPN soccer headshots are inconsistent across leagues — explicitly
|
||||
// omit so soccer falls through to silhouette unless a cached photo
|
||||
// is provided.
|
||||
};
|
||||
export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
|
||||
|
||||
export function getHeadshotUrl(input: HeadshotInput): string {
|
||||
const sport = String(input.sport || '').toLowerCase();
|
||||
const playerId = input.playerId != null ? String(input.playerId) : '';
|
||||
const espnId = input.espnId != null ? String(input.espnId) : '';
|
||||
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
if (playerId) {
|
||||
switch (sport) {
|
||||
case 'nba':
|
||||
return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`;
|
||||
case 'wnba':
|
||||
return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`;
|
||||
case 'mlb':
|
||||
return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (espnId && ESPN_SPORT_PATH[sport]) {
|
||||
return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`;
|
||||
}
|
||||
|
||||
return PLAYER_SILHOUETTE;
|
||||
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`.
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user