47ada9013c
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>
146 lines
6.8 KiB
JavaScript
146 lines
6.8 KiB
JavaScript
/* ============================================================
|
||
VYNDR 2.0 — grade adapter (§7).
|
||
Maps our grading-engine output (the /api/scan ScanResponse shape +
|
||
the GradeCard props) onto the GradeResultCard data contract from the
|
||
design spec. Plain CommonJS so the .tsx card imports it (allowJs) AND
|
||
Jest exercises the mapping logic directly (no TS/Babel transform).
|
||
============================================================ */
|
||
|
||
const STAT_LABELS = {
|
||
points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers',
|
||
steals: 'Steals', blocks: 'Blocks', pra: 'P+R+A', turnovers: 'Turnovers',
|
||
strikeouts: 'Strikeouts', hits_allowed: 'Hits Allowed', earned_runs: 'Earned Runs',
|
||
innings_pitched: 'Innings Pitched', hits: 'Hits', total_bases: 'Total Bases',
|
||
rbi: 'RBI', runs: 'Runs', home_runs: 'Home Runs',
|
||
};
|
||
|
||
/** Humanize a stat_type id ("home_runs" → "Home Runs"). */
|
||
function statLabel(stat) {
|
||
if (!stat) return '';
|
||
if (STAT_LABELS[stat]) return STAT_LABELS[stat];
|
||
return String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||
}
|
||
|
||
/** Signed edge as a percentage of the line, from projection vs line. */
|
||
function computeEdge(projection, line, direction) {
|
||
if (projection == null || line == null) return 0;
|
||
const raw = direction === 'under' ? line - projection : projection - line;
|
||
const pct = line > 0 ? (raw / line) * 100 : raw;
|
||
return Math.round(pct * 10) / 10;
|
||
}
|
||
|
||
/** A/A+ with strong support → "phosphor confirmed". */
|
||
function isPhosphorConfirmed(grade, confidence, sampleSize) {
|
||
const g = String(grade || '').trim().toUpperCase();
|
||
const strong = g === 'A+' || g === 'A';
|
||
return strong && ((confidence != null && confidence >= 70) || (sampleSize != null && sampleSize >= 30));
|
||
}
|
||
|
||
/** factors object/array → plain-English signal bullets (4–6). */
|
||
function toSignals(factors) {
|
||
if (!factors) return [];
|
||
if (Array.isArray(factors)) return factors.filter(Boolean).slice(0, 6).map(String);
|
||
return Object.entries(factors)
|
||
.filter(([, v]) => Boolean(v))
|
||
.slice(0, 6)
|
||
.map(([k, v]) => `${k.replace(/_/g, ' ').replace(/\b\w/, (c) => c.toUpperCase())}: ${v}`);
|
||
}
|
||
|
||
/**
|
||
* Map an engine result to the GradeResultCard contract.
|
||
* input: { player, team, sport, stat, line, direction|side, grade, projection,
|
||
* confidence, sample_size, factors, alt_lines, kill_conditions, books, tier }
|
||
*/
|
||
function mapScanToGradeResult(input = {}) {
|
||
const direction = (input.side || input.direction || 'over').toString().toLowerCase();
|
||
const side = direction === 'under' ? 'Under' : 'Over';
|
||
const line = input.line != null ? Number(input.line) : 0;
|
||
const projection = input.projection != null ? Number(input.projection) : undefined;
|
||
const confidence = input.confidence != null ? Math.round(Number(input.confidence)) : 0;
|
||
const tier = input.tier || 'free';
|
||
const includeAlt = tier === 'desk';
|
||
// Tier gating so the new card doesn't give paid content away (free users get
|
||
// a 3-signal teaser; kill conditions are Analyst+; alt ladder is Desk). The
|
||
// richer blurred-paywall treatment returns in Phase G (Session 38).
|
||
const allSignals = toSignals(input.factors);
|
||
const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals;
|
||
// Session 62 (A1-S1) — the pricing page promises free users a LOCKED
|
||
// PREVIEW of kill conditions (the count + codes exist server-side via
|
||
// tierGating), not their absence. Free sees "⚠ TRAP — upgrade to see
|
||
// details"; paid sees the reasons.
|
||
const killConditions = tier === 'free'
|
||
? (input.kill_conditions || [])
|
||
.map((k) => (k && k.code ? `${k.code} — upgrade to see details` : ''))
|
||
.filter(Boolean)
|
||
: (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean);
|
||
|
||
return {
|
||
player: input.player || '',
|
||
team: input.team || '',
|
||
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),
|
||
line,
|
||
side,
|
||
grade: input.grade || '—',
|
||
confidence,
|
||
// DATA SEMANTICS (Session 58): projection is MODEL output and must never
|
||
// be fabricated. The old fallback displayed the LINE as the projection —
|
||
// the audit's model==line / +0% edge degenerate. No projection → the
|
||
// MODEL row is absent and edge is null (the card renders an absent state,
|
||
// not a fake zero-edge read).
|
||
edge: projection != null ? computeEdge(projection, line, direction) : null,
|
||
projection: projection != null ? Math.round(projection * 10) / 10 : null,
|
||
phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size),
|
||
signals,
|
||
killConditions,
|
||
books: Array.isArray(input.books) ? input.books : [],
|
||
altLadder: includeAlt && Array.isArray(input.alt_lines)
|
||
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade, edge: a.edge_pct, base: a.base }))
|
||
: [],
|
||
// Session 62 (A1-S1) — quarter-Kelly (Desk; API already strips below).
|
||
kelly: includeAlt && input.kelly && input.kelly.pct != null ? input.kelly : undefined,
|
||
// Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
|
||
// they only render once the engine supplies them (Session 43 data pipeline).
|
||
...buildIntelFields(input),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build the optional archetype / stat-context / vyndr-intelligence fields for
|
||
* the grade card from whatever the engine provided. Returns {} when nothing is
|
||
* present so the card sections stay hidden (no empty boxes).
|
||
*/
|
||
function buildIntelFields(input) {
|
||
const out = {};
|
||
if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
|
||
out.archetypeBlend = input.archetype_blend;
|
||
} else if (input.archetype) {
|
||
out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
|
||
}
|
||
if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
|
||
out.propDNA = {
|
||
reliable: input.prop_dna.reliable || [],
|
||
volatile: input.prop_dna.volatile || [],
|
||
};
|
||
}
|
||
const sc = {};
|
||
if (input.season_avg != null) sc.season = String(input.season_avg);
|
||
if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
|
||
if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
|
||
if (Object.keys(sc).length) out.statContext = sc;
|
||
|
||
const vi = {};
|
||
if (input.form != null) vi.form = input.form;
|
||
if (input.usage != null) vi.usage = String(input.usage);
|
||
if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
|
||
if (input.rest != null) vi.rest = String(input.rest);
|
||
if (Object.keys(vi).length) out.vyndrIntel = vi;
|
||
|
||
return out;
|
||
}
|
||
|
||
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields };
|