Files
vyndr/src/services/liveTrackingService.js
T
builtbykev b5d3fd14bb S11 (a1): live tracking — the read locked, the game watched
MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:45:29 -04:00

369 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* liveTrackingService — LIVE TRACKING (A1 board, Session 11).
*
* The read is locked pre-game; the game is watched. This service produces the
* per-player CURRENT box-line values for in-progress games so the slate can
* render live progress marks — proto-outcomes in the OUTCOME slot region
* (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6). GRADES NEVER
* CHANGE IN-GAME.
*
* Sources (both FREE, zero out-of-pocket):
* MLB — statsapi.mlb.com: schedule?hydrate=linescore identifies Live games
* AND carries inning progress in ONE call; then
* /api/v1/game/{gamePk}/boxscore per live game.
* WNBA — ESPN scoreboard (state 'in' + period) identifies live events; then
* the ESPN summary?event= boxscore per live event.
*
* POLLING RULE / quota math: getLiveTracking caches `live:{sport}:{date}` for
* LIVE_TTL (90s). The cost during live windows is 1 schedule call + N boxscore
* calls per 90s ACROSS ALL USERS (shared cache); when nothing is live the
* cached { hasLive: false } envelope means one schedule check per 90s while
* anyone polls, and ZERO boxscore calls. The frontend only polls while live
* games are on screen.
*
* DATA SEMANTICS: only real box-line values. A player who hasn't appeared has
* EMPTY stats objects in the MLB boxscore (or didNotPlay / an empty stats row
* on ESPN) → he is ABSENT from the output, never 0.
*
* Everything is injectable (fetchJson, cacheGet, cacheSet) → parsers and the
* whole refresh cycle are unit-tested on REAL captured feed shapes with zero
* network (fixtures captured live 2026-07-11).
*/
const { nameKey } = require('../utils/playerName');
const LIVE_TTL = 90; // seconds — the shared polling window
const HTTP_TIMEOUT_MS = 10_000;
const MLB_BASE = 'https://statsapi.mlb.com/api/v1';
const ESPN_WNBA_BASE = 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba';
// ---------------------------------------------------------------------------
// MLB stat map — VYNDR stat_type → { group, field } in a LIVE boxscore player.
//
// This is a LOCAL map on purpose (same rule as outcomeService.MLB_LOG_FIELD):
// settlement reads the post-game GAME LOG, where statsapi has already picked
// ONE stat group for the row by position. The LIVE boxscore instead carries
// BOTH `stats.batting` and `stats.pitching` per player, so each stat must name
// its group explicitly — and innings_pitched must be parsed in thirds
// ('5.2' = 5⅔ ≈ 5.667) for pace math, which parseFloat gets wrong. Coupling
// the two maps would let a settlement-side change silently break live pace
// (or vice versa). If you add an MLB stat_type, wire it HERE for tracking and
// in outcomeService.MLB_LOG_FIELD for settlement.
// ---------------------------------------------------------------------------
const LIVE_BOX_FIELD = {
hits: { group: 'batting', field: 'hits' },
total_bases: { group: 'batting', field: 'totalBases' },
home_runs: { group: 'batting', field: 'homeRuns' },
rbi: { group: 'batting', field: 'rbi' },
runs: { group: 'batting', field: 'runs' },
stolen_bases: { group: 'batting', field: 'stolenBases' },
doubles: { group: 'batting', field: 'doubles' },
triples: { group: 'batting', field: 'triples' },
walks: { group: 'batting', field: 'baseOnBalls' },
// Pitcher props. `strikeouts` is the PITCHER prop in the VYNDR vocabulary
// (pitcher_strikeouts market) — resolved from stats.pitching.
strikeouts: { group: 'pitching', field: 'strikeOuts' },
earned_runs: { group: 'pitching', field: 'earnedRuns' },
hits_allowed: { group: 'pitching', field: 'hits' },
outs: { group: 'pitching', field: 'outs' },
innings_pitched: { group: 'pitching', field: 'inningsPitched', parse: ipToDecimal },
};
/** MLB innings string in thirds → decimal ('5.2' = 5 + 2/3). Null-strict. */
function ipToDecimal(v) {
if (v == null || v === '') return null;
const [whole, partial] = String(v).split('.');
const w = Number(whole);
if (!Number.isFinite(w)) return null;
const p = Number(partial) || 0;
return w + p / 3;
}
/** Strict numeric read — null when absent/unparseable, NEVER a fabricated 0. */
function numOrNull(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
// ---------------------------------------------------------------------------
// MLB parsers (pure)
// ---------------------------------------------------------------------------
/**
* Pure: statsapi schedule JSON (hydrate=linescore) → live games only:
* [{ gamePk, home, away, progress }]. progress = { label, fraction, inning,
* half, scheduledInnings }. Games without a parseable linescore still list
* (progress null) — the boxscore values are real either way.
*/
function parseMlbLiveSchedule(scheduleJson) {
const games = ((scheduleJson || {}).dates || []).flatMap((d) => d.games || []);
return games
.filter((g) => g && g.status && g.status.abstractGameState === 'Live')
.map((g) => ({
gamePk: g.gamePk ?? null,
home: g.teams?.home?.team?.name ?? null,
away: g.teams?.away?.team?.name ?? null,
progress: mlbProgress(g.linescore),
}));
}
/**
* Pure: an MLB linescore → { label, fraction, inning, half, scheduledInnings }.
* Fraction = (inning (top ? 1 : 0.5)) / scheduled — mid-inning estimate for
* pace math (Bottom 8th of 9 → 7.5/9 ≈ 0.833). Label uses ▲ (top) / ▼ (bottom):
* '▲6th' / '▼8th'. Null when the feed carries no inning yet.
*/
function mlbProgress(linescore) {
const ls = linescore || {};
const inning = numOrNull(ls.currentInning);
if (inning == null) return null;
const scheduled = numOrNull(ls.scheduledInnings) || 9;
// inningState: Top | Middle | Bottom | End. Middle/End sit between halves —
// count them as the completed half (top done → same fraction as bottom start).
const state = String(ls.inningState || (ls.isTopInning ? 'Top' : 'Bottom')).toLowerCase();
const top = state === 'top';
const fraction = Math.min(1, Math.max(0, (inning - (top ? 1 : 0.5)) / scheduled));
const ord = ordinal(inning);
return {
label: `${top ? '▲' : '▼'}${ord}`,
fraction,
inning,
half: top ? 'top' : 'bottom',
scheduledInnings: scheduled,
};
}
function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
}
/**
* Pure: an MLB live boxscore → { [nameKey]: { name, team, values } } where
* `values` maps VYNDR stat_type → current number for every wired stat the
* player has REAL box data for. A player with empty batting AND pitching
* objects has not appeared → OMITTED entirely (absent, never 0).
*/
function parseMlbBoxscore(boxJson) {
const out = {};
const teams = (boxJson || {}).teams || {};
for (const side of ['home', 'away']) {
const t = teams[side] || {};
const teamName = t.team?.name ?? null;
const players = t.players || {};
for (const key of Object.keys(players)) {
const p = players[key];
const name = p?.person?.fullName;
if (!name) continue;
const batting = p.stats?.batting;
const pitching = p.stats?.pitching;
const hasBatting = batting && Object.keys(batting).length > 0;
const hasPitching = pitching && Object.keys(pitching).length > 0;
if (!hasBatting && !hasPitching) continue; // not in the game yet — absent
const values = {};
for (const [statType, m] of Object.entries(LIVE_BOX_FIELD)) {
const groupObj = m.group === 'batting' ? (hasBatting ? batting : null) : (hasPitching ? pitching : null);
if (!groupObj) continue;
const raw = groupObj[m.field];
const val = m.parse ? m.parse(raw) : numOrNull(raw);
if (val != null) values[statType] = val;
}
if (Object.keys(values).length === 0) continue;
out[nameKey(name)] = { name, team: teamName, values };
}
}
return out;
}
// ---------------------------------------------------------------------------
// WNBA parsers (pure) — ESPN site API
// ---------------------------------------------------------------------------
// VYNDR stat_type → the ESPN boxscore `keys` entry. `threes` is the made count
// parsed from the "M-A" pair; `pra` is computed from components (never read).
const WNBA_BOX_KEY = {
points: 'points',
rebounds: 'rebounds',
assists: 'assists',
steals: 'steals',
blocks: 'blocks',
turnovers: 'turnovers',
threes: 'threePointFieldGoalsMade-threePointFieldGoalsAttempted',
};
/**
* Pure: ESPN scoreboard JSON → live events only:
* [{ id, home, away, progress }]. progress fraction = (period 0.5) / 4,
* clamped (OT caps at 1) — mid-period estimate, label 'Q{n}' / 'OT'.
*/
function parseWnbaLiveScoreboard(scoreboardJson) {
const events = (scoreboardJson || {}).events || [];
return events
.filter((e) => e?.status?.type?.state === 'in')
.map((e) => {
const comp = e.competitions?.[0] || {};
const competitors = comp.competitors || [];
const home = competitors.find((c) => c.homeAway === 'home');
const away = competitors.find((c) => c.homeAway === 'away');
const period = numOrNull(e.status?.period);
const progress = period == null ? null : {
label: period > 4 ? (period === 5 ? 'OT' : `${period - 4}OT`) : `Q${period}`,
fraction: Math.min(1, Math.max(0, (period - 0.5) / 4)),
period,
};
return {
id: String(e.id),
home: home?.team?.displayName ?? null,
away: away?.team?.displayName ?? null,
progress,
};
});
}
/**
* Pure: an ESPN summary boxscore → { [nameKey]: { name, team, values } }.
* ESPN ships per-team `statistics[0]` with a parallel `keys` array and
* per-athlete `stats` string arrays. didNotPlay or an empty stats row →
* the player has not appeared → OMITTED (absent, never 0). `pra` is computed
* only when points, rebounds AND assists are all present.
*/
function parseWnbaBoxscore(summaryJson) {
const out = {};
const teams = (summaryJson || {}).boxscore?.players || [];
for (const t of teams) {
const teamName = t.team?.displayName ?? t.team?.abbreviation ?? null;
const block = (t.statistics || [])[0];
if (!block || !Array.isArray(block.keys) || !Array.isArray(block.athletes)) continue;
const idx = {};
block.keys.forEach((k, i) => { idx[k] = i; });
for (const a of block.athletes) {
const name = a?.athlete?.displayName;
if (!name) continue;
const row = a.stats;
if (a.didNotPlay || !Array.isArray(row) || row.length === 0) continue; // absent
const values = {};
for (const [statType, key] of Object.entries(WNBA_BOX_KEY)) {
const i = idx[key];
if (i == null || row[i] == null) continue;
const raw = String(row[i]);
const val = statType === 'threes' ? numOrNull(raw.split('-')[0]) : numOrNull(raw);
if (val != null) values[statType] = val;
}
if (values.points != null && values.rebounds != null && values.assists != null) {
values.pra = values.points + values.rebounds + values.assists;
}
if (Object.keys(values).length === 0) continue;
out[nameKey(name)] = { name, team: teamName, values };
}
}
return out;
}
// ---------------------------------------------------------------------------
// Fetch + cache layer
// ---------------------------------------------------------------------------
async function defaultFetchJson(url) {
const axios = require('axios');
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
return res && res.data ? res.data : null;
}
function resolveDeps(opts = {}) {
return {
fetchJson: opts.fetchJson || defaultFetchJson,
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
};
}
/** Today in ET (sports days roll over on ET, not UTC). */
function todayET() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
/**
* Build the fresh live envelope for one sport (network path — called only on
* a cache miss). One schedule/scoreboard call; boxscore calls ONLY for games
* the schedule marks live. Per-game failures degrade to an empty players map
* for that game — the rest of the slate still tracks.
*/
async function fetchLiveTracking(sport, date, deps) {
const sp = String(sport || '').toLowerCase();
if (sp === 'mlb') {
const sched = await deps.fetchJson(`${MLB_BASE}/schedule?sportId=1&date=${date}&hydrate=linescore`);
const live = parseMlbLiveSchedule(sched);
const games = [];
for (const g of live) {
let players = {};
try {
const box = await deps.fetchJson(`${MLB_BASE}/game/${g.gamePk}/boxscore`);
players = parseMlbBoxscore(box);
} catch (e) {
console.warn('[liveTracking] mlb boxscore failed:', g.gamePk, e.message);
}
games.push({ id: String(g.gamePk), home: g.home, away: g.away, progress: g.progress, players });
}
return { sport: sp, date, hasLive: games.length > 0, games };
}
if (sp === 'wnba') {
const board = await deps.fetchJson(`${ESPN_WNBA_BASE}/scoreboard`);
const live = parseWnbaLiveScoreboard(board);
const games = [];
for (const g of live) {
let players = {};
try {
const summary = await deps.fetchJson(`${ESPN_WNBA_BASE}/summary?event=${encodeURIComponent(g.id)}`);
players = parseWnbaBoxscore(summary);
} catch (e) {
console.warn('[liveTracking] wnba summary failed:', g.id, e.message);
}
games.push({ id: g.id, home: g.home, away: g.away, progress: g.progress, players });
}
return { sport: sp, date, hasLive: games.length > 0, games };
}
// Other sports: no free live box feed wired — honest empty, never a guess.
return { sport: sp, date, hasLive: false, games: [] };
}
/**
* Cache-aside live read (the route's entrypoint). `live:{sport}:{date}` TTL
* 90s — the shared polling window. A no-live result is ALSO cached for 90s so
* idle polling costs one schedule check per window, zero boxscore calls.
* Never throws: any failure returns an empty envelope.
*/
async function getLiveTracking(sport, opts = {}) {
const deps = resolveDeps(opts);
const sp = String(sport || '').toLowerCase();
const date = opts.date || todayET();
const key = `live:${sp}:${date}`;
try {
const cached = await deps.cacheGet(key);
if (cached !== null) return cached;
const fresh = await fetchLiveTracking(sp, date, deps);
fresh.updated_at = new Date().toISOString();
await deps.cacheSet(key, fresh, LIVE_TTL);
return fresh;
} catch (err) {
console.warn(`[liveTracking] ${sp} failed:`, err.message);
return { sport: sp, date, hasLive: false, games: [], error: 'unavailable' };
}
}
module.exports = {
getLiveTracking,
fetchLiveTracking,
parseMlbLiveSchedule,
parseMlbBoxscore,
parseWnbaLiveScoreboard,
parseWnbaBoxscore,
mlbProgress,
__internals: { LIVE_BOX_FIELD, WNBA_BOX_KEY, ipToDecimal, numOrNull, ordinal, LIVE_TTL, todayET, MLB_BASE, ESPN_WNBA_BASE },
};