Merge Wave 2A (wiring/data): sport-agnostic headshot id thread-through

MLB MLBAM + NBA/WNBA ESPN athlete ids captured at ingestion (zero new I/O),
threaded onto grades → strips → PlayerAvatar across slate/scan/hotlist/search/
grade-card. Soccer honest-monogram (no free id). +11 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 13:19:47 -04:00
17 changed files with 381 additions and 121 deletions
+10
View File
@@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) {
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id)); const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
if (!id) return { found: false }; if (!id) return { found: false };
// Wave 2A — the REAL ESPN athlete id for the headshot CDN
// (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the
// pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot
// id, so it degrades to null → monogram. Never fabricate.
const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null;
const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null;
// 2. Fetch that athlete's stats overview. // 2. Fetch that athlete's stats overview.
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http); const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
const classifierInput = parseAthleteStats(stats); const classifierInput = parseAthleteStats(stats);
@@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) {
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '', team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
position: (athlete.position && athlete.position.abbreviation) || '', position: (athlete.position && athlete.position.abbreviation) || '',
classifierInput, classifierInput,
// Wave 2A — surfaced so resolvePlayerStats can thread it to the grade →
// slate strip → headshot. Absent → monogram (doctrine).
espnId,
}; };
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ } try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
return result; return result;
+3 -1
View File
@@ -165,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) {
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes); const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {}; const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) }); if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] }; // Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
// adapter. Absent → no id → monogram. Never guessed.
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
} }
return { found: false }; return { found: false };
} }
+21 -4
View File
@@ -298,6 +298,13 @@ async function runSnapshot(sport, opts = {}) {
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game). // and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {}; 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 // Wave 1 (trust bug) — the prop's game participants become the resolve's
// teamHint: it disambiguates namesake collisions (two "James Wood") and, when // 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 // 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 || {}); const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null; archByPlayer[player] = c.primary ? c.primary.name : null;
if (stats.team) teamByPlayer[player] = stats.team; 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) { if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
logEntries.push({ logEntries.push({
name: normalizeName(player).display || player, name: normalizeName(player).display || player,
@@ -341,12 +351,19 @@ async function runSnapshot(sport, opts = {}) {
}); });
await mergeRosterLogs(sp, logEntries, deps); await mergeRosterLogs(sp, logEntries, deps);
const enriched = graded.map((g) => ({ const enriched = graded.map((g) => {
const pn = g.player || g.player_name;
return {
...g, ...g,
gradedAt: gradedAtFor(g, oddsByKey, ts), gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null, archetype: archByPlayer[pn] || null,
team: teamByPlayer[g.player || g.player_name] || g.team || 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. // Line deltas vs the previous snapshot's locked lines.
const prev = await deps.cacheGet(`snapshot:${sp}:latest`); const prev = await deps.cacheGet(`snapshot:${sp}:latest`);
+17 -1
View File
@@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => {
expect(r.found).toBe(true); expect(r.found).toBe(true);
expect(r.team).toBe('DAL'); expect(r.team).toBe('DAL');
expect(r.classifierInput.ppg).toBe(33); expect(r.classifierInput.ppg).toBe(33);
// Wave 2A — the REAL ESPN athlete id is surfaced (headshot CDN), not discarded.
expect(r.espnId).toBe('123');
});
it('Wave 2A — a non-numeric uid degrades espnId to null (never fabricated)', async () => {
const http = {
get: async (url) => {
if (url.includes('/search')) return { data: { items: [{ uid: 's:40~l:46~a:999', displayName: 'X', team: {}, position: {} }] } };
return { data: { statistics: { splits: { categories: [{ stats: [{ name: 'avgPoints', value: 10 }] }] } } } };
},
};
const r = await espn.getSeasonAverages('X', 'nba', { http });
expect(r.found).toBe(true);
expect(r.espnId).toBeNull();
}); });
it('degrades to found:false when ESPN errors', async () => { it('degrades to found:false when ESPN errors', async () => {
@@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => {
it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => { it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => {
const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', { const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', {
nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } }, nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } },
espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 } }) }, espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 }, espnId: '3945274' }) },
}); });
expect(r.found).toBe(true); expect(r.found).toBe(true);
expect(r.team).toBe('DAL'); expect(r.team).toBe('DAL');
expect(r.classifierInput.ppg).toBe(33); expect(r.classifierInput.ppg).toBe(33);
// Wave 2A — espnId surfaces through resolvePlayerStats → the snapshot grade.
expect(r.espnId).toBe('3945274');
}); });
it('found:false when both sources are empty', async () => { it('found:false when both sources are empty', async () => {
+110
View File
@@ -0,0 +1,110 @@
// Wave 2A (WIRING & DATA TRAIN, Step 2) — the headshot id thread.
//
// Doctrine: a REAL athlete photo where an id resolves; a team-colored monogram
// (NEVER a gray silhouette, NEVER a broken image) where it can't. The id is
// NEVER fabricated — it rides free on the snapshot's per-player stats resolve.
//
// This suite locks the four links of the chain:
// (a) getHeadshotUrl builds the right per-league CDN URL from a known id
// (b) an MLB grade with a resolved MLBAM id → playerId on the strip
// (c) an NBA/WNBA grade with a resolved ESPN id → espnId on the strip
// (d) a grade with NO id → strip carries no id → PlayerAvatar falls to a
// monogram (the null path). Absent beats fabricated.
// The PURE URL core is CommonJS (the .ts re-exports it verbatim); jest can't
// transform the .ts, so we require the same single source of truth here.
const { getHeadshotUrl } = require('../../web/src/lib/playerHeadshotUrl');
const adapter = require('../../web/src/lib/slateAdapter');
describe('(a) getHeadshotUrl — per-league CDN URL from a real id', () => {
it('MLB → img.mlbstatic.com via the MLBAM people id', () => {
// Aaron Judge = MLBAM 592450.
const url = getHeadshotUrl({ sport: 'mlb', playerId: 592450 });
expect(url).toBe(
'https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/592450/headshot/67/current',
);
});
it('NBA → a.espncdn headshot from the ESPN athlete id (espnId, no playerId)', () => {
const url = getHeadshotUrl({ sport: 'nba', espnId: 3945274 });
expect(url).toBe(
'https://a.espncdn.com/combiner/i?img=/i/headshots/nba/players/full/3945274.png&w=130&h=95',
);
});
it('WNBA → a.espncdn headshot from the ESPN athlete id', () => {
const url = getHeadshotUrl({ sport: 'wnba', espnId: 4066533 });
expect(url).toBe(
'https://a.espncdn.com/combiner/i?img=/i/headshots/wnba/players/full/4066533.png&w=130&h=95',
);
});
it('dormant NFL/NHL leagues now resolve an ESPN headshot path (cheap correctness)', () => {
expect(getHeadshotUrl({ sport: 'nfl', espnId: 3139477 })).toContain('/headshots/nfl/players/full/3139477.png');
expect(getHeadshotUrl({ sport: 'nhl', espnId: 3024816 })).toContain('/headshots/nhl/players/full/3024816.png');
});
it('no id at all → the neutral silhouette sentinel (component swaps to monogram)', () => {
expect(getHeadshotUrl({ sport: 'mlb' })).toBe('/images/player-silhouette.svg');
expect(getHeadshotUrl({ sport: 'soccer', playerId: 123 })).toBe('/images/player-silhouette.svg');
});
});
describe('(b) MLB grade → playerId threads onto the strip', () => {
it('carries the MLBAM playerId from the enriched grade to the strip prop group', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5, home_team: 'NYY', away_team: 'BOS' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Aaron Judge', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A',
playerId: 592450, team: 'NYY',
gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].playerId).toBe(592450);
expect(strips[0].espnId).toBeUndefined();
// And the id builds the real MLB headshot.
expect(getHeadshotUrl({ sport: 'mlb', playerId: strips[0].playerId })).toContain('/people/592450/headshot');
});
});
describe('(c) NBA/WNBA grade → espnId threads onto the strip', () => {
it('carries the ESPN espnId from the enriched grade to the strip prop group', () => {
const props = [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, home_team: 'IND', away_team: 'CHI' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'B+',
espnId: 4433403, team: 'IND',
gradedAt: { line: 22.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].espnId).toBe(4433403);
expect(strips[0].playerId).toBeUndefined();
expect(getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId })).toContain('/players/full/4433403.png');
});
});
describe('(d) no resolved id → monogram path (never a fabricated face)', () => {
it('a grade with no id → strip has neither playerId nor espnId', () => {
const props = [{ player: 'Unknown Prospect', stat_type: 'hits', line: 0.5, home_team: 'NYY', away_team: 'BOS' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Unknown Prospect', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'C',
team: 'NYY',
gradedAt: { line: 0.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips[0].playerId).toBeUndefined();
expect(strips[0].espnId).toBeUndefined();
// PlayerAvatar renders `url = (playerId!=null || espnId!=null) ? … : null`,
// so an absent id yields a null url → the branded monogram. Prove the
// resolver returns the silhouette sentinel (which the component swaps out)
// rather than a fabricated CDN URL when no id is present.
expect(getHeadshotUrl({ sport: 'mlb', playerId: strips[0].playerId, espnId: strips[0].espnId }))
.toBe('/images/player-silhouette.svg');
});
});
+28
View File
@@ -115,6 +115,34 @@ describe('runSnapshot (fully injected)', () => {
expect(cache.store['grades:mlb'].grades).toHaveLength(2); expect(cache.store['grades:mlb'].grades).toHaveLength(2);
}); });
it('Wave 2A — threads the resolved athlete id (playerId/espnId) onto the enriched grade', async () => {
const cache = memCache();
const d = deps(cache);
// Judge resolves an MLBAM id; Betts resolves an ESPN id (cross-sport shape).
d.resolveStats = async (player) => (player === 'Aaron Judge'
? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 }, playerId: 592450 }
: { found: true, classifierInput: {}, espnId: 4433403 });
await svc.runSnapshot('mlb', d);
const snap = cache.store['snapshot:mlb:latest'];
const judge = snap.grades.find((g) => g.player === 'Aaron Judge');
const betts = snap.grades.find((g) => g.player === 'Mookie Betts');
expect(judge.playerId).toBe(592450);
expect(betts.espnId).toBe(4433403);
// grades:{sport} inherits the same ids (GameCard/Explore read from it).
const g = cache.store['grades:mlb'].grades.find((x) => x.player === 'Aaron Judge');
expect(g.playerId).toBe(592450);
});
it('Wave 2A — no resolved id → enriched grade carries null ids (monogram path)', async () => {
const cache = memCache();
const d = deps(cache);
d.resolveStats = async () => ({ found: false }); // nothing resolves
await svc.runSnapshot('mlb', d);
const snap = cache.store['snapshot:mlb:latest'];
expect(snap.grades[0].playerId).toBeNull();
expect(snap.grades[0].espnId).toBeNull();
});
it('rotates latest → previous and computes deltas on the second run', async () => { it('rotates latest → previous and computes deltas on the second run', async () => {
const cache = memCache(); const cache = memCache();
let line = 1.5; let line = 1.5;
+25 -39
View File
@@ -16,7 +16,8 @@ import {
trackScanLimitHit, trackScanLimitHit,
trackUpgradeClicked, trackUpgradeClicked,
} from '@/lib/analytics'; } from '@/lib/analytics';
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
type Sport = 'NBA' | 'MLB' | 'WNBA'; type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -119,6 +120,9 @@ export default function ScanPage() {
const [playerQuery, setPlayerQuery] = useState(''); const [playerQuery, setPlayerQuery] = useState('');
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]); const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
const [selectedPlayer, setSelectedPlayer] = useState<string>(''); const [selectedPlayer, setSelectedPlayer] = useState<string>('');
// Wave 2A — the MLBAM id of the player picked from search (MLB only; numeric).
// Feeds the grade card's real headshot. null → team-colored monogram.
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [stat, setStat] = useState<string>('points'); const [stat, setStat] = useState<string>('points');
const [line, setLine] = useState<string>(''); const [line, setLine] = useState<string>('');
const [direction, setDirection] = useState<'over' | 'under'>('over'); const [direction, setDirection] = useState<'over' | 'under'>('over');
@@ -306,6 +310,7 @@ export default function ScanPage() {
setError(''); setError('');
setPlayerQuery(''); setPlayerQuery('');
setSelectedPlayer(''); setSelectedPlayer('');
setSelectedPlayerId(null);
setLine(''); setLine('');
}; };
@@ -442,7 +447,6 @@ export default function ScanPage() {
}} }}
> >
{tonightsPlayers.map((p) => { {tonightsPlayers.map((p) => {
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
const selected = selectedPlayer === p.name; const selected = selectedPlayer === p.name;
return ( return (
<button <button
@@ -450,6 +454,7 @@ export default function ScanPage() {
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedPlayer(p.name); setSelectedPlayer(p.name);
setSelectedPlayerId(null); // tonight chips carry no id → monogram
setPlayerQuery(p.name); setPlayerQuery(p.name);
setPlayerSuggestions([]); setPlayerSuggestions([]);
// If the player has exactly one stat type with // If the player has exactly one stat type with
@@ -475,22 +480,9 @@ export default function ScanPage() {
minWidth: 0, minWidth: 0,
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* Wave 2A — tonight's players carry no id → team-colored
<img monogram (branded, never a gray silhouette). */}
src={headshot} <PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
alt=""
width={24}
height={24}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
objectFit: 'cover',
}}
/>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}> <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
{p.name} {p.name}
</span> </span>
@@ -511,6 +503,7 @@ export default function ScanPage() {
onChange={(e) => { onChange={(e) => {
setPlayerQuery(e.target.value); setPlayerQuery(e.target.value);
setSelectedPlayer(''); setSelectedPlayer('');
setSelectedPlayerId(null);
}} }}
autoComplete="off" autoComplete="off"
/> />
@@ -551,33 +544,25 @@ export default function ScanPage() {
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
setSelectedPlayer(p.full_name); setSelectedPlayer(p.full_name);
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
setPlayerQuery(p.full_name); setPlayerQuery(p.full_name);
setPlayerSuggestions([]); setPlayerSuggestions([]);
}} }}
style={suggestionStyle} style={suggestionStyle}
> >
{/* Session 19 — headshot in search suggestions. The {/* Wave 2A — real headshot in search suggestions. MLB's
/api/players/search response doesn't (yet) include /api/players/search carries the MLBAM id on p.id (numeric);
league IDs, so we hit ESPN-CDN fallback or NBA/WNBA ids are synthetic → guarded out → team-colored
silhouette. onError swaps in the silhouette when monogram. Never a gray silhouette. */}
the league hasn't published one. */} <span style={{ marginRight: 10, display: 'inline-flex' }}>
{/* eslint-disable-next-line @next/next/no-img-element */} <PlayerAvatar
<img name={p.full_name}
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })} sport={sport.toLowerCase() as HeadshotSport}
alt="" playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
width={28} team={p.team}
height={28} size={28}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
marginRight: 10,
objectFit: 'cover',
}}
/> />
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span> <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
{p.team && ( {p.team && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}> <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
@@ -731,6 +716,7 @@ export default function ScanPage() {
key={`${selectedPlayer}-${stat}-${line}-${direction}`} key={`${selectedPlayer}-${stat}-${line}-${direction}`}
data={mapScanToGradeResult({ data={mapScanToGradeResult({
player: selectedPlayer, player: selectedPlayer,
playerId: selectedPlayerId,
sport, sport,
stat, stat,
line: Number(line), line: Number(line),
+10 -11
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import type { HeadshotSport } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate'; import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/** /**
@@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit
{visible.map((p) => ( {visible.map((p) => (
<div key={`${p.name}-${p.stat}`} style={rowStyle}> <div key={`${p.name}-${p.stat}`} style={rowStyle}>
<span style={rankStyle}>#{p.rank}</span> <span style={rankStyle}>#{p.rank}</span>
<img {/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM);
src={getHeadshotUrl({ sport, playerId: p.playerId })} team-colored monogram otherwise. Never a gray silhouette. */}
alt={p.name} <PlayerAvatar
width={36} name={p.name}
height={36} sport={sport as HeadshotSport}
style={avatarStyle} playerId={p.playerId}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} team={p.team}
size={36}
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div> <div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
@@ -103,9 +105,6 @@ const rankStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28, flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
color: 'var(--text-tertiary, #6A6A78)', color: 'var(--text-tertiary, #6A6A78)',
}; };
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
};
const playerName: React.CSSProperties = { const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
+5
View File
@@ -37,6 +37,9 @@ export interface GameProp {
export interface PlayerStrip { export interface PlayerStrip {
player: string; player: string;
team: string; team: string;
// Wave 2A — real headshot ids threaded from the snapshot grade.
playerId?: string | number | null;
espnId?: string | number | null;
archetype?: StripArchetype; archetype?: StripArchetype;
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null; lineup?: { status: string; slot?: number } | null;
@@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
player={ps.player} player={ps.player}
team={ps.team} team={ps.team}
sport={g.sport} sport={g.sport}
playerId={ps.playerId}
espnId={ps.espnId}
archetype={ps.archetype} archetype={ps.archetype}
lineup={ps.lineup} lineup={ps.lineup}
injury={ps.injury} injury={ps.injury}
+12 -1
View File
@@ -6,6 +6,8 @@ import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn'; import VBtn from '@/components/vyndr/VBtn';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import GradeBadge from '@/components/vyndr/GradeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
import { edgeColor, gradeGlows } from '@/lib/colorContract'; import { edgeColor, gradeGlows } from '@/lib/colorContract';
import { playerHref } from '@/lib/playerHref'; import { playerHref } from '@/lib/playerHref';
@@ -14,6 +16,10 @@ export interface GradeResultData {
player: string; player: string;
team: string; team: string;
sport: string; sport: string;
// Wave 2A — real headshot ids (optional; self-hide to a team-colored
// monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId.
playerId?: string | number | null;
espnId?: string | number | null;
stat: string; stat: string;
line: number; line: number;
side: 'Over' | 'Under'; side: 'Over' | 'Under';
@@ -96,13 +102,18 @@ export default function GradeResultCard({
{/* 1. HEADER — player name links to the full profile (Session 42) */} {/* 1. HEADER — player name links to the full profile (Session 42) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
{/* Wave 2A — player identity block: real headshot / team-colored
monogram (never a gray silhouette). */}
<PlayerAvatar name={d.player} sport={d.sport as HeadshotSport} playerId={d.playerId} espnId={d.espnId} team={d.team} size={40} />
<div style={{ minWidth: 0 }}>
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a> <a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}> <div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span> <span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat} <span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
</div> </div>
</div> </div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<SportBadge sport={d.sport} /> <SportBadge sport={d.sport} />
{d.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{d.team}</span>} {d.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{d.team}</span>}
+10 -1
View File
@@ -21,17 +21,26 @@ export default function PlayerAvatar({
name, name,
sport = 'mlb', sport = 'mlb',
playerId, playerId,
espnId,
team, team,
size = 36, size = 36,
}: { }: {
name: string; name: string;
sport?: HeadshotSport; sport?: HeadshotSport;
/** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */
playerId?: string | number | null; playerId?: string | number | null;
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
team?: string | null; team?: string | null;
size?: number; size?: number;
}) { }) {
const [broken, setBroken] = useState(false); const [broken, setBroken] = useState(false);
const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null; // 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 })
: null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF'; const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken; const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
+22 -2
View File
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { playerHref } from '@/lib/playerHref'; import { playerHref } from '@/lib/playerHref';
import { searchTeams, teamHref } from '@/lib/teams'; import { searchTeams, teamHref } from '@/lib/teams';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
/** /**
* SearchModal — S6 (A1 board). Global search over players + teams. * SearchModal — S6 (A1 board). Global search over players + teams.
@@ -31,6 +33,8 @@ interface ResultItem {
sub: string; // team / abbr context sub: string; // team / abbr context
sport: string; sport: string;
href: string; href: string;
// Wave 2A — MLBAM headshot id (MLB only; NBA/WNBA search ids are synthetic).
playerId?: string | number | null;
} }
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) { export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
const seen = new Set<string>(); const seen = new Set<string>();
const merged: ResultItem[] = []; const merged: ResultItem[] = [];
for (const res of responses) { for (const res of responses) {
for (const p of res.players as Array<{ full_name?: string; team?: string }>) { for (const p of res.players as Array<{ full_name?: string; team?: string; id?: string | number }>) {
const name = String(p.full_name || '').trim(); const name = String(p.full_name || '').trim();
if (!name) continue; if (!name) continue;
const key = `${name.toLowerCase()}|${res.sport}`; const key = `${name.toLowerCase()}|${res.sport}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
// MLB's search id is the real MLBAM id → headshot. Other sports'
// ids are synthetic → guarded out → monogram.
const playerId = res.sport === 'mlb' && p.id != null && /^\d+$/.test(String(p.id)) ? p.id : null;
merged.push({ merged.push({
kind: 'player', kind: 'player',
label: name, label: name,
sub: p.team ? String(p.team) : '', sub: p.team ? String(p.team) : '',
sport: res.sport, sport: res.sport,
href: playerHref(name, res.sport), href: playerHref(name, res.sport),
playerId,
}); });
if (merged.length >= 9) break; if (merged.length >= 9) break;
} }
@@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
fontSize: 13, fontSize: 13,
}} }}
> >
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */} {/* ROW-GRAMMAR: never truncate a name — wrap instead. Wave 2A adds
the player identity avatar (real headshot / branded monogram). */}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
{it.kind === 'player' && (
<PlayerAvatar
name={it.label}
sport={it.sport as HeadshotSport}
playerId={it.playerId}
team={it.sub || null}
size={26}
/>
)}
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span> <span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>} {it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}> <span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
+7 -1
View File
@@ -195,6 +195,10 @@ interface StatStripProps {
player: string; player: string;
team: string; team: string;
sport?: string; sport?: string;
// Wave 2A — real headshot ids threaded from the snapshot grade. MLB → MLBAM
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
playerId?: string | number | null;
espnId?: string | number | null;
archetype?: StripArchetype; archetype?: StripArchetype;
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire). // Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null; lineup?: { status: string; slot?: number } | null;
@@ -225,6 +229,8 @@ export default function StatStrip({
player, player,
team, team,
sport, sport,
playerId,
espnId,
archetype, archetype,
lineup, lineup,
injury, injury,
@@ -378,7 +384,7 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram {/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */} (never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} team={team} size={30} /> <PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName> <PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span> <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />} {archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
+3
View File
@@ -78,6 +78,9 @@ function mapScanToGradeResult(input = {}) {
player: input.player || '', player: input.player || '',
team: input.team || '', team: input.team || '',
sport: (input.sport || 'nba').toString().toLowerCase(), sport: (input.sport || 'nba').toString().toLowerCase(),
// Wave 2A — real headshot ids (optional; card self-hides to a monogram).
playerId: input.playerId != null ? input.playerId : null,
espnId: input.espnId != null ? input.espnId : null,
stat: statLabel(input.stat), stat: statLabel(input.stat),
line, line,
side, side,
+26 -56
View File
@@ -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 * The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's
* primary because (a) ESPN rate-limits image hotlinking and (b) the * requireable by the plain-JS Jest suite AND importable here (allowJs) — same
* league CDNs are the same sources PrizePicks and Sleeper use, so * single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This
* coverage is closer to 100%. * 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: * Fallback chain inside the resolver:
* 1. `cachedPhotoUrl` — if our backend has a stored URL (soccer, * 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct
* where API-Football returns the photo in player responses), use * soccer URLs (no central CDN for player headshots).
* that directly. We DO NOT construct soccer URLs because no
* central CDN exists for player headshots.
* 2. League CDN with `playerId` — official source. * 2. League CDN with `playerId` — official source.
* 3. ESPN CDN fallback — only when `espnId` is present and no * 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
* league ID is available. Helpful while the roster table is * 4. `/images/player-silhouette.svg` — the sentinel the consuming component
* being backfilled with league-specific IDs. * swaps for a team-colored MONOGRAM (never a gray blob, never a broken
* 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette. * image). `<img onError>` also degrades a 404 to the monogram.
*
* `<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.
*/ */
import {
getHeadshotUrl as coreGetHeadshotUrl,
PLAYER_SILHOUETTE as CORE_SILHOUETTE,
} from '@/lib/playerHeadshotUrl';
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string; export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
export interface HeadshotInput { export interface HeadshotInput {
sport: HeadshotSport; sport: HeadshotSport;
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */ /** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
playerId?: string | number | null; 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; espnId?: string | number | null;
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */ /** Pre-cached photo URL (used by soccer where each league has no central CDN). */
cachedPhotoUrl?: string | null; cachedPhotoUrl?: string | null;
} }
export const PLAYER_SILHOUETTE = '/images/player-silhouette.svg'; export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
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 { export function getHeadshotUrl(input: HeadshotInput): string {
const sport = String(input.sport || '').toLowerCase(); return coreGetHeadshotUrl(input);
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 * Convenience wrapper for the common case where the caller has a player object
* player object with mixed ID fields. Pulls the first non-empty ID * with mixed ID fields. Pulls the first non-empty ID out of the union before
* out of the union before delegating to `getHeadshotUrl`. * delegating to `getHeadshotUrl`.
*/ */
export function headshotFromPlayer(player: { export function headshotFromPlayer(player: {
sport?: string; sport?: string;
+61
View File
@@ -0,0 +1,61 @@
/**
* Player headshot URL construction — PURE core (Wave 2A).
*
* CommonJS on purpose: importable by the `.ts` resolver (`playerHeadshot.ts`,
* allowJs) AND requireable by the plain-JS Jest suite — same doctrine as
* `vyndrTokens.js` / `playerName.js`. Keep the URL logic HERE so it stays
* genuinely unit-testable (jest can't transform the `.ts`).
*
* Each league hosts its own CDN. Fallback chain inside the resolver:
* 1. `cachedPhotoUrl` — a stored URL (soccer, where API-Football returns the
* photo). We DO NOT construct soccer URLs (no central CDN).
* 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 component swaps for a
* team-colored MONOGRAM (never a gray blob, never a broken image).
*/
const PLAYER_SILHOUETTE = '/images/player-silhouette.svg';
const ESPN_SPORT_PATH = {
nba: 'nba',
wnba: 'wnba',
mlb: 'mlb',
// Wave 2A — dormant leagues (not in ACTIVE_SPORTS + off-season). Cheap
// correctness: an ESPN athlete id ingested later resolves for free.
nfl: 'nfl',
nhl: 'nhl',
// ESPN soccer headshots are inconsistent across leagues — explicitly omit so
// soccer falls through to the silhouette unless a cached photo is provided.
};
function getHeadshotUrl(input) {
input = input || {};
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;
}
module.exports = { PLAYER_SILHOUETTE, ESPN_SPORT_PATH, getHeadshotUrl };
+7
View File
@@ -348,6 +348,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
player: displayName(p.player), player: displayName(p.player),
team: knownTeam, team: knownTeam,
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
// Wave 2A — real headshot id threaded from the snapshot grade
// (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram.
playerId: (rec && rec.playerId != null ? rec.playerId : undefined),
espnId: (rec && rec.espnId != null ? rec.espnId : undefined),
lineup: lineupStatusFor(pk, knownTeam), lineup: lineupStatusFor(pk, knownTeam),
injury: injuryFor(pk), injury: injuryFor(pk),
stats: [], stats: [],
@@ -359,6 +363,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
const cand = displayName(p.player); const cand = displayName(p.player);
if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand; if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand;
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype }; if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
// Fill an id from a later graded row if the first row lacked one.
if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId;
if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId;
} }
if (rec) { if (rec) {
const side = sideCh(rec.direction); const side = sideCh(rec.direction);