'use strict'; /** * teamService — the Team Hub payload (Session 51). * * Assembles, in one cached call, everything `/team/:abbr` needs: team meta + * a roster where each player carries their archetype, season stats, and tonight's * graded props. MLB is the real-data path (statsapi.mlb.com roster + season * stats); NBA/WNBA degrade to a roster built from the players graded tonight. * * Sources (all existing): mlbStatsAdapter (roster/season), archetypeService * (classify), the grades:{sport} snapshot cache (props + locked archetype). * Everything is injectable so the whole build is unit-testable with no network. */ const { nameKey, normalizeName } = require('../utils/playerName'); const HUB_TTL = 15 * 60; // expensive to build; 15-min cache const ROSTER_CONCURRENCY = 8; async function mapLimit(items, concurrency, fn) { const out = new Array(items.length); let i = 0; async function worker() { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } } await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker)); return out; } const sideChar = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O'); const statLabel = (s) => String(s || '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); /** Index the grades cache by normalized player → { archetype, props[] }. */ function indexGradesByPlayer(grades) { const map = {}; for (const g of grades || []) { const k = nameKey(g.player || g.player_name); if (!k) continue; if (!map[k]) map[k] = { archetype: g.archetype || null, props: [] }; if (!map[k].archetype && g.archetype) map[k].archetype = g.archetype; map[k].props.push({ stat: statLabel(g.stat_type || g.stat), line: g.line, side: sideChar(g.direction), grade: g.grade, gradedAt: g.gradedAt || null, }); } return map; } /** * Build the Team Hub for a sport + abbreviation. Returns the payload, or null * for an unknown MLB team (→ 404). Never throws. * opts: { mlbAdapter, classify, cacheGet, cacheSet, skipCache, * mapMlbHitter, mapMlbPitcher, mlbSeasonRows } */ async function getTeamHub(sport, abbr, opts = {}) { const sp = String(sport || 'mlb').toLowerCase(); const team = String(abbr || '').toUpperCase(); const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet; const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet; const classify = opts.classify || require('./archetypeService').classify; const cacheKey = `teamhub:${sp}:${team}`; if (!opts.skipCache) { try { const c = await cacheGet(cacheKey); if (c) return c; } catch { /* ignore */ } } let gradeIndex = {}; try { const env = await cacheGet(`grades:${sp}`); gradeIndex = indexGradesByPlayer(env && env.grades); } catch { gradeIndex = {}; } // ── NBA/WNBA/soccer: graceful fallback from tonight's graded players ── // No free roster feed; build a partial roster from the snapshot grades. if (sp !== 'mlb') { const env = await cacheGet(`grades:${sp}`).catch(() => null); const byPlayer = {}; for (const g of (env && env.grades) || []) { const disp = normalizeName(g.player || g.player_name).display || g.player || g.player_name; const k = nameKey(disp); if (!byPlayer[k]) byPlayer[k] = { player: disp, archetype: g.archetype ? { primary: g.archetype } : null, position: null, stats: [], props: [], propCount: 0 }; byPlayer[k].props.push({ stat: statLabel(g.stat_type || g.stat), line: g.line, side: sideChar(g.direction), grade: g.grade, gradedAt: g.gradedAt || null }); } const list = Object.values(byPlayer).map((p) => ({ ...p, propCount: p.props.length })); const hub = { team: { name: team, abbr: team, sport: sp }, roster: list, rosterSource: 'snapshot', note: list.length ? null : 'Roster unavailable — view individual players from the slate.' }; try { await cacheSet(cacheKey, hub, HUB_TTL); } catch { /* ignore */ } return hub; } // ── MLB: full real roster ── const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); const intel = require('./playerIntelService')._internals; const mapMlbHitter = opts.mapMlbHitter || intel.mapMlbHitter; const mapMlbPitcher = opts.mapMlbPitcher || intel.mapMlbPitcher; const mlbSeasonRows = opts.mlbSeasonRows || intel.mlbSeasonRows; const meta = await mlb.resolveTeam(team); if (!meta) return null; // unknown team → 404 const roster = await mlb.getTeamRoster(meta.id); const players = await mapLimit(roster, ROSTER_CONCURRENCY, async (p) => { const group = p.position === 'P' ? 'pitching' : 'hitting'; let classifierInput = {}; let stats = []; try { const season = await mlb.getSeasonAverages(p.id, undefined, group); if (season) { classifierInput = group === 'pitching' ? mapMlbPitcher(season) : mapMlbHitter(season); stats = mlbSeasonRows(season, group); } } catch { /* best-effort */ } const graded = gradeIndex[nameKey(p.name)]; let archetype = graded && graded.archetype ? { primary: graded.archetype } : null; if (!archetype && Object.keys(classifierInput).length) { const c = classify('mlb', classifierInput); archetype = c.primary ? { primary: c.primary.name } : null; } return { player: normalizeName(p.name).display || p.name, position: p.position, jersey: p.jersey, archetype, stats, props: graded ? graded.props : [], propCount: graded ? graded.props.length : 0, }; }); const hub = { team: { name: meta.name, abbr: meta.abbr, sport: 'mlb' }, roster: players, rosterSource: 'mlb-stats' }; try { await cacheSet(cacheKey, hub, HUB_TTL); } catch { /* ignore */ } return hub; } module.exports = { getTeamHub, indexGradesByPlayer, __internals: { mapLimit, statLabel } };