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
+59 -3
View File
@@ -4,9 +4,65 @@
2026-06-18 2026-06-18
## Current Phase ## Current Phase
SHIP BUILD v44.0 — Make it visible: VYNDR Original archetype names, grade-card SHIP BUILD v45.0 — Snapshot pipeline + GameCard swap + live ticker. The product
intel wiring, stale-game filtering, landing copy, depth proxies. Frontend + shifted: on-demand "Read" grading is RETIRED; a scheduled pipeline pre-grades the
wiring only (no new backend services). slate, locks grades to the line, and the dashboard shows them already there.
## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE
The on-demand grade model is retired. Backend 2061 → **2100 tests** (+39), 173
suites. Web build clean (exit 0).
### Phase 1 — snapshotService (orchestration of existing services)
`src/services/snapshotService.js` `runSnapshot(sport)`: getOdds (PropLine
rotation) → gradeAndCacheSlate (captured via injected cacheSet) → classify each
player's archetype (resolvePlayerStats + archetypeService, pure math) → attach
`gradedAt {line, odds, timestamp}` (LOCKED) → compute line deltas vs the previous
snapshot → write `snapshot:{sport}:latest|previous` + `grades:{sport}` → emit
ticker events. `runAllSnapshots()` loops mlb/nba/wnba/soccer. Everything
injectable → fully unit-tested with zero network. Deltas: `toward` = market
confirming our side, `away` = opposing; noise filtered <0.5.
### Phase 2 — internal API + cron + ticker API
- `POST /api/internal/snapshot/:sport` + `/snapshot/all` (existing
`requireInternalAuth`; `/all` registered first so it isn't captured as a sport).
- `GET /api/ticker` (public, cache-only, merges `TICKER_MANUAL` pins) + Next proxy.
- In-process cron `src/snapshotScheduler.js` (gated `SNAPSHOT_CRON=1`, UTC hours
14,19,22,1,3 = 10AM/3PM/6PM/9PM/11PM ET), armed in server.js. No new dep —
1-min unref'd interval, fires once per slot. Prefer external n8n cron hitting
the internal endpoint for multi-replica deploys.
### Phase 3 — GameCard swap (THE product shift)
The live Slate now renders `vyndr/GameCard` (legacy kept for TYPES only). Reads
`GET /api/snapshot/:sport` (cache-only, public; falls back to `grades:{sport}`),
overlays the locked grades onto each game's odds-derived props via
`slateAdapter.buildPlayerStripsFromProps` → player name ONCE + archetype badge +
locked grade + "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0" line delta.
Ungraded props → "Awaiting next scan" (NO Read button). The on-demand onGrade
flow was deleted.
### Phase 4 — live ticker
`vyndr/Ticker` now polls `/api/ticker` every 30s (graceful: keeps the passed
hardcoded items as initial/fallback, never blanks). Tag colors: A+/A/SCAN green,
MOVE/CASCADE amber, ALERT white.
### Phase 5 — NBA/WNBA ESPN fallback
`espnStatsAdapter.getSeasonAverages(name, sport)` — free public ESPN stats,
DEFENSIVE (`parseAthleteStats` returns null on any unrecognized shape → caller
degrades to found:false; never a wrong-but-confident number). Wired as the
NBA/WNBA fallback in `resolvePlayerStats` after the offline Python service. Note:
the live ESPN athlete-stats shape may need tuning against production — the parser
is tolerant and tested against a representative payload.
### Env vars (set in Coolify)
- `PROPLINE_API_KEY_1/2/3` — PropLine props rotation (snapshot odds source).
- `VYNDR_INTERNAL_KEY` — the internal-auth secret (header `x-internal-key`) for
the snapshot trigger. (This is the existing key, NOT a new INTERNAL_AUTH_TOKEN.)
- `SNAPSHOT_CRON=1` — arm the in-process scheduler (omit if using n8n).
- `TICKER_MANUAL` — JSON array of editorial pins, e.g.
`[{"tag":"ALERT","text":"VYNDR 2.0 is live."}]`.
## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE
## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE ## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE
+37
View File
@@ -413,6 +413,43 @@ Built from the Claude Design "VYNDR Player Intelligence" bundle.
vyndr/GameCard (playerStrips/pitchers) is built for that and swaps in then. vyndr/GameCard (playerStrips/pitchers) is built for that and swaps in then.
Don't swap it before the grades cache is populated (cards would be blank). Don't swap it before the grades cache is populated (cards would be blank).
## Snapshot Pipeline (Session 45 — the product model)
The on-demand "Read" grade flow is RETIRED. Grades are produced by a scheduled
snapshot, locked to the line, and read from cache.
- **`snapshotService.runSnapshot(sport)`** orchestrates EXISTING services (don't
rebuild): getOdds → gradeAndCacheSlate (captured via an injected cacheSet, so
we re-write an ENRICHED envelope) → classify archetype per player
(resolvePlayerStats + archetypeService) → attach `gradedAt {line,odds,timestamp}`
→ `computeLineDeltas` vs previous → write `snapshot:{sport}:latest|previous` +
`grades:{sport}` → `generateTickerEvents` → `ticker:items`. ALL deps injectable
→ unit-tested with zero network. `runAllSnapshots()` is the cron entrypoint.
- **Redis keys:** `snapshot:{sport}:latest` (current locked snapshot:
{grades, deltas}), `:previous` (for the next delta), `grades:{sport}` (enriched,
read by GameCard/Explore/leaders), `ticker:items` (capped 50 array).
- **Trigger is INTERNAL-ONLY:** `POST /api/internal/snapshot/:sport|/all` behind
`requireInternalAuth` (header `x-internal-key` == `VYNDR_INTERNAL_KEY`). `/all`
is registered BEFORE `/:sport` or Express captures "all" as a sport.
- **Cron:** `src/snapshotScheduler.js`, gated `SNAPSHOT_CRON=1`, UTC hours
14,19,22,1,3, armed in server.js (NOT app.js — app.js is imported by tests).
No new dependency. For multi-replica, use an external n8n cron hitting the
internal endpoint instead.
- **Read path:** `GET /api/snapshot/:sport` (public, cache-only, never triggers a
snapshot → can't drain PropLine). `GET /api/ticker` (public, merges
`TICKER_MANUAL` env pins).
- **GameCard swap:** the live Slate renders `vyndr/GameCard` (legacy GameCard kept
for TYPES only — `import type`). It overlays snapshot grades onto each game's
odds-derived props via `slateAdapter.buildPlayerStripsFromProps` (player name
once + archetype + locked grade + line-delta sub-line). Ungraded → "Awaiting
next scan", NO Read button. `StatStrip` renders the gradedAt/delta sub-line when
a prop carries `gradedAt`/`delta`/`awaiting` (snapshot mode), else the inline chip.
- **Ticker** (`vyndr/Ticker`) polls `/api/ticker` every 30s; the passed items are
the initial + graceful fallback (never blanks on fetch failure).
- **NBA/WNBA stats:** `espnStatsAdapter` is the FREE fallback when the Python
nba_api service is offline. `parseAthleteStats` is DEFENSIVE (null on any shape
it doesn't recognize → found:false). Its live ESPN shape may need prod tuning.
- **Env:** PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1,
SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array).
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+7
View File
@@ -144,6 +144,13 @@ app.use('/api/widget', widgetRoutes);
// stat-filtered views over all of them. // stat-filtered views over all of them.
const scheduleRoutes = require('./routes/schedule'); const scheduleRoutes = require('./routes/schedule');
app.use('/api/schedule', scheduleRoutes); 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'); const gameLinesRoutes = require('./routes/gameLines');
app.use('/api/gamelines', gameLinesRoutes); app.use('/api/gamelines', gameLinesRoutes);
const streaksRoutes = require('./routes/streaks'); 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; 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 / // 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. // game lines have data on the first page load. Non-blocking; see module.
const { scheduleStartupPrefetch } = require('./startupPrefetch'); 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, // Default 3001 — Next.js owns 3000 locally and in production. The poller,
// internal cron, and BASE_URL conventions all assume 3001 for the Express // 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. // 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. // Skips itself when RAPID_API_KEY is unset; never blocks or crashes boot.
scheduleStartupPrefetch(); 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') { if (sp === 'nba' || sp === 'wnba') {
// NBA/WNBA stats come from the Python nba_api service (nbaStatsClient). // PRIMARY: the Python nba_api service (nbaStatsClient) — often offline in
// It's frequently offline in prod (localhost service) — degrade quietly. // 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 nba = opts.nbaClient || require('./nbaStatsClient');
const data = await nba.getSeasonAvg(name).catch(() => null); let data = await nba.getSeasonAvg(name).catch(() => null);
if (!data || typeof data !== 'object') return { found: false }; if (!data || typeof data !== 'object' || !toNum(data.ppg ?? data.points)) {
const ppg = toNum(data.ppg ?? data.points); const espn = opts.espnStats || require('./adapters/espnStatsAdapter');
if (!ppg) return { found: false }; 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 = { 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), 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, 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 };
+96
View File
@@ -0,0 +1,96 @@
// Session 45 — internal snapshot endpoints (auth-gated) + the public ticker.
const express = require('express');
const request = require('supertest');
jest.mock('../../scripts/tank01-prefetch', () => ({ main: jest.fn() }));
jest.mock('../../src/services/quotaTracker', () => ({ getAllQuotaStatuses: jest.fn() }));
jest.mock('../../src/services/snapshotService', () => ({
runSnapshot: jest.fn(async (sport) => ({ sport, status: 'ok', gradeCount: 3 })),
runAllSnapshots: jest.fn(async () => [{ sport: 'mlb', status: 'ok' }, { sport: 'nba', status: 'skipped' }]),
}));
const snapshot = require('../../src/services/snapshotService');
beforeEach(() => {
jest.clearAllMocks();
process.env.VYNDR_INTERNAL_KEY = 'test-key-123';
});
function mountInternal() {
delete require.cache[require.resolve('../../src/routes/internal')];
const internalRoutes = require('../../src/routes/internal');
const app = express();
app.use(express.json());
app.use('/api/internal', internalRoutes);
return app;
}
describe('POST /api/internal/snapshot/:sport', () => {
it('rejects without the internal key (401)', async () => {
const res = await request(mountInternal()).post('/api/internal/snapshot/mlb').send({});
expect(res.status).toBe(401);
expect(snapshot.runSnapshot).not.toHaveBeenCalled();
});
it('runs a single-sport snapshot with the key', async () => {
const res = await request(mountInternal())
.post('/api/internal/snapshot/mlb')
.set('x-internal-key', 'test-key-123')
.send({});
expect(res.status).toBe(200);
expect(snapshot.runSnapshot).toHaveBeenCalledWith('mlb');
expect(res.body.summary.gradeCount).toBe(3);
});
it('routes /snapshot/all to runAllSnapshots (not captured as a sport)', async () => {
const res = await request(mountInternal())
.post('/api/internal/snapshot/all')
.set('x-internal-key', 'test-key-123')
.send({});
expect(res.status).toBe(200);
expect(snapshot.runAllSnapshots).toHaveBeenCalled();
expect(snapshot.runSnapshot).not.toHaveBeenCalled();
expect(res.body.results).toHaveLength(2);
});
});
describe('GET /api/ticker', () => {
const mockCache = { value: null };
jest.mock('../../src/utils/redis', () => ({
cacheGet: async () => mockCache.value,
cacheSet: async () => true,
getRedisClient: () => ({}),
isDegraded: () => false,
}));
function mountTicker() {
delete require.cache[require.resolve('../../src/routes/ticker')];
const tickerRoutes = require('../../src/routes/ticker');
const app = express();
app.use('/api/ticker', tickerRoutes);
return app;
}
it('returns snapshot items newest-first', async () => {
mockCache.value = [{ tag: 'SCAN', text: 'MLB slate scanned · 248 props graded' }, { tag: 'A+', text: 'BOMBER Judge graded A+' }];
delete process.env.TICKER_MANUAL;
const res = await request(mountTicker()).get('/api/ticker');
expect(res.status).toBe(200);
expect(res.body.items[0].tag).toBe('SCAN');
});
it('merges editorial pins from TICKER_MANUAL', async () => {
mockCache.value = [{ tag: 'SCAN', text: 'scanned' }];
process.env.TICKER_MANUAL = JSON.stringify([{ tag: 'ALERT', text: 'VYNDR 2.0 is live.' }]);
const res = await request(mountTicker()).get('/api/ticker');
expect(res.body.items.find((i) => i.tag === 'ALERT')).toBeTruthy();
delete process.env.TICKER_MANUAL;
});
it('returns [] gracefully on a cold cache', async () => {
mockCache.value = null;
delete process.env.TICKER_MANUAL;
const res = await request(mountTicker()).get('/api/ticker');
expect(res.body.items).toEqual([]);
});
});
+73
View File
@@ -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);
});
});
+81
View File
@@ -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 ');
});
});
+38
View File
@@ -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));
});
});
+149
View File
@@ -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);
});
});
+33
View File
@@ -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');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Snapshot proxy (Session 45) — forwards GET /api/snapshot/:sport (pre-graded slate). */
export async function GET(_req: NextRequest, ctx: { params: Promise<{ sport: string }> }) {
const { sport } = await ctx.params;
try {
const upstream = await fetch(`${BACKEND_URL}/api/snapshot/${encodeURIComponent(sport)}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({ grades: [], deltas: [] }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ sport, grades: [], deltas: [] }, { status: 200 });
}
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Ticker proxy (Session 45) — forwards GET /api/ticker to Express (snapshot
* exhaust + editorial pins). Thin pass-through; the page polls this every 30s.
*/
export async function GET() {
try {
const upstream = await fetch(`${BACKEND_URL}/api/ticker`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({ items: [] }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ items: [] }, { status: 200 });
}
}
+54 -78
View File
@@ -2,9 +2,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; // Session 45 — the live Slate now renders the pre-graded snapshot via the
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow'; // VYNDR 2.0 card. Legacy GameCard is kept ONLY for its shared types.
import { isRelevantGame } from '@/lib/slateAdapter'; import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime } from '@/lib/slateAdapter';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the // Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the // navigation system; streaks + hot lists layer ON TOP of the odds the
@@ -157,6 +160,31 @@ interface StreakApiRow {
} }
interface StreaksResponse { streaks?: StreakApiRow[] } interface StreaksResponse { streaks?: StreakApiRow[] }
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
type GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex): GameCardData {
return {
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
sport: g.sport,
live: g.status === 'in',
score: g.score ? { away: g.score.away, home: g.score.home } : undefined,
away: { abbr: g.awayAbbr || g.awayTeam, name: g.awayTeam },
home: { abbr: g.homeAbbr || g.homeTeam, name: g.homeTeam },
time: formatGameTime(g.gameTime),
venue: g.venue,
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex),
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
};
}
// ---- Session 24: schedule + game-lines response shapes ---- // ---- Session 24: schedule + game-lines response shapes ----
interface ScheduleTeam { name?: string | null; abbreviation?: string | null } interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
interface ScheduleGame { interface ScheduleGame {
@@ -321,6 +349,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// and game lines stay visible regardless (handled inside GameCard). // and game lines stay visible regardless (handled inside GameCard).
const [activeStat, setActiveStat] = useState<string>('all'); const [activeStat, setActiveStat] = useState<string>('all');
const [games, setGames] = useState<SlateGame[]>([]); const [games, setGames] = useState<SlateGame[]>([]);
// Session 45 — merged pre-graded snapshot across the loaded sports.
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
// Session 26 — per-sport schedule counts for the tab labels, fetched // Session 26 — per-sport schedule counts for the tab labels, fetched
@@ -333,11 +364,6 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// games, this becomes a soft inline notice instead of a wall-of-error. // games, this becomes a soft inline notice instead of a wall-of-error.
const [oddsNotice, setOddsNotice] = useState(false); const [oddsNotice, setOddsNotice] = useState(false);
// Grade state — Map keyed by propRowKey.
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
const [gradingKey, setGradingKey] = useState<string | null>(null);
const [errorByKey, setErrorByKey] = useState<Record<string, string | undefined>>({});
// Search filter (Phase 3.4 — kept here so the Slate owns its own filtering). // Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -385,11 +411,13 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const perSport = await Promise.all( const perSport = await Promise.all(
sportsToFetch.map(async (sport) => { sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[]; const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines, streaksRes] = await Promise.all([ const [oddsResults, schedule, lines, streaksRes, snap] = await Promise.all([
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))), Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
// Session 45 — pre-graded snapshot (locked grades + line deltas).
getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
]); ]);
const oddsOk = oddsResults.some((o) => o !== null); const oddsOk = oddsResults.some((o) => o !== null);
@@ -397,20 +425,26 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const oddsGames = groupByGame(oddsProps, sport); const oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || []; const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks); const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 }; return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [] };
}), }),
); );
const allGames: SlateGame[] = []; const allGames: SlateGame[] = [];
const allSnapGrades: SnapshotGrade[] = [];
const allSnapDeltas: SnapshotDelta[] = [];
let anyOddsOk = false; let anyOddsOk = false;
let anyScheduleShown = false; let anyScheduleShown = false;
for (const s of perSport) { for (const s of perSport) {
allGames.push(...s.merged); allGames.push(...s.merged);
allSnapGrades.push(...s.snapGrades);
allSnapDeltas.push(...s.snapDeltas);
if (s.oddsOk) anyOddsOk = true; if (s.oddsOk) anyOddsOk = true;
if (s.hadSchedule) anyScheduleShown = true; if (s.hadSchedule) anyScheduleShown = true;
} }
setGames(allGames); setGames(allGames);
setSnapGrades(allSnapGrades);
setSnapDeltas(allSnapDeltas);
// Odds down but schedule carried the slate → soft notice, not a wall. // Odds down but schedule carried the slate → soft notice, not a wall.
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true); if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
@@ -455,58 +489,15 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
// Grading call site. Single source of truth so we never have two // Session 45 — the on-demand "Read" grade flow is RETIRED. Grades come from
// PropRows in-flight from the same prop (the loadingKey enforces it). // the scheduled snapshot pipeline (snapshot:{sport}:latest), overlaid onto the
const onGrade = useCallback(async (prop: PropRowProp) => { // slate below. The legacy onGrade call site was removed with the legacy card.
const key = propRowKey(prop);
if (gradingKey) return; // already a grade in flight — defer
setGradingKey(key);
setErrorByKey((prev) => ({ ...prev, [key]: undefined }));
try {
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({
sport: 'NBA', // overwritten below per game card sport
player: prop.player,
stat: prop.stat_type,
line: prop.line,
direction: prop.direction,
book: prop.book || 'draftkings',
}),
});
const body = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: string };
if (!res.ok) {
setErrorByKey((prev) => ({ ...prev, [key]: body.error || `HTTP ${res.status}` }));
return;
}
const result: PropRowResult = {
grade: String(body.grade || 'C'),
confidence: typeof body.confidence === 'number' ? body.confidence : undefined,
edge_pct: typeof body.edge_pct === 'number' ? body.edge_pct : undefined,
reasoning: (body.reasoning as PropRowResult['reasoning']) || undefined,
kill_conditions_triggered: (body.kill_conditions_triggered as PropRowResult['kill_conditions_triggered']) || [],
tier_gated: !!body.tier_gated,
upgrade_hint: typeof body.upgrade_hint === 'string' ? body.upgrade_hint : undefined,
};
setGradedProps((prev) => {
const next = new Map(prev);
next.set(key, result);
return next;
});
} catch {
setErrorByKey((prev) => ({ ...prev, [key]: 'Network error. Try again.' }));
} finally {
setGradingKey(null);
}
}, [gradingKey, session]);
const onUpgrade = useCallback(() => router.push('/pricing'), [router]);
// Filter pipeline — searchQuery applied to games + props. // Filter pipeline — searchQuery applied to games + props.
// Session 45 — index the pre-graded snapshot once for the overlay.
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
const filteredGames = useMemo(() => { const filteredGames = useMemo(() => {
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never // Session 44 — drop completed games >24h old so a 5-day-old FINAL never
// lingers on the dashboard. Upcoming + live always show. // lingers on the dashboard. Upcoming + live always show.
@@ -742,25 +733,10 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
<div style={{ display: 'grid', gap: 16 }}> <div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => ( {filteredGames.map((g, i) => (
<GameCard <VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`} key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
sport={g.sport} game={slateGameToCardData(g, gradeIndex, deltaIndex)}
homeTeam={g.homeTeam} onOpen={() => router.push('/scan')}
awayTeam={g.awayTeam}
gameTime={g.gameTime}
venue={g.venue}
context={g.context}
props={g.props}
status={g.status}
score={g.score}
gameLines={g.gameLines}
streaks={g.streaks}
gradedProps={gradedProps}
loadingKey={gradingKey}
errorByKey={errorByKey}
tier={tier}
onGrade={(p) => onGrade({ ...p })}
onUpgrade={onUpgrade}
/> />
))} ))}
</div> </div>
+58 -14
View File
@@ -9,7 +9,11 @@ export interface StripProp {
stat: string; stat: string;
line: number | string; line: number | string;
side: string; // O / U / Over / Under side: string; // O / U / Over / Under
grade: string; grade: string | null;
// Session 45 — pre-graded snapshot model: the locked grade + market movement.
gradedAt?: { line: number; odds?: number | null; timestamp?: string; ago?: string } | null;
delta?: { delta: number; direction: 'toward' | 'away'; currentLine: number } | null;
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
} }
export interface StripArchetype { export interface StripArchetype {
primary: string; primary: string;
@@ -127,19 +131,59 @@ export default function StatStrip({
</span> </span>
))} ))}
</div> </div>
{props && props.length > 0 && ( {props && props.length > 0 && (() => {
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}> // Session 45 — snapshot mode renders each prop as a block with its
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span> // locked grade + market-movement sub-line; otherwise the inline chip row.
{props.map((p, i) => ( const snapshotMode = props.some((p) => p.gradedAt || p.delta || p.awaiting);
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}> if (!snapshotMode) {
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>} return (
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}> <div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
{p.stat} {p.side}{p.line} <GradeBadge grade={p.grade} size="sm" /> <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
</span> {props.map((p, i) => (
</span> <span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
))} {i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
</div> <span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
)} {p.stat} {p.side}{p.line} {p.grade && <GradeBadge grade={p.grade} size="sm" />}
</span>
</span>
))}
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{props.map((p, i) => {
if (p.awaiting) {
return (
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>Awaiting next scan</span>
</div>
);
}
const toward = p.delta?.direction === 'toward';
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
{p.gradedAt?.ago && (
<span style={{ color: 'var(--text-2)' }}>
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
</span>
)}
</div>
{p.delta && (
<div className="mono" style={{ fontSize: 11, color: toward ? 'var(--g-a)' : 'var(--amber)' }}>
Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta}
</div>
)}
</div>
);
})}
</div>
);
})()}
</div> </div>
); );
} }
+46 -26
View File
@@ -1,3 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
type TickerItem = { type TickerItem = {
tag: string; tag: string;
text: string; text: string;
@@ -12,31 +16,58 @@ type TickerItem = {
type TickerProps = { type TickerProps = {
items: TickerItem[]; items: TickerItem[];
height?: number; height?: number;
/** Poll /api/ticker for live snapshot events (Session 45). Default true. */
live?: boolean;
/** Poll interval ms (default 30s). */
pollMs?: number;
};
const TAG_COLORS: Record<string, string> = {
'A+': 'var(--g-ap)', A: 'var(--g-a)', SCAN: 'var(--g-a)',
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
}; };
/** /**
* Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the * Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the
* loop is seamless. Edge fades mask the wrap. Tags glow; values stay crisp. * loop is seamless. Session 45 — polls /api/ticker for live snapshot exhaust
* (top grades, line moves, slate-scanned events); the passed `items` are the
* initial + graceful fallback so the bar is never empty.
*/ */
export default function Ticker({ items, height = 34 }: TickerProps) { export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
const content = items.map((it, i) => ( const [feed, setFeed] = useState<TickerItem[] | null>(null);
useEffect(() => {
if (!live) return;
let active = true;
const load = async () => {
try {
const r = await fetch('/api/ticker', { cache: 'no-store' });
if (!r.ok) return;
const data = (await r.json()) as { items?: TickerItem[] };
if (active && Array.isArray(data.items) && data.items.length > 0) {
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
}
} catch {
/* keep the current items on failure — never blank the bar */
}
};
load();
const id = setInterval(load, pollMs);
return () => { active = false; clearInterval(id); };
}, [live, pollMs]);
const display = feed && feed.length > 0 ? feed : items;
const content = display.map((it, i) => (
<span <span
key={i} key={i}
className="mono" className="mono"
style={{ style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 26px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '0 26px',
fontSize: 12,
letterSpacing: '0.04em',
color: 'var(--text-1)',
}}
> >
<span <span
style={{ style={{
color: it.color || 'var(--amber)', color: it.color || 'var(--amber)',
textShadow: it.glow ? 'var(--amber-glow)' : 'none', textShadow: it.glow || it.tag === 'A+' ? 'var(--amber-glow)' : 'none',
fontWeight: 700, fontWeight: 700,
}} }}
> >
@@ -44,9 +75,7 @@ export default function Ticker({ items, height = 34 }: TickerProps) {
</span> </span>
<span style={{ color: 'var(--text-0)' }}>{it.text}</span> <span style={{ color: 'var(--text-0)' }}>{it.text}</span>
{it.delta && ( {it.delta && (
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}> <span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
{it.delta}
</span>
)} )}
<span style={{ color: 'var(--text-2)' }}>·</span> <span style={{ color: 'var(--text-2)' }}>·</span>
</span> </span>
@@ -54,16 +83,7 @@ export default function Ticker({ items, height = 34 }: TickerProps) {
return ( return (
<div <div
className="scanlines" className="scanlines"
style={{ style={{ height, overflow: 'hidden', background: 'var(--bg-1)', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', position: 'relative' }}
height,
overflow: 'hidden',
background: 'var(--bg-1)',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
position: 'relative',
}}
> >
<div className="ticker-track"> <div className="ticker-track">
{content} {content}
+98
View File
@@ -192,6 +192,99 @@ function isRelevantGame(game, now = Date.now()) {
return (now - t) / 3_600_000 < 24; 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 = { module.exports = {
parseAmericanOdds, parseAmericanOdds,
detectBestLines, detectBestLines,
@@ -201,4 +294,9 @@ module.exports = {
groupPropsByPlayer, groupPropsByPlayer,
mapPitchers, mapPitchers,
isRelevantGame, isRelevantGame,
indexGrades,
indexDeltas,
statShort,
gradedAgo,
buildPlayerStripsFromProps,
}; };