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
+7
View File
@@ -144,6 +144,13 @@ app.use('/api/widget', widgetRoutes);
// stat-filtered views over all of them.
const scheduleRoutes = require('./routes/schedule');
app.use('/api/schedule', scheduleRoutes);
// Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public,
// cache-only, never triggers a snapshot.
const tickerRoutes = require('./routes/ticker');
app.use('/api/ticker', tickerRoutes);
// Session 45 — pre-graded slate read (snapshot:{sport}:latest). Public, cache-only.
const snapshotReadRoutes = require('./routes/snapshot');
app.use('/api/snapshot', snapshotReadRoutes);
const gameLinesRoutes = require('./routes/gameLines');
app.use('/api/gamelines', gameLinesRoutes);
const streaksRoutes = require('./routes/streaks');
+34
View File
@@ -82,4 +82,38 @@ router.post('/prefetch/tank01', async (req, res) => {
}
});
/**
* POST /api/internal/snapshot/:sport (Session 45)
*
* Trigger one snapshot cycle for a sport (pre-grade the slate, lock grades,
* compute deltas, emit ticker events). Internal-only (requireInternalAuth at the
* router root). This is what the cron / n8n schedule calls — never public, so a
* bad actor can't drain the PropLine quota by spamming it.
*/
/** POST /api/internal/snapshot/all — every active sport, sequentially.
* Registered BEFORE /snapshot/:sport so "all" isn't captured as a sport. */
router.post('/snapshot/all', async (req, res) => {
const snapshot = require('../services/snapshotService');
try {
const results = await snapshot.runAllSnapshots();
return res.json({ ok: true, results });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/snapshot/all] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
router.post('/snapshot/:sport', async (req, res) => {
const snapshot = require('../services/snapshotService');
try {
const summary = await snapshot.runSnapshot(req.params.sport);
return res.json({ ok: true, summary });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/snapshot] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
module.exports = router;
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/**
* GET /api/snapshot/:sport (Session 45) — the latest pre-graded slate.
*
* Public, cache-only read of `snapshot:{sport}:latest` (enriched grades with
* archetype + gradedAt, plus line deltas). Falls back to the `grades:{sport}`
* envelope when no snapshot has run yet. NEVER triggers a snapshot (that's the
* internal cron's job) — so it can't drain the PropLine quota.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { cacheGet } = require('../utils/redis');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
try {
const snap = await cacheGet(`snapshot:${sport}:latest`);
if (snap && Array.isArray(snap.grades)) {
res.set('Cache-Control', 'public, max-age=30');
return res.json({ sport, updated_at: snap.updated_at, grades: snap.grades, deltas: snap.deltas || [] });
}
// Fallback: the grades envelope (no deltas yet).
const env = await cacheGet(`grades:${sport}`);
const grades = env && Array.isArray(env.grades) ? env.grades : [];
res.set('Cache-Control', 'public, max-age=30');
return res.json({ sport, updated_at: env && env.updated_at, grades, deltas: [] });
} catch (err) {
console.error('[snapshot]', err.message);
return res.status(200).json({ sport, grades: [], deltas: [] });
}
});
module.exports = router;
+48
View File
@@ -0,0 +1,48 @@
'use strict';
/**
* GET /api/ticker (Session 45) — the live ticker feed.
*
* Returns the latest snapshot-generated events from the `ticker:items` Redis
* list (newest first), merged with editorial pins from the TICKER_MANUAL env
* var (a JSON array of { tag, text, color }). Public + cached 30s. Reads cache
* only — never triggers a snapshot.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { cacheGet } = require('../utils/redis');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const LIMIT = 30;
function parseManual() {
const raw = process.env.TICKER_MANUAL;
if (!raw) return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr)
? arr.filter((x) => x && x.text).map((x) => ({ tag: x.tag || 'ALERT', text: String(x.text), color: x.color || 'var(--text-0)' }))
: [];
} catch {
return [];
}
}
router.get('/', async (req, res) => {
let items = [];
try {
const stored = await cacheGet('ticker:items');
if (Array.isArray(stored)) items = stored;
} catch {
items = [];
}
// Snapshot events are already newest-first; editorial pins follow.
const merged = [...items, ...parseManual()].slice(0, LIMIT);
res.set('Cache-Control', 'public, max-age=30');
res.json({ items: merged, count: merged.length });
});
module.exports = router;
+5
View File
@@ -7,6 +7,8 @@ const { getConfiguredProviders, listProviderIds } = require('./config/providers'
// Session 24 — warm the Tank01 cache after boot so streaks / hot lists /
// game lines have data on the first page load. Non-blocking; see module.
const { scheduleStartupPrefetch } = require('./startupPrefetch');
// Session 45 — in-process snapshot cron (gated on SNAPSHOT_CRON=1).
const { startSnapshotScheduler } = require('./snapshotScheduler');
// Default 3001 — Next.js owns 3000 locally and in production. The poller,
// internal cron, and BASE_URL conventions all assume 3001 for the Express
@@ -25,4 +27,7 @@ app.listen(PORT, () => {
// Session 24 — fire-and-forget cache warm. 5s delay so Redis is ready.
// Skips itself when RAPID_API_KEY is unset; never blocks or crashes boot.
scheduleStartupPrefetch();
// Session 45 — arm the snapshot cron (no-op unless SNAPSHOT_CRON=1).
startSnapshotScheduler();
});
+111
View File
@@ -0,0 +1,111 @@
'use strict';
/**
* espnStatsAdapter — best-effort NBA/WNBA season averages from ESPN (Session 45).
*
* The primary NBA/WNBA stats source (`nbaStatsClient`) depends on a Python
* nba_api service that is frequently offline in prod. This adapter is a FREE,
* no-auth fallback off ESPN's public site API. It is intentionally DEFENSIVE:
* any shape it doesn't recognize → null (the caller degrades to found:false),
* never a throw and never a wrong-but-confident number.
*
* Parsing is tolerant by design (ESPN's athlete-stats JSON varies by sport and
* season), so `parseAthleteStats` is a pure, unit-tested function.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search';
const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' };
const TTL = 6 * 3600;
const TIMEOUT = 10_000;
// ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped.
const STAT_MAP = {
pointspergame: 'ppg', avgpoints: 'ppg', points: 'ppg', ppg: 'ppg',
reboundspergame: 'rpg', avgrebounds: 'rpg', rebounds: 'rpg', rpg: 'rpg', totalrebounds: 'rpg',
assistspergame: 'apg', avgassists: 'apg', assists: 'apg', apg: 'apg',
blockspergame: 'bpg', avgblocks: 'bpg', blocks: 'bpg', bpg: 'bpg',
stealspergame: 'spg', avgsteals: 'spg', steals: 'spg', spg: 'spg',
threepointfieldgoalsmade: 'threes', threepointfieldgoalspergame: 'threes', avg3pointfieldgoalsmade: 'threes',
};
const keyify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
/**
* Walk an ESPN athlete-stats payload and pull out per-game averages we can
* classify. Returns a classifier-input object (possibly partial) or null when
* nothing usable is found.
*/
function parseAthleteStats(payload) {
if (!payload || typeof payload !== 'object') return null;
const out = {};
// ESPN nests stats under categories[].stats[] with { name|abbreviation, value|displayValue }.
const categories = payload?.statistics?.splits?.categories
|| payload?.splits?.categories
|| payload?.categories
|| [];
const visit = (statArr) => {
for (const st of statArr || []) {
const label = keyify(st.name || st.abbreviation || st.label);
const mapped = STAT_MAP[label];
if (!mapped) continue;
const val = Number(st.value != null ? st.value : st.displayValue);
if (Number.isFinite(val) && out[mapped] == null) out[mapped] = val;
}
};
for (const cat of categories) visit(cat.stats);
if (Array.isArray(payload.stats)) visit(payload.stats); // flat fallback
return Object.keys(out).length > 0 ? out : null;
}
async function fetchJson(url, http) {
const client = http || axios;
const res = await client.get(url, { timeout: TIMEOUT });
return res && res.data;
}
/**
* Resolve a player's NBA/WNBA season averages from ESPN. Returns
* { found, team, position, classifierInput } or { found:false }. Never throws.
* opts.http injectable for tests.
*/
async function getSeasonAverages(name, sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const path = SPORT_PATH[sp];
if (!path || !name) return { found: false };
const cacheKey = `espnstats:${sp}:${keyify(name)}`;
try {
const cached = await cacheGet(cacheKey);
if (cached) return cached;
} catch { /* ignore */ }
try {
// 1. Resolve the athlete id via ESPN search.
const search = await fetchJson(`${SEARCH}?query=${encodeURIComponent(name)}&limit=5&sport=${encodeURIComponent(path)}`, opts.http);
const items = (search && (search.items || search.results)) || [];
const athlete = items.find((it) => keyify(it.displayName || it.name) === keyify(name)) || items[0];
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
if (!id) return { found: false };
// 2. Fetch that athlete's stats overview.
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
const classifierInput = parseAthleteStats(stats);
if (!classifierInput) return { found: false };
const result = {
found: true,
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
position: (athlete.position && athlete.position.abbreviation) || '',
classifierInput,
};
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
return result;
} catch (err) {
console.warn('[espnStats] season averages failed:', name, sp, err.message);
return { found: false };
}
}
module.exports = { getSeasonAverages, parseAthleteStats, __internals: { STAT_MAP, keyify, SPORT_PATH } };
+18 -7
View File
@@ -124,15 +124,26 @@ async function resolvePlayerStats(name, sport, opts = {}) {
};
}
if (sp === 'nba' || sp === 'wnba') {
// NBA/WNBA stats come from the Python nba_api service (nbaStatsClient).
// It's frequently offline in prod (localhost service) — degrade quietly.
// PRIMARY: the Python nba_api service (nbaStatsClient) — often offline in
// prod. FALLBACK: ESPN's free public stats (espnStatsAdapter). Either way,
// a miss degrades quietly to found:false (no badge, never a crash).
const nba = opts.nbaClient || require('./nbaStatsClient');
const data = await nba.getSeasonAvg(name).catch(() => null);
if (!data || typeof data !== 'object') return { found: false };
const ppg = toNum(data.ppg ?? data.points);
if (!ppg) return { found: false };
let data = await nba.getSeasonAvg(name).catch(() => null);
if (!data || typeof data !== 'object' || !toNum(data.ppg ?? data.points)) {
const espn = opts.espnStats || require('./adapters/espnStatsAdapter');
const e = await espn.getSeasonAverages(name, sp).catch(() => ({ found: false }));
if (e && e.found && e.classifierInput) {
const ci = e.classifierInput;
const season = [
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
{ k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
];
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position }, season, last10: [], splits: [] };
}
return { found: false };
}
const classifierInput = {
ppg, rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
ppg: toNum(data.ppg ?? data.points), rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals),
threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position,
};
+250
View File
@@ -0,0 +1,250 @@
/**
* snapshotService — scheduled grade pipeline (Session 45).
*
* Orchestrates ONE snapshot cycle for one sport. The on-demand "Read" model is
* retired: a snapshot pre-grades the full slate, LOCKS each grade to the line at
* snapshot time (`gradedAt`), classifies each player's archetype, computes line
* deltas vs the previous snapshot, and emits ticker events.
*
* Everything is orchestration of EXISTING services (oddsService,
* gradeSlateService, archetypeService, playerIntelService). All I/O is injectable
* so the whole cycle is unit-testable with zero network.
*
* Redis keys written:
* snapshot:{sport}:latest — current locked snapshot { sport, updated_at, grades, deltas }
* snapshot:{sport}:previous — prior snapshot (for the next delta computation)
* grades:{sport} — { grades, updated_at, source } (GameCard / Explore / leaders)
* ticker:items — capped array of ticker events (newest first)
*/
const SNAP_TTL = 6 * 3600; // 6h — a snapshot is valid until the next run
const GRADES_TTL = 2 * 3600; // matches gradeSlateService
const TICKER_TTL = 24 * 3600;
const TICKER_CAP = 50;
const DELTA_NOISE = 0.5; // ignore movements smaller than this
const DELTA_MOVE = 1.0; // ticker MOVE threshold
const STATS_CONCURRENCY = 5;
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
const lastName = (full) => {
const parts = String(full || '').trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : (parts[0] || '');
};
const sideChar = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
const propKey = (g) => `${norm(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${String(g.direction || '').toLowerCase()}`;
async function mapLimit(items, concurrency, fn) {
const out = new Array(items.length);
let i = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker));
return out;
}
/** Index original odds props by propKey-ish (player|stat) for odds lookup. */
function indexOdds(props) {
const map = {};
for (const p of props || []) {
const k = `${norm(p.player)}|${String(p.stat_type || '').toLowerCase()}`;
map[k] = p;
}
return map;
}
function gradedAtFor(g, oddsByKey, ts) {
const k = `${norm(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}`;
const o = oddsByKey[k];
let odds = null;
if (o) {
odds = String(g.direction || '').toLowerCase() === 'under'
? (o.under_odds ?? o.under ?? o.odds ?? null)
: (o.over_odds ?? o.over ?? o.odds ?? null);
}
return { line: g.line, odds, timestamp: ts };
}
/**
* Compare current grades to the previous snapshot's locked lines. A delta is
* emitted only when |movement| >= DELTA_NOISE. `direction`:
* 'toward' = market moving in the direction of our graded side (confirming)
* 'away' = market moving against it.
* For an OVER, a rising line confirms (toward); for an UNDER, a falling line.
*/
function computeLineDeltas(current, previous) {
const prevMap = {};
for (const p of previous || []) prevMap[propKey(p)] = p;
const out = [];
for (const c of current || []) {
const prev = prevMap[propKey(c)];
if (!prev) continue;
const gradedLine = prev.gradedAt ? prev.gradedAt.line : prev.line;
const currentLine = c.line;
if (gradedLine == null || currentLine == null) continue;
const delta = +(Number(currentLine) - Number(gradedLine)).toFixed(2);
if (Math.abs(delta) < DELTA_NOISE) continue;
const side = String(c.direction || 'over').toLowerCase();
const toward = side === 'over' ? delta > 0 : delta < 0;
out.push({
player: c.player || c.player_name,
stat: c.stat_type || c.stat,
side: sideChar(side),
gradedLine: Number(gradedLine),
currentLine: Number(currentLine),
delta,
direction: toward ? 'toward' : 'away',
grade: c.grade,
});
}
return out;
}
const isTopGrade = (g) => g === 'A+' || g === 'A';
/**
* Build ticker events from a snapshot: a SCAN summary, GRADE events for the top
* grades, and MOVE events for significant deltas. Newest-relevant first.
*/
function generateTickerEvents(sport, grades, deltas, ts) {
const events = [];
events.push({
tag: 'SCAN', color: 'var(--g-a)', ts,
text: `${sport.toUpperCase()} slate scanned · ${grades.length} props graded`,
});
for (const g of grades.filter((x) => isTopGrade(x.grade)).slice(0, 6)) {
const arch = g.archetype ? `${g.archetype} ` : '';
events.push({
tag: g.grade, color: g.grade === 'A+' ? 'var(--g-ap)' : 'var(--g-a)', ts,
text: `${arch}${lastName(g.player || g.player_name)} ${g.stat_type || g.stat} ${sideChar(g.direction)}${g.line} graded ${g.grade}`,
});
}
for (const d of deltas.filter((x) => Math.abs(x.delta) >= DELTA_MOVE).slice(0, 6)) {
const arrow = d.delta > 0 ? '▲' : '▼';
const s = d.side === 'U' ? 'u' : 'o';
events.push({
tag: 'MOVE', color: 'var(--amber)', ts,
text: `${lastName(d.player)} ${s}${d.gradedLine}${s}${d.currentLine} ${arrow}${d.delta > 0 ? '+' : ''}${d.delta}`,
});
}
return events;
}
async function pushTickerItems(events, deps) {
if (!events || events.length === 0) return;
const existing = await deps.cacheGet('ticker:items');
const arr = Array.isArray(existing) ? existing : [];
const merged = [...events, ...arr].slice(0, TICKER_CAP);
await deps.cacheSet('ticker:items', merged, TICKER_TTL);
}
const ACTIVE_SPORTS = ['mlb', 'nba', 'wnba', 'soccer'];
/**
* Run one snapshot cycle for `sport`. Returns a summary; never throws.
* opts (all injectable): getOdds, gradeAndCacheSlate, resolveStats, classify,
* cacheGet, cacheSet, now, nowMs.
*/
async function runSnapshot(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const deps = {
getOdds: opts.getOdds || require('./oddsService').getOdds,
gradeAndCacheSlate: opts.gradeAndCacheSlate || require('./gradeSlateService').gradeAndCacheSlate,
resolveStats: opts.resolveStats || require('./playerIntelService').resolvePlayerStats,
classify: opts.classify || require('./archetypeService').classify,
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
now: opts.now || (() => new Date().toISOString()),
nowMs: opts.nowMs || (() => Date.now()),
};
const start = deps.nowMs();
const ts = deps.now();
let odds;
try {
odds = await deps.getOdds(sp);
} catch (e) {
return { sport: sp, status: 'error', reason: e.message, gradeCount: 0 };
}
const props = (odds && Array.isArray(odds.props)) ? odds.props : [];
if (props.length === 0) return { sport: sp, status: 'skipped', reason: 'no props', gradeCount: 0 };
// Grade the slate via the existing service; capture the envelope instead of
// letting it write (we re-write an ENRICHED version below).
let envelope = null;
await deps.gradeAndCacheSlate(sp, props, {
source: (odds && odds.provider) || 'odds-api',
now: deps.now,
cacheSet: async (_k, v) => { envelope = v; },
});
const graded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : [];
if (graded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 };
// Archetype per unique player (pure math once we have stats). Best-effort —
// a missing stat line → no badge (not a fallback archetype).
const oddsByKey = indexOdds(props);
const players = [...new Set(graded.map((g) => g.player || g.player_name).filter(Boolean))];
const archByPlayer = {};
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
try {
const stats = await deps.resolveStats(player, sp);
if (stats && stats.found) {
const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null;
}
} catch { /* graceful — no badge */ }
});
const enriched = graded.map((g) => ({
...g,
gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null,
}));
// Line deltas vs the previous snapshot's locked lines.
const prev = await deps.cacheGet(`snapshot:${sp}:latest`);
const deltas = computeLineDeltas(enriched, prev && prev.grades);
// Lock: previous = old latest, latest = new, grades = enriched.
if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL);
const snapshot = { sport: sp, updated_at: ts, grades: enriched, deltas, gradeCount: enriched.length };
await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL);
await deps.cacheSet(`grades:${sp}`, { grades: enriched, updated_at: ts, source: (odds && odds.provider) || 'odds-api' }, GRADES_TTL);
// Ticker exhaust.
const events = generateTickerEvents(sp, enriched, deltas, ts);
await pushTickerItems(events, deps);
return {
sport: sp,
status: 'ok',
gradeCount: enriched.length,
topGrades: enriched.filter((g) => isTopGrade(g.grade)).slice(0, 5).map((g) => ({
player: g.player || g.player_name, stat: g.stat_type || g.stat, grade: g.grade, archetype: g.archetype,
})),
deltas: deltas.length,
duration: deps.nowMs() - start,
};
}
/** Run snapshots for every active sport sequentially (cron entrypoint). */
async function runAllSnapshots(opts = {}) {
const results = [];
for (const sp of ACTIVE_SPORTS) {
results.push(await runSnapshot(sp, opts));
}
return results;
}
module.exports = {
runSnapshot,
runAllSnapshots,
computeLineDeltas,
generateTickerEvents,
pushTickerItems,
ACTIVE_SPORTS,
__internals: { propKey, gradedAtFor, indexOdds, lastName, isTopGrade, DELTA_NOISE, DELTA_MOVE, TICKER_CAP },
};
+49
View File
@@ -0,0 +1,49 @@
'use strict';
/**
* snapshotScheduler — in-process snapshot cron (Session 45).
*
* No new dependency: a 1-minute unref'd interval that fires `runAllSnapshots`
* at the configured UTC hours (default 14,19,22,1,3 = 10AM/3PM/6PM/9PM/11PM ET,
* matching the sports cycle — morning research, afternoon news, pre-game lock,
* in-game). Gated on SNAPSHOT_CRON=1 so it never runs in dev/test or on a
* container that shouldn't own the schedule. Prefer an EXTERNAL cron (n8n) hitting
* POST /api/internal/snapshot/all when running multiple API replicas — this
* in-process variant assumes a single scheduler instance.
*/
const HOURS_UTC = (process.env.SNAPSHOT_HOURS_UTC || '14,19,22,1,3')
.split(',')
.map((n) => parseInt(n, 10))
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
function startSnapshotScheduler(opts = {}) {
if (process.env.SNAPSHOT_CRON !== '1') return null;
const runAll = opts.runAllSnapshots || require('./services/snapshotService').runAllSnapshots;
const now = opts.now || (() => new Date());
let lastFiredSlot = null;
const tick = async () => {
const d = now();
if (d.getUTCMinutes() !== 0) return;
const h = d.getUTCHours();
if (!HOURS_UTC.includes(h)) return;
const slot = `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${h}`;
if (slot === lastFiredSlot) return; // fire once per slot
lastFiredSlot = slot;
try {
const results = await runAll();
const ok = results.filter((r) => r.status === 'ok');
console.log(`[snapshot] cron fired ${h}:00 UTC — ${ok.length}/${results.length} sports graded`);
} catch (e) {
console.warn('[snapshot] cron run failed:', e.message);
}
};
const interval = setInterval(tick, 60_000);
if (interval.unref) interval.unref();
console.log(`[snapshot] scheduler armed for UTC hours: ${HOURS_UTC.join(', ')}`);
return { interval, tick };
}
module.exports = { startSnapshotScheduler, HOURS_UTC };