Session 19: Sports design overhaul — player cards with headshots, game card redesign, scan page tonight's players, odds diagnostic logging, tier gate utility (1444 tests)

This commit is contained in:
Kev
2026-06-12 00:30:13 -04:00
parent 0e3839a90a
commit 56392ec8f4
12 changed files with 825 additions and 41 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* Player headshot URL construction (Session 19).
*
* 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.
* 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.
*/
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. */
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 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;
}
/**
* 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,
});
}