Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+57
View File
@@ -0,0 +1,57 @@
{
"_meta": {
"purpose": "Seed coach profiles. Loaded into coach_profiles on first cold-start. Update when a coaching change happens, at season start, and at the trade deadline.",
"fields": "coach_name, team, sport, career_avg_pace, current_team_pace, tenure_games, primary_player, system_style, without_primary_style, without_primary_pace_delta",
"kev_action": "Populate with researched values per coach. The structure below has the canonical starters as placeholders so the cold-path doesn't crash on empty tables."
},
"coaches": [
{
"coach_name": "Tom Thibodeau",
"team": "NYK",
"sport": "nba",
"career_avg_pace": 96.5,
"current_team_pace": 98.1,
"tenure_games": 320,
"primary_player": "Jalen Brunson",
"system_style": "half_court_iso",
"without_primary_style": "motion",
"without_primary_pace_delta": 1.5
},
{
"coach_name": "Joe Mazzulla",
"team": "BOS",
"sport": "nba",
"career_avg_pace": 99.5,
"current_team_pace": 100.1,
"tenure_games": 180,
"primary_player": "Jayson Tatum",
"system_style": "motion",
"without_primary_style": "transition",
"without_primary_pace_delta": 2.0
},
{
"coach_name": "Erik Spoelstra",
"team": "MIA",
"sport": "nba",
"career_avg_pace": 95.8,
"current_team_pace": 96.0,
"tenure_games": 1200,
"primary_player": "Jimmy Butler",
"system_style": "half_court_iso",
"without_primary_style": "motion",
"without_primary_pace_delta": 1.2
},
{
"coach_name": "Sandy Brondello",
"team": "NYL",
"sport": "wnba",
"career_avg_pace": 80.5,
"current_team_pace": 81.2,
"tenure_games": 110,
"primary_player": "Breanna Stewart",
"system_style": "motion",
"without_primary_style": "motion",
"without_primary_pace_delta": 0.5
}
]
}
+249
View File
@@ -0,0 +1,249 @@
/**
* Sport configuration — two layers in one file.
*
* SPORTS (legacy, UI-facing)
* `active`, `collectData`, `comingSoon` — drives the landing-page badges
* and the "is grading on for this sport" gate. Consumed by:
* - src/services/UnifiedOddsProvider.js (shouldCollect)
* - src/routes/pipeline.js (isActiveSport)
* Mirror in `web/src/config/sports.ts`.
*
* SPORT_CONFIG (pipeline / resolution / poller)
* Per-sport ESPN endpoints + stat parsers. The resolution route, the
* ESPN poller, and the odds adapters all read from here. Active across
* all 7 graded sports — when a sport's UI flag is off but pipeline is
* on, we collect data without surfacing grades.
*
* Game hours are stored as ET (Eastern Time). Pollers convert from UTC
* with Intl.DateTimeFormat — see `getETHour()` in poller/poller.js.
*
* Stat parsing supports FOUR formats; calculateStat in the resolution
* route picks based on which key is present:
* 1. idx + parse → flat stats[] (NBA / WNBA / NCAAB)
* 2. mlbField → MLB Stats API native field name
* 3. category + field → NFL / NCAAFB category-based athletes
* 4. calc / mlbCalc → combo stat computed from components
* 5. field → NHL named-field box score
*
* All parse functions MUST be defensive — undefined/null/empty input must
* return 0 rather than NaN, otherwise resolution divides by NaN and bricks
* an entire sport's batch.
*/
// ---------------------------------------------------------------
// Legacy UI config (unchanged) — keep stable for existing consumers.
// ---------------------------------------------------------------
const SPORTS = Object.freeze({
nba: { key: 'nba', label: 'NBA', color: '#E94B3C', active: true, collectData: true },
wnba: { key: 'wnba', label: 'WNBA', color: '#F7944A', active: true, collectData: true },
mlb: { key: 'mlb', label: 'MLB', color: '#1E90FF', active: true, collectData: true },
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
});
const ALL = Object.values(SPORTS);
const ACTIVE = ALL.filter((s) => s.active);
const COLLECTING = ALL.filter((s) => s.collectData);
const isActiveSport = (key) => !!SPORTS[String(key || '').toLowerCase()]?.active;
const shouldCollect = (key) => !!SPORTS[String(key || '').toLowerCase()]?.collectData;
// ---------------------------------------------------------------
// Stat parsers — defensive helpers.
// ---------------------------------------------------------------
const numOrZero = (v) => {
if (v === undefined || v === null || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
// "3-7" → 3 (makes-attempts string format used in NBA threes/FG/FT)
const splitMakes = (v) => {
if (!v) return 0;
const left = String(v).split('-')[0];
const n = Number(left);
return Number.isFinite(n) ? n : 0;
};
// "5.1" innings pitched → 5.333... (decimal innings)
const inningsToDecimal = (v) => {
if (!v) return 0;
const s = String(v);
const [whole, partial] = s.split('.');
const w = Number(whole) || 0;
const p = Number(partial) || 0;
return w + p / 3;
};
// ---------------------------------------------------------------
// SPORT_CONFIG — pipeline / resolution layer.
// ---------------------------------------------------------------
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
// NBA / WNBA / NCAAB share the same basketball stat layout — index-based
// athletes[].stats array. We define the map once and reuse it; the espn
// URLs differ.
const BASKETBALL_STAT_MAP = {
// idx values follow the order ESPN returns in the basketball box-score
// statistics[0].labels array: ['MIN','FG','3PT','FT','OREB','DREB','REB','AST','STL','BLK','TO','PF','+/-','PTS']
// …but historically points was at idx 1 and rebounds at 5 — keep the
// tested indices verified against tests/fixtures live samples.
minutes: { idx: 0, parse: numOrZero },
points: { idx: 1, parse: numOrZero },
field_goals: { idx: 2, parse: splitMakes },
threes_made: { idx: 3, parse: splitMakes },
free_throws: { idx: 4, parse: splitMakes },
rebounds: { idx: 5, parse: numOrZero },
assists: { idx: 6, parse: numOrZero },
turnovers: { idx: 7, parse: numOrZero },
steals: { idx: 8, parse: numOrZero },
blocks: { idx: 9, parse: numOrZero },
pts_reb_ast: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[5]) + numOrZero(s?.[6]) },
pts_reb: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[5]) },
pts_ast: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[6]) },
reb_ast: { calc: (s) => numOrZero(s?.[5]) + numOrZero(s?.[6]) },
stl_blk: { calc: (s) => numOrZero(s?.[8]) + numOrZero(s?.[9]) },
};
const MLB_STAT_MAP = {
totalBases: { mlbField: 'totalBases' },
strikeOuts: { mlbField: 'strikeOuts' },
hits: { mlbField: 'hits' },
homeRuns: { mlbField: 'homeRuns' },
rbi: { mlbField: 'rbi' },
stolenBases: { mlbField: 'stolenBases' },
earnedRuns: { mlbField: 'earnedRuns' },
inningsPitched: { mlbField: 'inningsPitched', parse: inningsToDecimal },
runs: { mlbField: 'runs' },
baseOnBalls: { mlbField: 'baseOnBalls' },
hits_runs_rbi: { mlbCalc: (s) => numOrZero(s?.hits) + numOrZero(s?.runs) + numOrZero(s?.rbi) },
};
const FOOTBALL_STAT_MAP = {
passing_yards: { category: 'passing', field: 'passingYards' },
passing_tds: { category: 'passing', field: 'passingTouchdowns' },
interceptions: { category: 'passing', field: 'interceptions' },
completions: { category: 'passing', field: 'completions' },
rushing_yards: { category: 'rushing', field: 'rushingYards' },
rushing_tds: { category: 'rushing', field: 'rushingTouchdowns' },
receiving_yards: { category: 'receiving', field: 'receivingYards' },
receptions: { category: 'receiving', field: 'receptions' },
receiving_tds: { category: 'receiving', field: 'receivingTouchdowns' },
};
const NHL_STAT_MAP = {
goals: { field: 'goals' },
assists: { field: 'assists' },
shots: { field: 'shots' },
saves: { field: 'saves' },
points: { calc: (s) => numOrZero(s?.goals) + numOrZero(s?.assists) },
};
const SPORT_CONFIG = Object.freeze({
nba: Object.freeze({
key: 'nba',
label: 'NBA',
active: true,
espnScoreboard: `${ESPN_BASE}/basketball/nba/scoreboard`,
espnSummary: `${ESPN_BASE}/basketball/nba/summary`,
gameStartHourET: 18,
gameEndHourET: 24,
statMap: BASKETBALL_STAT_MAP,
}),
wnba: Object.freeze({
key: 'wnba',
label: 'WNBA',
active: true,
espnScoreboard: `${ESPN_BASE}/basketball/wnba/scoreboard`,
espnSummary: `${ESPN_BASE}/basketball/wnba/summary`,
gameStartHourET: 18,
gameEndHourET: 24,
statMap: BASKETBALL_STAT_MAP,
}),
ncaab: Object.freeze({
key: 'ncaab',
label: 'NCAAB',
active: true,
espnScoreboard: `${ESPN_BASE}/basketball/mens-college-basketball/scoreboard`,
espnSummary: `${ESPN_BASE}/basketball/mens-college-basketball/summary`,
gameStartHourET: 18,
gameEndHourET: 25, // late games on the west coast often roll past midnight
statMap: BASKETBALL_STAT_MAP,
}),
mlb: Object.freeze({
key: 'mlb',
label: 'MLB',
active: true,
espnScoreboard: `${ESPN_BASE}/baseball/mlb/scoreboard`,
espnSummary: `${ESPN_BASE}/baseball/mlb/summary`,
// MLB resolution reads from the MLB Stats API (richer + more reliable
// than ESPN's MLB box scores), but tip-off detection still rides on
// ESPN's scoreboard.
useMlbStatsApi: true,
mlbStatsApiBase: 'https://statsapi.mlb.com/api/v1.1',
gameStartHourET: 13,
gameEndHourET: 25,
statMap: MLB_STAT_MAP,
}),
nfl: Object.freeze({
key: 'nfl',
label: 'NFL',
active: true,
espnScoreboard: `${ESPN_BASE}/football/nfl/scoreboard`,
espnSummary: `${ESPN_BASE}/football/nfl/summary`,
gameStartHourET: 13,
gameEndHourET: 24,
statMap: FOOTBALL_STAT_MAP,
}),
ncaafb: Object.freeze({
key: 'ncaafb',
label: 'NCAA Football',
active: true,
espnScoreboard: `${ESPN_BASE}/football/college-football/scoreboard`,
espnSummary: `${ESPN_BASE}/football/college-football/summary`,
gameStartHourET: 12,
gameEndHourET: 25,
statMap: FOOTBALL_STAT_MAP,
}),
nhl: Object.freeze({
key: 'nhl',
label: 'NHL',
active: true,
espnScoreboard: `${ESPN_BASE}/hockey/nhl/scoreboard`,
espnSummary: `${ESPN_BASE}/hockey/nhl/summary`,
gameStartHourET: 19,
gameEndHourET: 24,
statMap: NHL_STAT_MAP,
}),
});
function getActiveSports() {
return Object.values(SPORT_CONFIG).filter((s) => s.active);
}
function getSportConfig(sport) {
const cfg = SPORT_CONFIG[String(sport || '').toLowerCase()];
if (!cfg) throw new Error(`Unknown sport: ${sport}`);
return cfg;
}
module.exports = {
// Legacy UI surface
SPORTS,
ALL,
ACTIVE,
COLLECTING,
isActiveSport,
shouldCollect,
// Pipeline / resolution surface
SPORT_CONFIG,
getActiveSports,
getSportConfig,
};