Session 45: Snapshot pipeline + GameCard swap + live ticker (2100 tests)

The on-demand "Read" grade model is RETIRED. A scheduled pipeline pre-grades the
slate, locks grades to the line, tracks movement; the dashboard shows them already
there. Orchestrates existing services — nothing rebuilt.

- snapshotService.runSnapshot(sport): getOdds → gradeAndCacheSlate → classify
  archetype per player → lock gradedAt → line deltas vs previous snapshot → write
  snapshot:{sport}:latest/previous + grades:{sport} → ticker events. Fully
  injectable, zero-network unit tests. runAllSnapshots = cron entrypoint.
- Internal trigger POST /api/internal/snapshot/:sport + /all (requireInternalAuth).
  In-process cron (SNAPSHOT_CRON=1, UTC 14,19,22,1,3) in server.js, no new dep.
- Public reads: GET /api/snapshot/:sport (cache-only) + GET /api/ticker (merges
  TICKER_MANUAL pins) + Next proxies.
- GameCard swap: live Slate renders vyndr/GameCard (legacy kept for types only),
  overlays locked grades onto game props → player name once + archetype badge +
  "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0". Ungraded → "Awaiting next
  scan", NO Read button. On-demand onGrade flow deleted.
- Ticker polls /api/ticker every 30s, graceful fallback to hardcoded items.
- NBA/WNBA: espnStatsAdapter free fallback (defensive parse → found:false on shape
  mismatch) wired into resolvePlayerStats after the offline Python service.

Env: PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, TICKER_MANUAL.
Backend 2061 -> 2100 tests (+39), 173 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 21:34:29 -04:00
parent 7969a4971a
commit f8b120c0aa
24 changed files with 1425 additions and 129 deletions
+98
View File
@@ -192,6 +192,99 @@ function isRelevantGame(game, now = Date.now()) {
return (now - t) / 3_600_000 < 24;
}
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
const snorm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
const gradeKey = (player, stat) => `${snorm(player)}|${String(stat || '').toLowerCase()}`;
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
/** Index snapshot grades by player|stat → the locked grade record. */
function indexGrades(grades) {
const map = {};
for (const g of grades || []) {
map[gradeKey(g.player || g.player_name, g.stat_type || g.stat)] = g;
}
return map;
}
/** Index line deltas by player|stat|side → delta record. */
function indexDeltas(deltas) {
const map = {};
for (const d of deltas || []) {
map[`${gradeKey(d.player, d.stat)}|${d.side}`] = d;
}
return map;
}
const STAT_SHORT = {
total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs',
strikeouts: 'Ks', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP',
stolen_bases: 'SB', points: 'Pts', rebounds: 'Reb', assists: 'Ast', threes: '3PT',
steals: 'Stl', blocks: 'Blk', pra: 'PRA', turnovers: 'TO',
};
function statShort(stat) {
if (!stat) return '';
return STAT_SHORT[stat] || String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Relative "Graded Xh ago" from an ISO timestamp. */
function gradedAgo(iso, now = Date.now()) {
const t = iso ? new Date(iso).getTime() : NaN;
if (Number.isNaN(t)) return '';
const mins = Math.max(0, Math.round((now - t) / 60000));
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.round(hrs / 24)}d ago`;
}
/**
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
* locked grades onto the game's odds-derived props (which already carry the
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
* or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read
* button). Archetype comes from the snapshot's per-player classification.
*/
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now()) {
const byPlayer = {};
const order = [];
for (const p of gameProps || []) {
if (!p || !p.player) continue;
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
if (!byPlayer[p.player]) {
byPlayer[p.player] = {
player: p.player,
team: p.team || '',
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
stats: [],
props: [],
};
order.push(p.player);
} else if (!byPlayer[p.player].archetype && rec && rec.archetype) {
byPlayer[p.player].archetype = { primary: rec.archetype };
}
if (rec) {
const side = sideCh(rec.direction);
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
byPlayer[p.player].props.push({
stat: statShort(rec.stat_type || rec.stat),
line: rec.line,
side,
grade: rec.grade,
gradedAt: rec.gradedAt
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
: null,
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
});
} else {
byPlayer[p.player].props.push({
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
});
}
}
return order.map((name) => byPlayer[name]);
}
module.exports = {
parseAmericanOdds,
detectBestLines,
@@ -201,4 +294,9 @@ module.exports = {
groupPropsByPlayer,
mapPitchers,
isRelevantGame,
indexGrades,
indexDeltas,
statShort,
gradedAgo,
buildPlayerStripsFromProps,
};