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:
@@ -0,0 +1,73 @@
|
||||
// Session 45 — ESPN NBA/WNBA stats fallback. parseAthleteStats is pure; the
|
||||
// fetch path is exercised with an injected http client.
|
||||
|
||||
const espn = require('../../src/services/adapters/espnStatsAdapter');
|
||||
const svc = require('../../src/services/playerIntelService');
|
||||
|
||||
describe('parseAthleteStats (defensive)', () => {
|
||||
it('pulls per-game averages from the ESPN categories shape', () => {
|
||||
const payload = {
|
||||
statistics: { splits: { categories: [
|
||||
{ stats: [
|
||||
{ name: 'avgPoints', value: 28.1 },
|
||||
{ name: 'avgRebounds', value: 8.2 },
|
||||
{ name: 'avgAssists', value: 6.4 },
|
||||
] },
|
||||
] } },
|
||||
};
|
||||
const ci = espn.parseAthleteStats(payload);
|
||||
expect(ci.ppg).toBe(28.1);
|
||||
expect(ci.rpg).toBe(8.2);
|
||||
expect(ci.apg).toBe(6.4);
|
||||
});
|
||||
|
||||
it('returns null for an unrecognized / empty shape (graceful)', () => {
|
||||
expect(espn.parseAthleteStats(null)).toBeNull();
|
||||
expect(espn.parseAthleteStats({ nonsense: true })).toBeNull();
|
||||
expect(espn.parseAthleteStats({ statistics: { splits: { categories: [{ stats: [{ name: 'foo', value: 1 }] }] } } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSeasonAverages (injected http)', () => {
|
||||
it('resolves an athlete and parses stats', async () => {
|
||||
const http = {
|
||||
get: async (url) => {
|
||||
if (url.includes('/search')) return { data: { items: [{ id: 123, displayName: 'Luka Doncic', team: { abbreviation: 'DAL' }, position: { abbreviation: 'G' } }] } };
|
||||
return { data: { statistics: { splits: { categories: [{ stats: [{ name: 'avgPoints', value: 33 }, { name: 'avgAssists', value: 9 }] }] } } } };
|
||||
},
|
||||
};
|
||||
const r = await espn.getSeasonAverages('Luka Doncic', 'nba', { http });
|
||||
expect(r.found).toBe(true);
|
||||
expect(r.team).toBe('DAL');
|
||||
expect(r.classifierInput.ppg).toBe(33);
|
||||
});
|
||||
|
||||
it('degrades to found:false when ESPN errors', async () => {
|
||||
const http = { get: async () => { throw new Error('espn down'); } };
|
||||
expect((await espn.getSeasonAverages('X', 'nba', { http })).found).toBe(false);
|
||||
});
|
||||
|
||||
it('returns found:false for non-basketball sports', async () => {
|
||||
expect((await espn.getSeasonAverages('X', 'mlb')).found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePlayerStats wires the ESPN fallback for NBA', () => {
|
||||
it('falls back to ESPN when nbaStatsClient is offline → classifies', async () => {
|
||||
const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', {
|
||||
nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } },
|
||||
espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 } }) },
|
||||
});
|
||||
expect(r.found).toBe(true);
|
||||
expect(r.team).toBe('DAL');
|
||||
expect(r.classifierInput.ppg).toBe(33);
|
||||
});
|
||||
|
||||
it('found:false when both sources are empty', async () => {
|
||||
const r = await svc.resolvePlayerStats('Nobody', 'nba', {
|
||||
nbaClient: { getSeasonAvg: async () => null },
|
||||
espnStats: { getSeasonAverages: async () => ({ found: false }) },
|
||||
});
|
||||
expect(r.found).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Session 45 — Phase 3: pre-graded snapshot overlay + the GameCard swap.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const a = require('../../web/src/lib/slateAdapter');
|
||||
|
||||
describe('snapshot overlay adapter', () => {
|
||||
const grades = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', archetype: 'BOMBER', gradedAt: { line: 1.5, odds: -115, timestamp: '2026-06-18T18:00:00Z' } },
|
||||
{ player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', archetype: 'BOMBER', gradedAt: { line: 0.5, odds: 120, timestamp: '2026-06-18T18:00:00Z' } },
|
||||
];
|
||||
const deltas = [{ player: 'Aaron Judge', stat: 'total_bases', side: 'O', delta: 1.0, direction: 'toward', currentLine: 2.5 }];
|
||||
|
||||
it('indexes grades + deltas for lookup', () => {
|
||||
const gi = a.indexGrades(grades);
|
||||
expect(gi['aaronjudge|total_bases'].grade).toBe('A+');
|
||||
const di = a.indexDeltas(deltas);
|
||||
expect(di['aaronjudge|total_bases|O'].direction).toBe('toward');
|
||||
});
|
||||
|
||||
it('overlays grades onto game props → playerStrips (name once, archetype, gradedAt, delta)', () => {
|
||||
const gameProps = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5 },
|
||||
{ player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5 },
|
||||
];
|
||||
const strips = a.buildPlayerStripsFromProps(gameProps, a.indexGrades(grades), a.indexDeltas(deltas), new Date('2026-06-18T20:00:00Z').getTime());
|
||||
expect(strips).toHaveLength(1); // name once
|
||||
expect(strips[0].archetype).toEqual({ primary: 'BOMBER' });
|
||||
const tb = strips[0].props.find((p) => p.stat === 'TB');
|
||||
expect(tb.grade).toBe('A+');
|
||||
expect(tb.gradedAt.ago).toBe('2h ago');
|
||||
expect(tb.gradedAt.odds).toBe(-115);
|
||||
expect(tb.delta).toEqual({ delta: 1.0, direction: 'toward', currentLine: 2.5 });
|
||||
});
|
||||
|
||||
it('marks ungraded props as awaiting (no Read button)', () => {
|
||||
const strips = a.buildPlayerStripsFromProps(
|
||||
[{ player: 'New Guy', stat_type: 'hits', line: 1.5 }],
|
||||
a.indexGrades(grades), a.indexDeltas(deltas),
|
||||
);
|
||||
expect(strips[0].props[0].awaiting).toBe(true);
|
||||
expect(strips[0].props[0].grade).toBeNull();
|
||||
});
|
||||
|
||||
it('gradedAgo formats relative time', () => {
|
||||
const now = new Date('2026-06-18T20:00:00Z').getTime();
|
||||
expect(a.gradedAgo('2026-06-18T19:30:00Z', now)).toBe('30m ago');
|
||||
expect(a.gradedAgo('2026-06-18T18:00:00Z', now)).toBe('2h ago');
|
||||
expect(a.gradedAgo(undefined, now)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slate uses the VYNDR card + pre-graded snapshot (swap is real)', () => {
|
||||
const src = read('components/Slate.tsx');
|
||||
it('imports the VYNDR GameCard component (legacy only for types)', () => {
|
||||
expect(src).toContain("import VyndrGameCard");
|
||||
expect(src).toContain("'@/components/vyndr/GameCard'");
|
||||
expect(src).toContain('<VyndrGameCard');
|
||||
// legacy component default-import is gone (only `import type` remains)
|
||||
expect(src).not.toMatch(/^import GameCard /m);
|
||||
});
|
||||
it('fetches the pre-graded snapshot and builds playerStrips', () => {
|
||||
expect(src).toContain('/api/snapshot/');
|
||||
expect(src).toContain('buildPlayerStripsFromProps');
|
||||
expect(src).toContain('slateGameToCardData');
|
||||
});
|
||||
it('retired the on-demand Read grade flow', () => {
|
||||
expect(src).not.toContain('const onGrade = useCallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatStrip renders snapshot states', () => {
|
||||
const src = read('components/vyndr/StatStrip.tsx');
|
||||
it('renders Awaiting next scan + line-delta sub-line', () => {
|
||||
expect(src).toContain('Awaiting next scan');
|
||||
expect(src).toContain('TOWARD');
|
||||
expect(src).toContain('Graded ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Session 45 — in-process snapshot scheduler (gated, fires once per slot).
|
||||
|
||||
const { startSnapshotScheduler, HOURS_UTC } = require('../../src/snapshotScheduler');
|
||||
|
||||
afterEach(() => { delete process.env.SNAPSHOT_CRON; });
|
||||
|
||||
describe('startSnapshotScheduler', () => {
|
||||
it('no-ops unless SNAPSHOT_CRON=1', () => {
|
||||
delete process.env.SNAPSHOT_CRON;
|
||||
expect(startSnapshotScheduler()).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to the sports-cycle UTC hours', () => {
|
||||
expect(HOURS_UTC).toEqual([14, 19, 22, 1, 3]);
|
||||
});
|
||||
|
||||
it('fires runAllSnapshots at a scheduled hour, once per slot', async () => {
|
||||
process.env.SNAPSHOT_CRON = '1';
|
||||
const runAllSnapshots = jest.fn(async () => [{ sport: 'mlb', status: 'ok' }]);
|
||||
let d = new Date(Date.UTC(2026, 5, 18, 14, 0, 0)); // 14:00 UTC — scheduled
|
||||
const sched = startSnapshotScheduler({ runAllSnapshots, now: () => d });
|
||||
await sched.tick();
|
||||
await sched.tick(); // same slot — must NOT double-fire
|
||||
expect(runAllSnapshots).toHaveBeenCalledTimes(1);
|
||||
if (sched.interval && sched.interval.unref) clearInterval(sched.interval);
|
||||
});
|
||||
|
||||
it('does not fire off-schedule (wrong hour / non-zero minute)', async () => {
|
||||
process.env.SNAPSHOT_CRON = '1';
|
||||
const runAllSnapshots = jest.fn();
|
||||
const sched = startSnapshotScheduler({ runAllSnapshots, now: () => new Date(Date.UTC(2026, 5, 18, 15, 0, 0)) });
|
||||
await sched.tick();
|
||||
const sched2 = startSnapshotScheduler({ runAllSnapshots, now: () => new Date(Date.UTC(2026, 5, 18, 14, 30, 0)) });
|
||||
await sched2.tick();
|
||||
expect(runAllSnapshots).not.toHaveBeenCalled();
|
||||
[sched, sched2].forEach((s) => s.interval && clearInterval(s.interval));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// Session 45 — snapshot pipeline. Every dependency is injected, so the whole
|
||||
// cycle runs with zero network / Redis.
|
||||
|
||||
const svc = require('../../src/services/snapshotService');
|
||||
|
||||
// A tiny in-memory cache to back cacheGet/cacheSet.
|
||||
function memCache() {
|
||||
const store = {};
|
||||
return {
|
||||
store,
|
||||
cacheGet: async (k) => (k in store ? store[k] : null),
|
||||
cacheSet: async (k, v) => { store[k] = v; },
|
||||
};
|
||||
}
|
||||
|
||||
const sampleProps = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'dk' },
|
||||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: -130, under_odds: 100, book: 'dk' },
|
||||
];
|
||||
|
||||
// Fake grader output captured via gradeAndCacheSlate's cacheSet.
|
||||
function fakeGradeAndCacheSlate(grades) {
|
||||
return async (_sport, _props, opts) => {
|
||||
await opts.cacheSet(`grades:x`, { grades, updated_at: opts.now(), source: 'test' });
|
||||
return { written: true, count: grades.length };
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeLineDeltas', () => {
|
||||
it('detects movements >= 0.5 and ignores noise', () => {
|
||||
const prev = [{ player: 'A', stat_type: 'pts', direction: 'over', gradedAt: { line: 26.5 } }];
|
||||
const cur = [
|
||||
{ player: 'A', stat_type: 'pts', direction: 'over', line: 27.5, grade: 'A' }, // +1.0
|
||||
{ player: 'B', stat_type: 'reb', direction: 'over', line: 9.5, grade: 'B' }, // no prev
|
||||
];
|
||||
const d = svc.computeLineDeltas(cur, prev);
|
||||
expect(d).toHaveLength(1);
|
||||
expect(d[0].delta).toBe(1);
|
||||
expect(d[0].gradedLine).toBe(26.5);
|
||||
expect(d[0].currentLine).toBe(27.5);
|
||||
});
|
||||
|
||||
it('marks direction toward/away by graded side', () => {
|
||||
const prev = [
|
||||
{ player: 'A', stat_type: 'pts', direction: 'over', gradedAt: { line: 26.5 } },
|
||||
{ player: 'B', stat_type: 'pts', direction: 'under', gradedAt: { line: 20.5 } },
|
||||
];
|
||||
const cur = [
|
||||
{ player: 'A', stat_type: 'pts', direction: 'over', line: 28, grade: 'A' }, // over + rising = toward
|
||||
{ player: 'B', stat_type: 'pts', direction: 'under', line: 22, grade: 'B' }, // under + rising = away
|
||||
];
|
||||
const d = svc.computeLineDeltas(cur, prev);
|
||||
expect(d.find((x) => x.player === 'A').direction).toBe('toward');
|
||||
expect(d.find((x) => x.player === 'B').direction).toBe('away');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTickerEvents', () => {
|
||||
const grades = [
|
||||
{ player: 'Aaron Judge', stat_type: 'TB', line: 1.5, direction: 'over', grade: 'A+', archetype: 'BOMBER' },
|
||||
{ player: 'Mookie Betts', stat_type: 'Hits', line: 1.5, direction: 'over', grade: 'B' },
|
||||
];
|
||||
it('emits a SCAN summary + GRADE events for A/A+ only', () => {
|
||||
const ev = svc.generateTickerEvents('mlb', grades, [], '2026-06-18T20:00:00Z');
|
||||
expect(ev[0].tag).toBe('SCAN');
|
||||
expect(ev[0].text).toContain('2 props graded');
|
||||
const grade = ev.find((e) => e.tag === 'A+');
|
||||
expect(grade.text).toContain('BOMBER');
|
||||
expect(grade.text).toContain('Judge');
|
||||
expect(ev.find((e) => e.tag === 'B')).toBeUndefined(); // only top grades
|
||||
});
|
||||
it('emits MOVE events for |delta| >= 1.0', () => {
|
||||
const deltas = [{ player: 'V Wemby', stat: 'pts', side: 'O', gradedLine: 26.5, currentLine: 27.5, delta: 1.0, direction: 'toward' }];
|
||||
const ev = svc.generateTickerEvents('nba', grades, deltas, 't');
|
||||
const move = ev.find((e) => e.tag === 'MOVE');
|
||||
expect(move.text).toContain('▲+1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runSnapshot (fully injected)', () => {
|
||||
const baseGrades = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', confidence: 80 },
|
||||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 65 },
|
||||
];
|
||||
const deps = (cache) => ({
|
||||
getOdds: async () => ({ sport: 'mlb', props: sampleProps, provider: 'propline' }),
|
||||
gradeAndCacheSlate: fakeGradeAndCacheSlate(baseGrades),
|
||||
resolveStats: async (player) => (player === 'Aaron Judge'
|
||||
? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 } }
|
||||
: { found: false }),
|
||||
classify: require('../../src/services/archetypeService').classify,
|
||||
cacheGet: cache.cacheGet,
|
||||
cacheSet: cache.cacheSet,
|
||||
now: () => '2026-06-18T20:00:00Z',
|
||||
nowMs: () => 1000,
|
||||
});
|
||||
|
||||
it('returns an ok summary with gradeCount + topGrades', async () => {
|
||||
const cache = memCache();
|
||||
const r = await svc.runSnapshot('mlb', deps(cache));
|
||||
expect(r.status).toBe('ok');
|
||||
expect(r.gradeCount).toBe(2);
|
||||
expect(r.topGrades[0].player).toBe('Aaron Judge');
|
||||
expect(r.topGrades[0].archetype).toBe('BOMBER'); // classified from real stats
|
||||
});
|
||||
|
||||
it('writes snapshot:{sport}:latest + grades:{sport} with gradedAt + archetype', async () => {
|
||||
const cache = memCache();
|
||||
await svc.runSnapshot('mlb', deps(cache));
|
||||
const snap = cache.store['snapshot:mlb:latest'];
|
||||
expect(snap).toBeTruthy();
|
||||
expect(snap.grades[0].gradedAt.line).toBe(1.5);
|
||||
expect(snap.grades[0].gradedAt.timestamp).toBe('2026-06-18T20:00:00Z');
|
||||
expect(snap.grades[0].archetype).toBe('BOMBER');
|
||||
expect(cache.store['grades:mlb'].grades).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rotates latest → previous and computes deltas on the second run', async () => {
|
||||
const cache = memCache();
|
||||
let line = 1.5;
|
||||
const d = deps(cache);
|
||||
d.gradeAndCacheSlate = (sport, props, opts) => fakeGradeAndCacheSlate([
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line, direction: 'over', grade: 'A+', confidence: 80 },
|
||||
])(sport, props, opts);
|
||||
await svc.runSnapshot('mlb', d);
|
||||
line = 2.5; // line moved +1.0
|
||||
const r2 = await svc.runSnapshot('mlb', d);
|
||||
expect(cache.store['snapshot:mlb:previous']).toBeTruthy();
|
||||
expect(r2.deltas).toBe(1);
|
||||
});
|
||||
|
||||
it('pushes ticker events (capped) into ticker:items', async () => {
|
||||
const cache = memCache();
|
||||
await svc.runSnapshot('mlb', deps(cache));
|
||||
const items = cache.store['ticker:items'];
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
expect(items.find((e) => e.tag === 'SCAN')).toBeTruthy();
|
||||
expect(items.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('skips gracefully on an empty slate', async () => {
|
||||
const cache = memCache();
|
||||
const d = deps(cache);
|
||||
d.getOdds = async () => ({ sport: 'mlb', props: [] });
|
||||
const r = await svc.runSnapshot('mlb', d);
|
||||
expect(r.status).toBe('skipped');
|
||||
expect(r.gradeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Session 45 — Phase 4: the ticker polls /api/ticker (snapshot exhaust).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('Ticker live wiring', () => {
|
||||
const src = read('components/vyndr/Ticker.tsx');
|
||||
it('fetches from /api/ticker (not purely hardcoded)', () => {
|
||||
expect(src).toContain("fetch('/api/ticker'");
|
||||
expect(src).toContain('setInterval(load');
|
||||
});
|
||||
it('polls every 30s by default', () => {
|
||||
expect(src).toContain('pollMs = 30_000');
|
||||
});
|
||||
it('falls back to the passed items + keeps current on failure (never blanks)', () => {
|
||||
expect(src).toContain('feed && feed.length > 0 ? feed : items');
|
||||
expect(src).toContain('keep the current items on failure');
|
||||
});
|
||||
it('colors event tags (A+/MOVE/SCAN/ALERT)', () => {
|
||||
expect(src).toContain("MOVE: 'var(--amber)'");
|
||||
expect(src).toContain("ALERT: 'var(--text-0)'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticker Next proxy', () => {
|
||||
it('exists and forwards to the backend', () => {
|
||||
const proxy = read('app/api/ticker/route.ts');
|
||||
expect(proxy).toContain('/api/ticker');
|
||||
expect(proxy).toContain('BACKEND_URL');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user