Session 51: Complete Team Hub (2234 tests)
Research-depth team view: /team/[abbr] with roster, archetypes, stats, props.
- Team API: mlbStatsAdapter.getTeams/resolveTeam/getTeamRoster (statsapi, abbr→id
+ active roster, cached). teamService.getTeamHub assembles roster → per-player
season stats (bounded concurrency) + archetype (snapshot grade or classify) +
tonight's graded props from grades:{sport}; whole hub cached 15min. MLB real;
NBA/WNBA graceful snapshot roster. GET /api/team/:abbr (404 unknown) + proxy.
- Team Hub page: server page.tsx (generateMetadata) + TeamHub client — header,
sort (archetype/graded/A-Z), archetype filter chips, roster rows (archetype +
player link + position + stats + graded props + parlay "+"), "No active props"
greyed state, loading/error.
- Game cards: team abbreviations are now TeamLinks → /team/:abbr?sport= (green
hover, stops propagation). Team Hub has "← Back to Slate".
Backend 2215 -> 2234 tests (+19), 190 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -188,6 +188,42 @@ async function getPlayerStats(name, season = DEFAULT_SEASON) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All MLB teams (Session 51) → [{ id, abbr, name }]. Cached 24h. Used to map a
|
||||
* UI abbreviation ("NYY") to the statsapi team id.
|
||||
*/
|
||||
async function getTeams(season = DEFAULT_SEASON) {
|
||||
const url = `${BASE}/teams?sportId=1&season=${season}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:teams:${season}`, 24 * 3600);
|
||||
const teams = (data && Array.isArray(data.teams)) ? data.teams : [];
|
||||
return teams.map((t) => ({ id: t.id, abbr: t.abbreviation || null, name: t.name || null }));
|
||||
}
|
||||
|
||||
/** Resolve a team abbreviation → { id, abbr, name } or null. */
|
||||
async function resolveTeam(abbr, season = DEFAULT_SEASON) {
|
||||
const a = String(abbr || '').toUpperCase();
|
||||
if (!a) return null;
|
||||
const teams = await getTeams(season);
|
||||
return teams.find((t) => String(t.abbr).toUpperCase() === a) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
|
||||
* Cached 6h. [] on failure.
|
||||
*/
|
||||
async function getTeamRoster(teamId, season = DEFAULT_SEASON) {
|
||||
if (!teamId) return [];
|
||||
const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}`, 6 * 3600);
|
||||
const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
|
||||
return roster.map((r) => ({
|
||||
id: r.person?.id ?? null,
|
||||
name: r.person?.fullName ?? null,
|
||||
position: r.position?.abbreviation ?? null,
|
||||
jersey: r.jerseyNumber ?? null,
|
||||
})).filter((p) => p.id && p.name);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getScheduleWithPitchers,
|
||||
getPlayerGameLog,
|
||||
@@ -195,5 +231,8 @@ module.exports = {
|
||||
getBatterVsPitcher,
|
||||
searchPlayer,
|
||||
getPlayerStats,
|
||||
getTeams,
|
||||
resolveTeam,
|
||||
getTeamRoster,
|
||||
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'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 } = 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 = 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: 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 } };
|
||||
Reference in New Issue
Block a user