Files
vyndr/src/services/depthChartService.js
T
builtbykev 7969a4971a Session 44: Make it visible — VYNDR archetype names, grade intel, schedule fix, landing page (2061 tests)
Frontend + wiring only. Wires existing backend into the pages users see.

- VYNDR Original archetype rename (41) across archetypeService.js + lib/
  archetypes.js + ArchetypeBadge, each keeping legacyName (resolves stale data).
  Judge -> BOMBER. Old POWER PULL slot -> WHIFF strikeout-artist pitcher.
- BACKEND_HANDOFF.md: canonical frontend<->backend data contract.
- Grade card intel: scan/page.tsx now forwards the engine's intel fields
  (season_avg/form/usage/matchup_grade/archetype/...) into mapScanToGradeResult
  -> STAT CONTEXT + VYNDR INTELLIGENCE sections populate. The chain already
  preserved them (tierGating + /api/scan spread); the page was dropping them.
- Schedule freshness: slateAdapter.isRelevantGame drops completed games >24h
  old; Slate.filteredGames applies it. (TTL already 60s.)
- Landing: Features.tsx rewritten to user-facing copy (no Point-biserial/Zone
  14/ABS/Phi-coefficient).
- Depth chart Next proxies added (/api/stats/lineup|depth|cascade) - were 404.
- GameCard swap DEFERRED (Kev): legacy on-demand card stays as a bridge until
  the snapshot pipeline populates the grades cache; vyndr/GameCard swaps in then.

Backend 2045 -> 2061 tests (+16), 167 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:15:38 -04:00

132 lines
5.3 KiB
JavaScript

/**
* depthChartService — lineup / depth-chart / cascade foundation (Session 43).
*
* Provides the data model + graceful aggregation for:
* - getLineup(sport, team) — tonight's projected lineup / starters
* - getDepthChart(sport, team) — starters + backups per position
* - getCascadeProjection(...) — "when X is OUT, teammate Y gets +delta"
*
* Sources (all best-effort, injectable for tests, never throws):
* - mlbStatsAdapter.getScheduleWithPitchers → probable pitchers (MLB)
* - scheduleService.getGameSummary → ESPN injuries / leaders
*
* This is the FOUNDATION: contracts + real data where it's freely available,
* graceful empty defaults elsewhere. The minutes/usage projection model and
* full batting orders arrive with the Session-44/45 pipelines.
*/
const norm = (s) => String(s == null ? '' : s).trim().toUpperCase();
/** Today's UTC date (YYYY-MM-DD). Injectable for deterministic tests. */
function todayISO(now) {
return (now || new Date()).toISOString().slice(0, 10);
}
/**
* Tonight's projected lineup for a team. MLB returns the probable starting
* pitcher (the one lineup slot the free schedule feed exposes); other sports
* fall back to the ESPN summary leaders when a game is found. Always returns an
* array (possibly empty).
*/
async function getLineup(sport, team, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
const t = norm(team);
if (!t) return [];
try {
if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const date = opts.date || todayISO(opts.now);
const games = await mlb.getScheduleWithPitchers(date);
const game = (games || []).find((g) => matchesTeam(g.home, t) || matchesTeam(g.away, t));
if (!game) return [];
const side = matchesTeam(game.home, t) ? game.home : game.away;
const out = [];
if (side?.probablePitcher?.name) {
out.push({ player: side.probablePitcher.name, position: 'SP', battingOrder: null, projectedMinutes: null });
}
return out;
}
} catch (err) {
console.warn('[depthChart] getLineup failed:', sport, team, err.message);
}
return [];
}
function matchesTeam(side, t) {
if (!side || !t) return false;
const name = norm(side.team || side.name);
if (!name) return false;
const lastWord = name.split(' ').pop();
return name === t || name.includes(t) || (lastWord.length >= 3 && t.includes(lastWord));
}
/**
* Depth chart for a team: positions with starter/backup/thirdString. When no
* roster source is wired, returns a valid empty structure (graceful default).
* Pass opts.roster ([{player, position, depth}]) to build a real chart.
*/
async function getDepthChart(sport, team, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
const t = norm(team);
const base = { sport: sp, team: t, positions: [] };
const roster = Array.isArray(opts.roster) ? opts.roster : null;
if (!roster) return base;
const byPos = {};
for (const r of roster) {
const pos = norm(r.position) || 'UTIL';
(byPos[pos] = byPos[pos] || []).push(r);
}
base.positions = Object.entries(byPos).map(([position, players]) => {
const sorted = players.slice().sort((a, b) => (a.depth || 99) - (b.depth || 99));
return {
position,
starter: sorted[0]?.player || null,
backup: sorted[1]?.player || null,
thirdString: sorted[2]?.player || null,
};
});
return base;
}
/**
* Cascade projection: what happens to teammates' production when `player` is
* OUT. Foundation heuristic — uses the design's archetype cascade weights
* (usage sponges benefit most). Returns [] when we can't establish that the
* player is actually out or have no teammates to project onto.
* opts.teammates: [{player, archetype, baseUsage}] — injected by the caller.
*/
async function getCascadeProjection(sport, player, team, opts = {}) {
const teammates = Array.isArray(opts.teammates) ? opts.teammates : [];
if (!player || teammates.length === 0) return [];
// Distribute a fixed usage pool across teammates, weighting usage sponges and
// high-usage creators heavier (mirrors the design's cascade framing).
const POOL = Number.isFinite(opts.usagePool) ? opts.usagePool : 12; // %
// VYNDR Originals (Session 44): SURGE (usage sponge) benefits most; primary
// creators (TORCH/DUAL THREAT/SWITCHBOARD) next; low-usage glue least.
const weightFor = (a) => {
const k = norm(a);
if (k === 'SURGE' || k.includes('USAGE SPONGE')) return 3;
if (['TORCH', 'DUAL THREAT', 'SWITCHBOARD', 'IGNITER'].includes(k) || k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2;
if (k === 'CONNECTOR' || k === 'LOCKDOWN' || k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5;
return 1;
};
const weights = teammates.map((m) => weightFor(m.archetype));
const total = weights.reduce((s, w) => s + w, 0) || 1;
return teammates.map((m, i) => {
const delta = +((POOL * weights[i]) / total).toFixed(1);
return {
player: m.player,
stat: 'usage',
delta: `+${delta}%`,
reason: `${player} OUT → ${m.player} absorbs touches`,
};
}).sort((a, b) => parseFloat(b.delta) - parseFloat(a.delta));
}
module.exports = {
getLineup,
getDepthChart,
getCascadeProjection,
_internals: { norm, matchesTeam, todayISO },
};