diff --git a/src/services/adapters/espnStatsAdapter.js b/src/services/adapters/espnStatsAdapter.js index 2c90265..5b99a13 100644 --- a/src/services/adapters/espnStatsAdapter.js +++ b/src/services/adapters/espnStatsAdapter.js @@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) { const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id)); 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. const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http); const classifierInput = parseAthleteStats(stats); @@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) { team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '', position: (athlete.position && athlete.position.abbreviation) || '', 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 */ } return result; diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 5b71a68..4cc57b5 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -165,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) { const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes); const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {}; 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 }; } diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index b65b1c9..8446270 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -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`); diff --git a/tests/unit/espnStatsAdapter.test.js b/tests/unit/espnStatsAdapter.test.js index 7f234ce..a8dfea0 100644 --- a/tests/unit/espnStatsAdapter.test.js +++ b/tests/unit/espnStatsAdapter.test.js @@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => { expect(r.found).toBe(true); expect(r.team).toBe('DAL'); 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 () => { @@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => { it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => { const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', { 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.team).toBe('DAL'); 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 () => { diff --git a/tests/unit/headshotThread.test.js b/tests/unit/headshotThread.test.js new file mode 100644 index 0000000..e76fd28 --- /dev/null +++ b/tests/unit/headshotThread.test.js @@ -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'); + }); +}); diff --git a/tests/unit/snapshotService.test.js b/tests/unit/snapshotService.test.js index 2e0150f..9b3c1aa 100644 --- a/tests/unit/snapshotService.test.js +++ b/tests/unit/snapshotService.test.js @@ -115,6 +115,34 @@ describe('runSnapshot (fully injected)', () => { 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 () => { const cache = memCache(); let line = 1.5; diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 56e4244..61964c1 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -16,7 +16,8 @@ import { trackScanLimitHit, trackUpgradeClicked, } 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'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -119,6 +120,9 @@ export default function ScanPage() { const [playerQuery, setPlayerQuery] = useState(''); const [playerSuggestions, setPlayerSuggestions] = useState([]); const [selectedPlayer, setSelectedPlayer] = useState(''); + // 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(null); const [stat, setStat] = useState('points'); const [line, setLine] = useState(''); const [direction, setDirection] = useState<'over' | 'under'>('over'); @@ -306,6 +310,7 @@ export default function ScanPage() { setError(''); setPlayerQuery(''); setSelectedPlayer(''); + setSelectedPlayerId(null); setLine(''); }; @@ -442,7 +447,6 @@ export default function ScanPage() { }} > {tonightsPlayers.map((p) => { - const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport }); const selected = selectedPlayer === p.name; return (