Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)

Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections).

- Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5
  WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend.
  Frontend visual map web/src/lib/archetypes.js (colors verified == backend).
  ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar).
- StatStrip (compact/expanded): player name once, horizontal mono stats,
  inline GradeBadge props, onPlayerClick -> profile.
- Stats API: extended src/routes/stats.js with /player/:name, /leaders,
  /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes
  name param; grades cache; graceful on cold cache). Next proxies added.
- Player Profile /player/[name]: all 9 design sections, graceful empty states.
- Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard
  (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via
  gradeAdapter.buildIntelFields). Player-name links wired everywhere.
- Settings page replaces the S41 redirect (account/subscription/notifications/
  display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the
  real /settings/security MFA page — does not replace it. + BookChip.
- Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added
  Explore + Settings to Nav MORE.

Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate
redesign, Stats Explorer sub-panels.

Backend 1940 -> 2011 tests (+71), 157 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 11:12:24 -04:00
parent 32069863dc
commit 8bc79f3c38
33 changed files with 2655 additions and 22 deletions
+149
View File
@@ -0,0 +1,149 @@
/**
* playerIntelService — aggregates a player's intelligence payload for the
* /player/:name profile page and the stats API (Session 42).
*
* Sources, all best-effort + graceful (the page must render even when every
* upstream is cold):
* - archetypeService.classify → PRIMARY/SECONDARY archetype + DNA blend
* - grades:{sport} cache → this player's graded props for tonight
* - caller-supplied season/last10/splits/gradeHistory (wired by the route
* from the stats adapters; empty when unavailable)
*
* Pure-ish: I/O is only the grades-cache read, and that's injectable for tests.
*/
const { classify } = require('./archetypeService');
/**
* Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
* that isn't a letter/number/space or name punctuation (. - '), collapse
* whitespace, cap length. Defends the cache key + any downstream lookups.
*/
function sanitizePlayerName(raw) {
let decoded = String(raw == null ? '' : raw);
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
return decoded
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60);
}
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
async function loadPlayerGrades(sport, name, cacheGetFn) {
const env = await cacheGetFn(`grades:${sport}`);
const grades = env && Array.isArray(env.grades) ? env.grades : [];
const target = normName(name);
if (!target) return [];
return grades.filter((g) => normName(g.player_name || g.player) === target);
}
/** 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));
// Form: lean on last-10 vs season if provided, else a neutral baseline that
// scales gently with the strongest graded prop's confidence proxy.
const form = stats.form != null ? clamp(Math.round(stats.form), 0, 100) : 70 + clamp(propCount * 4, 0, 22);
const usage = stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—';
return [
{ label: 'FORM', kind: 'form', value: String(form), score: `${clamp(form, 0, 100)}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' },
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
{ label: 'MATCHUP', kind: 'grade', value: arch.primary ? gradeFromForm(form) : 'B', color: '#00D4A0' },
{ label: 'REST', kind: 'plain', value: stats.rest || '+0%', color: '#00D4A0' },
];
}
function gradeFromForm(form) {
if (form >= 90) return 'A';
if (form >= 80) return 'B+';
if (form >= 70) return 'B';
return 'C';
}
/**
* Build the full player intelligence payload.
* opts: { cacheGet, stats, season, last10, splits, gradeHistory, injury, team }
*/
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);
let props = [];
try {
props = await loadPlayerGrades(sp, clean, cacheGetFn);
} catch {
props = []; // cold/broken cache must not 500 the page
}
const activeProps = props.map((p) => ({
stat: p.stat_type || p.stat,
line: p.line,
side: String(p.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
grade: p.grade,
confidence: p.confidence != null ? `${p.confidence}%` : null,
}));
const 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,
archetype,
propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
education: archetype.primary ? archetype.primary.education : '',
season: opts.season || [],
last10: opts.last10 || [],
splits: opts.splits || [],
gradeHistory: opts.gradeHistory || [],
activeProps,
intel: buildIntel(stats, archetype, props.length),
injury: opts.injury || null,
};
}
/**
* Tonight's leaders for a sport — top graded props by confidence, optionally
* filtered to a single stat. Reads the same grades:{sport} cache.
*/
async function getLeaders(sport, opts = {}) {
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
const sp = String(sport || 'nba').toLowerCase();
const stat = opts.stat ? String(opts.stat).toLowerCase() : null;
const limit = Math.max(1, Math.min(50, Number(opts.limit) || 10));
let grades = [];
try {
const env = await cacheGetFn(`grades:${sp}`);
grades = env && Array.isArray(env.grades) ? env.grades : [];
} catch {
grades = [];
}
return grades
.filter((g) => !stat || String(g.stat_type || g.stat || '').toLowerCase() === stat)
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0))
.slice(0, limit)
.map((g) => ({
player: g.player_name || g.player,
team: g.team || g.team_abbr || '',
stat: g.stat_type || g.stat,
line: g.line,
side: String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
grade: g.grade,
confidence: g.confidence,
}));
}
module.exports = {
sanitizePlayerName,
getPlayerIntel,
getLeaders,
_internals: { normName, loadPlayerGrades, buildIntel },
};