Files
vyndr/web/src/lib/gradeAdapter.js
T
builtbykev 47ada9013c 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>
2026-07-13 13:18:59 -04:00

146 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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 (46). */
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 };