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:
+59
-3
@@ -4,9 +4,65 @@
|
||||
2026-06-18
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v44.0 — Make it visible: VYNDR Original archetype names, grade-card
|
||||
intel wiring, stale-game filtering, landing copy, depth proxies. Frontend +
|
||||
wiring only (no new backend services).
|
||||
SHIP BUILD v45.0 — Snapshot pipeline + GameCard swap + live ticker. The product
|
||||
shifted: on-demand "Read" grading is RETIRED; a scheduled pipeline pre-grades the
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -144,6 +144,13 @@ app.use('/api/widget', widgetRoutes);
|
||||
// stat-filtered views over all of them.
|
||||
const scheduleRoutes = require('./routes/schedule');
|
||||
app.use('/api/schedule', scheduleRoutes);
|
||||
// Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public,
|
||||
// cache-only, never triggers a snapshot.
|
||||
const tickerRoutes = require('./routes/ticker');
|
||||
app.use('/api/ticker', tickerRoutes);
|
||||
// Session 45 — pre-graded slate read (snapshot:{sport}:latest). Public, cache-only.
|
||||
const snapshotReadRoutes = require('./routes/snapshot');
|
||||
app.use('/api/snapshot', snapshotReadRoutes);
|
||||
const gameLinesRoutes = require('./routes/gameLines');
|
||||
app.use('/api/gamelines', gameLinesRoutes);
|
||||
const streaksRoutes = require('./routes/streaks');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -7,6 +7,8 @@ const { getConfiguredProviders, listProviderIds } = require('./config/providers'
|
||||
// Session 24 — warm the Tank01 cache after boot so streaks / hot lists /
|
||||
// game lines have data on the first page load. Non-blocking; see module.
|
||||
const { scheduleStartupPrefetch } = require('./startupPrefetch');
|
||||
// Session 45 — in-process snapshot cron (gated on SNAPSHOT_CRON=1).
|
||||
const { startSnapshotScheduler } = require('./snapshotScheduler');
|
||||
|
||||
// Default 3001 — Next.js owns 3000 locally and in production. The poller,
|
||||
// internal cron, and BASE_URL conventions all assume 3001 for the Express
|
||||
@@ -25,4 +27,7 @@ app.listen(PORT, () => {
|
||||
// Session 24 — fire-and-forget cache warm. 5s delay so Redis is ready.
|
||||
// Skips itself when RAPID_API_KEY is unset; never blocks or crashes boot.
|
||||
scheduleStartupPrefetch();
|
||||
|
||||
// Session 45 — arm the snapshot cron (no-op unless SNAPSHOT_CRON=1).
|
||||
startSnapshotScheduler();
|
||||
});
|
||||
|
||||
@@ -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 } };
|
||||
@@ -124,15 +124,26 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
||||
};
|
||||
}
|
||||
if (sp === 'nba' || sp === 'wnba') {
|
||||
// NBA/WNBA stats come from the Python nba_api service (nbaStatsClient).
|
||||
// It's frequently offline in prod (localhost service) — degrade quietly.
|
||||
// PRIMARY: the Python nba_api service (nbaStatsClient) — often offline in
|
||||
// prod. FALLBACK: ESPN's free public stats (espnStatsAdapter). Either way,
|
||||
// a miss degrades quietly to found:false (no badge, never a crash).
|
||||
const nba = opts.nbaClient || require('./nbaStatsClient');
|
||||
const data = await nba.getSeasonAvg(name).catch(() => null);
|
||||
if (!data || typeof data !== 'object') return { found: false };
|
||||
const ppg = toNum(data.ppg ?? data.points);
|
||||
if (!ppg) return { found: false };
|
||||
let data = await nba.getSeasonAvg(name).catch(() => null);
|
||||
if (!data || typeof data !== 'object' || !toNum(data.ppg ?? data.points)) {
|
||||
const espn = opts.espnStats || require('./adapters/espnStatsAdapter');
|
||||
const e = await espn.getSeasonAverages(name, sp).catch(() => ({ found: false }));
|
||||
if (e && e.found && e.classifierInput) {
|
||||
const ci = e.classifierInput;
|
||||
const season = [
|
||||
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
|
||||
{ k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
|
||||
];
|
||||
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position }, season, last10: [], splits: [] };
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
const classifierInput = {
|
||||
ppg, rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
|
||||
ppg: toNum(data.ppg ?? data.points), rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
|
||||
bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals),
|
||||
threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position,
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
|
||||
import { isRelevantGame } from '@/lib/slateAdapter';
|
||||
// Session 45 — the live Slate now renders the pre-graded snapshot via the
|
||||
// VYNDR 2.0 card. Legacy GameCard is kept ONLY for its shared types.
|
||||
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';
|
||||
// Session 23 — all-day intelligence layer. The stat filter is the
|
||||
// navigation system; streaks + hot lists layer ON TOP of the odds the
|
||||
@@ -157,6 +160,31 @@ interface 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 ----
|
||||
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
|
||||
interface ScheduleGame {
|
||||
@@ -321,6 +349,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
// and game lines stay visible regardless (handled inside GameCard).
|
||||
const [activeStat, setActiveStat] = useState<string>('all');
|
||||
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 [fetchError, setFetchError] = useState<string | null>(null);
|
||||
// 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.
|
||||
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).
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
@@ -385,11 +411,13 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
const perSport = await Promise.all(
|
||||
sportsToFetch.map(async (sport) => {
|
||||
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))),
|
||||
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<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);
|
||||
@@ -397,20 +425,26 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
const oddsGames = groupByGame(oddsProps, sport);
|
||||
const scheduleGames = schedule?.games || [];
|
||||
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 allSnapGrades: SnapshotGrade[] = [];
|
||||
const allSnapDeltas: SnapshotDelta[] = [];
|
||||
let anyOddsOk = false;
|
||||
let anyScheduleShown = false;
|
||||
for (const s of perSport) {
|
||||
allGames.push(...s.merged);
|
||||
allSnapGrades.push(...s.snapGrades);
|
||||
allSnapDeltas.push(...s.snapDeltas);
|
||||
if (s.oddsOk) anyOddsOk = true;
|
||||
if (s.hadSchedule) anyScheduleShown = true;
|
||||
}
|
||||
|
||||
setGames(allGames);
|
||||
setSnapGrades(allSnapGrades);
|
||||
setSnapDeltas(allSnapDeltas);
|
||||
|
||||
// Odds down but schedule carried the slate → soft notice, not a wall.
|
||||
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
|
||||
@@ -455,58 +489,15 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Grading call site. Single source of truth so we never have two
|
||||
// PropRows in-flight from the same prop (the loadingKey enforces it).
|
||||
const onGrade = useCallback(async (prop: PropRowProp) => {
|
||||
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]);
|
||||
// Session 45 — the on-demand "Read" grade flow is RETIRED. Grades come from
|
||||
// the scheduled snapshot pipeline (snapshot:{sport}:latest), overlaid onto the
|
||||
// slate below. The legacy onGrade call site was removed with the legacy card.
|
||||
|
||||
// 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(() => {
|
||||
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
|
||||
// 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 }}>
|
||||
{filteredGames.map((g, i) => (
|
||||
<GameCard
|
||||
<VyndrGameCard
|
||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||
sport={g.sport}
|
||||
homeTeam={g.homeTeam}
|
||||
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}
|
||||
game={slateGameToCardData(g, gradeIndex, deltaIndex)}
|
||||
onOpen={() => router.push('/scan')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,11 @@ export interface StripProp {
|
||||
stat: string;
|
||||
line: number | string;
|
||||
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 {
|
||||
primary: string;
|
||||
@@ -127,19 +131,59 @@ export default function StatStrip({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{props && props.length > 0 && (
|
||||
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
|
||||
{props.map((p, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||
{p.stat} {p.side}{p.line} <GradeBadge grade={p.grade} size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{props && props.length > 0 && (() => {
|
||||
// Session 45 — snapshot mode renders each prop as a block with its
|
||||
// locked grade + market-movement sub-line; otherwise the inline chip row.
|
||||
const snapshotMode = props.some((p) => p.gradedAt || p.delta || p.awaiting);
|
||||
if (!snapshotMode) {
|
||||
return (
|
||||
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
|
||||
{props.map((p, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type TickerItem = {
|
||||
tag: string;
|
||||
text: string;
|
||||
@@ -12,31 +16,58 @@ type TickerItem = {
|
||||
type TickerProps = {
|
||||
items: TickerItem[];
|
||||
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
|
||||
* 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) {
|
||||
const content = items.map((it, i) => (
|
||||
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
|
||||
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
|
||||
key={i}
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 26px',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--text-1)',
|
||||
}}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 26px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: it.color || 'var(--amber)',
|
||||
textShadow: it.glow ? 'var(--amber-glow)' : 'none',
|
||||
textShadow: it.glow || it.tag === 'A+' ? 'var(--amber-glow)' : 'none',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
@@ -44,9 +75,7 @@ export default function Ticker({ items, height = 34 }: TickerProps) {
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-0)' }}>{it.text}</span>
|
||||
{it.delta && (
|
||||
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>
|
||||
{it.delta}
|
||||
</span>
|
||||
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text-2)' }}>·</span>
|
||||
</span>
|
||||
@@ -54,16 +83,7 @@ export default function Ticker({ items, height = 34 }: TickerProps) {
|
||||
return (
|
||||
<div
|
||||
className="scanlines"
|
||||
style={{
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-1)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
style={{ 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">
|
||||
{content}
|
||||
|
||||
@@ -192,6 +192,99 @@ function isRelevantGame(game, now = Date.now()) {
|
||||
return (now - t) / 3_600_000 < 24;
|
||||
}
|
||||
|
||||
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
|
||||
const snorm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const gradeKey = (player, stat) => `${snorm(player)}|${String(stat || '').toLowerCase()}`;
|
||||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
|
||||
|
||||
/** Index snapshot grades by player|stat → the locked grade record. */
|
||||
function indexGrades(grades) {
|
||||
const map = {};
|
||||
for (const g of grades || []) {
|
||||
map[gradeKey(g.player || g.player_name, g.stat_type || g.stat)] = g;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Index line deltas by player|stat|side → delta record. */
|
||||
function indexDeltas(deltas) {
|
||||
const map = {};
|
||||
for (const d of deltas || []) {
|
||||
map[`${gradeKey(d.player, d.stat)}|${d.side}`] = d;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const STAT_SHORT = {
|
||||
total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs',
|
||||
strikeouts: 'Ks', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP',
|
||||
stolen_bases: 'SB', points: 'Pts', rebounds: 'Reb', assists: 'Ast', threes: '3PT',
|
||||
steals: 'Stl', blocks: 'Blk', pra: 'PRA', turnovers: 'TO',
|
||||
};
|
||||
function statShort(stat) {
|
||||
if (!stat) return '';
|
||||
return STAT_SHORT[stat] || String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Relative "Graded Xh ago" from an ISO timestamp. */
|
||||
function gradedAgo(iso, now = Date.now()) {
|
||||
const t = iso ? new Date(iso).getTime() : NaN;
|
||||
if (Number.isNaN(t)) return '';
|
||||
const mins = Math.max(0, Math.round((now - t) / 60000));
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.round(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
|
||||
* locked grades onto the game's odds-derived props (which already carry the
|
||||
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
|
||||
* or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read
|
||||
* button). Archetype comes from the snapshot's per-player classification.
|
||||
*/
|
||||
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now()) {
|
||||
const byPlayer = {};
|
||||
const order = [];
|
||||
for (const p of gameProps || []) {
|
||||
if (!p || !p.player) continue;
|
||||
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
|
||||
if (!byPlayer[p.player]) {
|
||||
byPlayer[p.player] = {
|
||||
player: p.player,
|
||||
team: p.team || '',
|
||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||
stats: [],
|
||||
props: [],
|
||||
};
|
||||
order.push(p.player);
|
||||
} else if (!byPlayer[p.player].archetype && rec && rec.archetype) {
|
||||
byPlayer[p.player].archetype = { primary: rec.archetype };
|
||||
}
|
||||
if (rec) {
|
||||
const side = sideCh(rec.direction);
|
||||
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
|
||||
byPlayer[p.player].props.push({
|
||||
stat: statShort(rec.stat_type || rec.stat),
|
||||
line: rec.line,
|
||||
side,
|
||||
grade: rec.grade,
|
||||
gradedAt: rec.gradedAt
|
||||
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
|
||||
: null,
|
||||
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
|
||||
});
|
||||
} else {
|
||||
byPlayer[p.player].props.push({
|
||||
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return order.map((name) => byPlayer[name]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseAmericanOdds,
|
||||
detectBestLines,
|
||||
@@ -201,4 +294,9 @@ module.exports = {
|
||||
groupPropsByPlayer,
|
||||
mapPitchers,
|
||||
isRelevantGame,
|
||||
indexGrades,
|
||||
indexDeltas,
|
||||
statShort,
|
||||
gradedAgo,
|
||||
buildPlayerStripsFromProps,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user