Session 43: Data pipeline + audit fixes + depth chart foundation (2045 tests)

P0 fixes + wiring real data into the S42 Player Intelligence architecture.

- P0 dropdown z-index: the nav's backdrop-filter stacking context let the
  Ticker/HeartbeatBar paint over the avatar/More dropdowns and eat clicks.
  nav now position:relative zIndex:2; menus zIndex:100. Avatar Settings ->
  /settings.
- Real MLB stats: mlbStatsAdapter.searchPlayer + getPlayerStats (name->id->
  season+gamelog). playerIntelService.resolvePlayerStats normalizes into the
  archetype classifier; getPlayerIntel returns found:true + real season +
  archetype classified from real stats. NBA via nbaStatsClient (degrades).
- Game cards: slateAdapter.groupPropsByPlayer (playerStrips, name once) +
  mapPitchers (MLB probables), folded into mapScheduleToGameCards. Legacy
  GameCard line grid renders BookChip (brand colors) not grey text.
- Grade card intel: analyzeViaEngine1.buildIntelFields computes stat-context +
  form/usage/matchup/rest from the existing feature vector (zero extra I/O);
  gradeAdapter lights up the card sections. Archetype deferred (needs season
  line at grade time).
- Depth chart foundation: depthChartService (getLineup/getDepthChart/
  getCascadeProjection) + /api/stats/lineup|depth|cascade, graceful + injectable.
- Mobile: player hero name overflow-wrap + 24px on <=640px (was clipping).

Backend 2011 -> 2045 tests (+34), 163 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 15:38:06 -04:00
parent 8bc79f3c38
commit 80683e71b4
19 changed files with 940 additions and 18 deletions
+131 -9
View File
@@ -14,6 +14,15 @@
const { classify } = require('./archetypeService');
const toNum = (v) => {
const n = parseFloat(v);
return Number.isNaN(n) ? 0 : n;
};
const fmt3 = (v) => {
const s = String(v == null ? '' : v);
return s.startsWith('0.') ? s.slice(1) : s; // ".282" baseball style
};
/**
* Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
* that isn't a letter/number/space or name punctuation (. - '), collapse
@@ -39,6 +48,106 @@ async function loadPlayerGrades(sport, name, cacheGetFn) {
return grades.filter((g) => normName(g.player_name || g.player) === target);
}
// ── MLB raw-stat normalization (Session 43) ─────────────────────────
// Maps the raw statsapi.mlb.com season object into (a) classifier input and
// (b) the profile's display rows + last-10 log.
function mapMlbHitter(s) {
const pa = toNum(s.plateAppearances) || toNum(s.atBats);
return {
avg: toNum(s.avg), hr: toNum(s.homeRuns), rbi: toNum(s.rbi), sb: toNum(s.stolenBases),
ops: toNum(s.ops), runs: toNum(s.runs), doubles: toNum(s.doubles),
k_rate: pa > 0 ? (toNum(s.strikeOuts) / pa) * 100 : 0,
};
}
function mapMlbPitcher(s) {
const ip = toNum(s.inningsPitched);
const gs = toNum(s.gamesStarted);
return {
era: toNum(s.era), whip: toNum(s.whip),
k9: toNum(s.strikeoutsPer9Inn) || (ip > 0 ? (toNum(s.strikeOuts) / ip) * 9 : 0),
ip_per_start: gs > 0 ? ip / gs : 0,
saves: toNum(s.saves),
role: gs > 0 ? 'SP' : toNum(s.saves) > 0 ? 'CL' : 'RP',
};
}
function mlbSeasonRows(s, group) {
if (group === 'pitching') {
return [
{ k: 'ERA', v: String(s.era ?? '—') },
{ k: 'K', v: String(s.strikeOuts ?? '—') },
{ k: 'IP', v: String(s.inningsPitched ?? '—') },
{ k: 'WHIP', v: String(s.whip ?? '—') },
{ k: 'GS', v: String(s.gamesStarted ?? '—') },
];
}
return [
{ k: 'AVG', v: fmt3(s.avg) || '—' },
{ k: 'HR', v: String(s.homeRuns ?? '—') },
{ k: 'RBI', v: String(s.rbi ?? '—') },
{ k: 'OPS', v: fmt3(s.ops) || '—' },
{ k: 'GP', v: String(s.gamesPlayed ?? '—') },
];
}
function mlbLast10Rows(log, group) {
return (log || []).slice(-10).reverse().map((g) => {
const st = g.stat || {};
const summary = group === 'pitching'
? `${st.inningsPitched ?? '0'} IP · ${st.strikeOuts ?? 0} K`
: `${st.hits ?? 0}-${st.atBats ?? 0} · ${st.homeRuns ?? 0} HR`;
return { d: String(g.date || '').slice(5), opp: g.opponent ? String(g.opponent).slice(0, 3).toUpperCase() : '', stat: summary };
});
}
/**
* Resolve a player's REAL stats by sport (Session 43). Returns a normalized
* bundle or { found:false }. Best-effort + never throws — the profile renders
* regardless. Adapters are injectable for tests (opts.mlbAdapter/nbaClient).
*/
async function resolvePlayerStats(name, sport, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
try {
if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const res = await mlb.getPlayerStats(name);
if (!res || !res.found) return { found: false };
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
return {
found: true,
team: res.team || '',
classifierInput,
season: mlbSeasonRows(res.season, res.group),
last10: mlbLast10Rows(res.last10, res.group),
splits: [],
};
}
if (sp === 'nba' || sp === 'wnba') {
// NBA/WNBA stats come from the Python nba_api service (nbaStatsClient).
// It's frequently offline in prod (localhost service) — degrade quietly.
const nba = opts.nbaClient || require('./nbaStatsClient');
const data = await nba.getSeasonAvg(name).catch(() => null);
if (!data || typeof data !== 'object') return { found: false };
const ppg = toNum(data.ppg ?? data.points);
if (!ppg) return { found: false };
const classifierInput = {
ppg, rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals),
threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position,
};
const season = [
{ k: 'PPG', v: String(classifierInput.ppg) }, { k: 'RPG', v: String(classifierInput.rpg) },
{ k: 'APG', v: String(classifierInput.apg) }, { k: 'BLK', v: String(classifierInput.bpg) },
];
return { found: true, team: data.team || '', classifierInput, season, last10: [], splits: [] };
}
} catch (err) {
console.warn('[playerIntel] resolvePlayerStats failed:', name, sp, err.message);
}
return { found: false };
}
/** Derive the VYNDR Intelligence metric row from whatever we have. */
function buildIntel(stats, arch, propCount) {
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
@@ -69,10 +178,22 @@ async function getPlayerIntel(name, sport, opts = {}) {
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
const clean = sanitizePlayerName(name);
const sp = String(sport || 'nba').toLowerCase();
const stats = opts.stats || {};
const archetype = classify(sp, stats);
// 1. Real season stats from the sport adapter (Session 43). Injectable for
// tests via opts.resolveStats; opts.stats short-circuits to caller-supplied.
let resolved;
if (opts.stats) {
resolved = { found: Object.keys(opts.stats).length > 0, classifierInput: opts.stats, season: opts.season || [], last10: opts.last10 || [], splits: opts.splits || [], team: opts.team || '' };
} else {
const resolver = opts.resolveStats || resolvePlayerStats;
resolved = await resolver(clean, sp, opts);
}
const realStats = resolved.classifierInput || {};
// 2. Archetype, now classified from REAL stats when we have them.
const archetype = classify(sp, realStats);
// 3. Tonight's graded props from the slate cache.
let props = [];
try {
props = await loadPlayerGrades(sp, clean, cacheGetFn);
@@ -88,22 +209,22 @@ async function getPlayerIntel(name, sport, opts = {}) {
confidence: p.confidence != null ? `${p.confidence}%` : null,
}));
const team = (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';
const team = resolved.team || (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';
return {
player: clean,
sport: sp,
team,
found: props.length > 0 || Object.keys(stats).length > 0,
found: !!resolved.found || props.length > 0,
archetype,
propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
education: archetype.primary ? archetype.primary.education : '',
season: opts.season || [],
last10: opts.last10 || [],
splits: opts.splits || [],
season: resolved.season || opts.season || [],
last10: resolved.last10 || opts.last10 || [],
splits: resolved.splits || opts.splits || [],
gradeHistory: opts.gradeHistory || [],
activeProps,
intel: buildIntel(stats, archetype, props.length),
intel: buildIntel(realStats, archetype, props.length),
injury: opts.injury || null,
};
}
@@ -145,5 +266,6 @@ module.exports = {
sanitizePlayerName,
getPlayerIntel,
getLeaders,
_internals: { normName, loadPlayerGrades, buildIntel },
resolvePlayerStats,
_internals: { normName, loadPlayerGrades, buildIntel, mapMlbHitter, mapMlbPitcher, mlbSeasonRows, mlbLast10Rows },
};