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:
@@ -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 },
|
||||
};
|
||||
Reference in New Issue
Block a user