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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user