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