Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user