Session 55: Self-learning loop + real-time layer (2274 tests)

Product overhaul core — the two transformative, differentiated systems:

Self-learning loop (Phase 2): outcomeService settles locked snapshot grades
against real MLB Stats API results → hit/miss/push, rolling accuracy by grade
tier (30d window). Idempotent, injectable, unit-tested. New GET /api/accuracy +
/api/ledger/accuracy + internal settle triggers + cron hook. AccuracyBadge
(dashboard/scan/landing) is honest — "LEARNING" below MIN_SAMPLE, never a fake
number. Settled HIT/MISS chips overlay the live slate.

Real-time layer (Phase 1): Slate silent 60s auto-refresh (no flash, no wipe on
transient blips) + "SIGNAL LIVE · UPDATED Xs ago" freshness strip; Ticker LIVE
badge that flashes on fresh events.

Landing (Phase 3): TopSignals shows tonight's real top-3 A-rated grades + live
accuracy — the product shown, not described.

Founder pricing: FOUNDER_CODE_EXPIRY default 2026-06-30 → 2026-12-31 (had
lapsed, disabling every founder code + the ClaimMeter pitch). That expiry — not
a tier change — was the real cause of the 4 stripe test failures.

Backend 2255 (4 failing) → 2274 (all green; +19 new, +4 fixed). Web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-10 15:39:13 -04:00
parent 8629021774
commit d09a06c054
27 changed files with 1285 additions and 17 deletions
+31
View File
@@ -175,3 +175,34 @@ Slate builds a team→pitcher map (`slateAdapter.buildPitcherMap` /
- Player props: PropLine primary (3-key rotation), The-Odds-API backup.
- `grades:{sport}` cache (TTL 2h) is written by `gradeSlateService` on a fresh
odds fetch; it feeds `/leaders` + the player card's `activeProps`.
---
## 7. Accuracy / self-learning loop (`outcomeService`, Session 55)
The system's track record — settled grades vs real results. Written by
`outcomeService.settleAllOutcomes()` (cron, before grading); read-only endpoints.
### `GET /api/accuracy` (public, cached 5m)
```
{
overall: { // aggregate across sports
sport: 'overall', updated_at, window_days: 30, sample,
overall: { hits, misses, pushes, total, pct|null }, // pct excludes pushes
byGrade: { 'A+':{…}, 'A':{…}, 'B':{…}, 'C':{…}, 'D':{…}, 'F':{…} }
} | null,
sports: { mlb?: <record>, nba?: <record>, … }, // same shape per sport
min_sample: 8, // below this, show "LEARNING" not a %
updated_at: string | null
}
```
### `GET /api/ledger/accuracy` (public) — the ledger `buckets` shape
```
{ buckets: [{ grade, hits, total, pct|null }], overall, updated_at }
```
### Settled outcome overlay
`GET /api/snapshot/:sport` grades may carry `outcome: { result:'hit'|'miss'|
'push', actual:number }` once the game is final. `AccuracyBadge` +
`StatStrip.OutcomeChip` render these. Frontend proxy: `web/src/app/api/accuracy`.
+56 -3
View File
@@ -1,11 +1,64 @@
# VYNDR — Build State
## Last Updated
2026-06-19
2026-07-10
## Current Phase
SHIP BUILD v54.0 — Audit cleanup: name edge cases (hyphen / middle-initial /
richie), Team Hub name normalization, accent-keeping dedup, parlay copy.
SHIP BUILD v55.0 — Product overhaul: the self-learning loop (outcome tracking +
accuracy) + the real-time layer (auto-refresh, freshness, live signals) + landing
top-signals preview. Founder-pricing expiry restored.
## Session 55 (2026-07-10) — SHIPPED ✅ SELF-LEARNING LOOP + REAL-TIME LAYER
Backend 2255 (4 failing) → **2274 tests** (all green; +19 new, +4 fixed), 196
suites. Web build clean (exit 0). Spec: `specs/session55-self-learning.md`.
### Phase 2 — the self-learning loop (the crown jewel; nothing like it existed)
- **`src/services/outcomeService.js`** — settles each locked snapshot grade
against the REAL result (MLB Stats API game log — free, same source the grade
pipeline uses) → hit/miss/push, aggregated by grade tier over a trailing 30-day
window. Presence of a game-log row for the graded date ⇒ FINAL. Fully
injectable → unit-tested with zero network. Idempotent (dedupe by
`nameKey|stat|line|side|date`). NBA/WNBA degrade to `pending` (offline stats),
never throw.
- **Redis:** `outcomes:{sport}:log` (settled, cap 1000), `accuracy:{sport}`,
`accuracy:overall` ({ overall, byGrade } over 30d; pct excludes pushes).
- **Routes:** `GET /api/accuracy` (public, cached 5m), `GET /api/ledger/accuracy`
(buckets — fills the pre-existing Next proxy that had no writer), internal
`POST /api/internal/outcomes/:sport|/all`. Cron: `settleAllOutcomes()` runs on
the snapshot scheduler tick BEFORE grading (settle yesterday, grade today).
- **UI:** `AccuracyBadge` (dashboard header + scan result + landing) — honest by
construction: below MIN_SAMPLE (8) it reads "MODEL · LEARNING" (amber) instead
of faking a number; above it, "A-RATED · 68% HIT · 30D" (green). Settled
outcome chips (`✓ HIT (2)` / `✕ MISS`) on slate props via a snapshot-route
overlay + `StatStrip.OutcomeChip`.
### Phase 1 — the real-time layer (make it feel ALIVE)
- **Slate auto-refresh:** silent 60s poll (no skeleton flash, never wipes a good
view on a transient blip) + a "SIGNAL LIVE · N PROPS GRADED · M LIVE · UPDATED
Xs ago" strip with a ticking freshness clock (`nowTick`, 15s).
- **Ticker:** anchored `LIVE` badge (pulsing dot) that flashes green when a fresh
event slides in (breaking-news feel).
### Phase 3 — landing top-signals preview
- **`TopSignals.tsx`** — pulls tonight's REAL top-3 A-rated grades from
`/api/snapshot/{mlb,nba,wnba}` as mini grade cards (archetype + grade + line) +
the live `AccuracyBadge`. Self-hides off-hours. The product shown, not described.
### Founder pricing (the real cause of the "4 stripe failures")
`FOUNDER_CODE_EXPIRY` default was `2026-06-30` — lapsed as of the current date
(2026-07-10), silently disabling every founder code (and the ClaimMeter scarcity
pitch). Extended the default to `2026-12-31` (operators still override via env).
That restored founder pricing AND turned the 4 failing tests green.
### Honest scope (deferred — NOT built this session)
The prompt's Phases 48 (dashboard card redesign, scan reveal polish, Parlay Lab
derivatives-desk polish, nav/mobile/onboarding/settings/team-hub polish) are real
follow-on work. This session invested in the two TRANSFORMATIVE, differentiated
systems (real-time + self-learning) end-to-end with tests + the highest-leverage
first-visit surfaces (landing signals, dashboard accuracy). Live calibration
adjustment (spec 2.3, feeding outcomes back into grade confidence) is scaffolded
by the accuracy record but not yet wired into the engine.
## Session 54 (2026-06-19) — SHIPPED ✅ AUDIT CLEANUP
+39
View File
@@ -619,6 +619,45 @@ snapshot, locked to the line, and read from cache.
regression"). "kill conditions" is still legit IN-APP product copy (help/pricing/
scan) — only the social meta was cleaned.
## Self-Learning Loop + Real-Time Layer (Session 55 — non-obvious)
- **`outcomeService.settleSnapshot(sport, deps)`** is the self-learning loop:
reads `snapshot:{sport}:latest`, settles each locked grade vs the REAL result
from `mlbStatsAdapter.getPlayerStats().last10` (game-log row whose `date`
matches the graded date — UTC **or** ET, to cover late-game rollover), records
hit/miss/push, and writes `outcomes:{sport}:log` + `accuracy:{sport}` +
`accuracy:overall`. **Idempotent** — dedupe key is `nameKey|stat|line|side|date`;
re-running never double-counts. Presence of a game-log row ⇒ the game is FINAL
(no separate status check). NBA/WNBA have no free settled-result feed → they
stay `pending` (never throw). If you add an MLB stat_type, add it to
`MLB_LOG_FIELD` in outcomeService (a LOCAL copy — settlement is decoupled from
featureCache's map on purpose) or its props won't settle.
- **Accuracy pct EXCLUDES pushes** (`hits/(hits+misses)`); `sample` counts pushes.
Grade buckets: `A+` stands alone, then first-letter (`A-`/`A`→A, ``/`B`→B).
30-day trailing window keyed off each outcome's game `date`.
- **The cron settles BEFORE it grades** (`snapshotScheduler` tick): settle
yesterday's now-completed games, then grade today's fresh slate. Trigger
manually via internal `POST /api/internal/outcomes/all` (same key as snapshot).
- **`GET /api/accuracy`** (public, cached) + **`GET /api/ledger/accuracy`**
(buckets — the pre-existing Next `ledger/accuracy` proxy finally has a writer).
Browser reaches them via `web/src/app/api/accuracy/route.ts` (new proxy) — same
S25 rule.
- **`AccuracyBadge`** (`@/components/vyndr`) is HONEST: `< MIN_SAMPLE` (8 settled)
→ "MODEL · LEARNING" (amber), else "A-RATED · X% HIT · 30D" (green). Pass
`sport=` for a sport-specific record. Self-hides only when the fetch fails/empty.
- **Settled outcomes overlay the live slate:** `GET /api/snapshot/:sport` merges
`outcomes:{sport}:log` onto grades (`outcome:{result,actual}`), threaded through
`slateAdapter.buildPlayerStripsFromProps` → `StatStrip.OutcomeChip` (`✓ HIT (2)`
/ `✕ MISS`). A settled prop hides its "+parlay" / BOOK-IT chips (the bet is over).
- **Real-time Slate:** `fetchSlate(tab, silent=true)` polls every 60s WITHOUT the
skeleton flash and NEVER wipes a good view on a transient empty result (guard:
`if (silent && allGames.length===0) return`). The "SIGNAL LIVE · UPDATED Xs ago"
strip uses `lastRefreshed` + a 15s `nowTick`. Ticker has an anchored LIVE badge
that flashes on a new head event.
- **Founder pricing:** `stripeService` `FOUNDER_CODE_EXPIRY` default is now
`2026-12-31` (was `2026-06-30`, which had lapsed and disabled every founder code
+ the ClaimMeter pitch). That expiry lapsing — NOT a tier-limit change — was the
cause of the "4 stripe test failures." Operators override via `FOUNDER_CODE_EXPIRY`.
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
+76
View File
@@ -0,0 +1,76 @@
# Spec — Self-Learning Loop (Outcome Tracking + Accuracy) — Session 55
## Problem
VYNDR grades props but never checks whether it was right. The intelligence is
static — no outcome tracking, no accuracy record, no "the system learns" signal.
This is the #1 trust builder for a product a 98K-follower bettor would stake his
reputation on: showing real accuracy, including misses.
## What we build
`outcomeService` — settles locked snapshot grades against real game results,
records hit/miss per graded prop, and aggregates a rolling accuracy record by
grade tier. Powered by the FREE MLB Stats API game log (the same source the
grade pipeline already uses); NBA/WNBA degrade to `pending` when the Python
stats service is offline (matches existing posture). Zero new dependency, zero
new paid API credits.
## Data source
`mlbStatsAdapter.getPlayerStats(name)``{ found, last10: [{ date, opponent, stat:{…} }] }`.
A prop is "settled" when a game-log row exists for the graded date (presence in
the log ⇒ the game is FINAL). The actual stat value is read via a settlement
map (VYNDR `stat_type` → statsapi game-log field), mirroring `MLB_LOG_FIELD`.
## Settlement logic
For each grade in `snapshot:{sport}:latest`:
- `targetDates` = the UTC date and America/New_York date of `gradedAt.timestamp`
(covers late-game ET rollover without heavy TZ math).
- Find the game-log row whose `date``targetDates`. None ⇒ `pending` (skip).
- `actual` = settlement-map value for the graded `stat_type`. Unmappable ⇒ skip.
- `result`:
- side `over`: actual > line ⇒ `hit`; actual < line ⇒ `miss`; == ⇒ `push`
- side `under`: actual < line ⇒ `hit`; actual > line ⇒ `miss`; == ⇒ `push`
- Idempotent: outcome key = `nameKey(player)|stat|line|side|date`. A prop settles
once; re-runs never double-count.
## Persistence (Redis, no DB migration)
- `outcomes:{sport}:log` — array of settled outcomes (newest first, cap 1000,
deduped by key). Each: `{ key, player, stat, line, side, grade, actual, result, date, gradedAt, settledAt }`.
- `accuracy:{sport}``{ sport, updated_at, window_days:30, sample, overall:{hits,total,pushes,pct}, byGrade:{'A+':{…},'A':{…},'B':{…},'C':{…},'D':{…}} }`.
- `accuracy:overall` — same shape aggregated across sports (dashboard header).
- `pct` = hits / (hits + misses) over the trailing 30 days; pushes excluded from pct.
## Endpoints
- `GET /api/accuracy` (public, cached 5m) → `{ overall, sports:{mlb,nba,wnba,…}, updated_at }`.
- `GET /api/ledger/accuracy` (public) → `{ buckets:[{ grade, hits, total, pct }] }`
(feeds the existing Next proxy `web/src/app/api/ledger/accuracy`).
- `POST /api/internal/outcomes/:sport` and `/outcomes/all` (internal-key gated) —
trigger settlement. Same auth as the snapshot trigger.
- Cron: `settleAllOutcomes()` runs on the snapshot scheduler tick BEFORE grading
(settle yesterday's completed games, then grade today's).
## Frontend
- Dashboard header accuracy pill: "A-RATED · 68% HIT RATE (30D)" from `/api/accuracy`.
Self-hides when sample is too small (< MIN_SAMPLE).
- `GradeResultCard`: "A-rated props hit 68% of the time" line when accuracy for
that grade tier is available (passed via `gradeAdapter`).
- Snapshot prop rows: a settled prop shows `✅ HIT (2)` / `❌ MISS (1)` /
`PUSH` derived from `outcomes:{sport}:log` (via the snapshot read merge).
## Acceptance criteria
1. `settleSnapshot('mlb', deps)` with injected grades + game logs returns settled
outcomes with correct hit/miss/push for over AND under sides.
2. Settlement is idempotent — running twice does not double-count.
3. Accuracy aggregates hits/total/pct by grade tier over the 30-day window.
4. A prop with no matching game-log row stays `pending` and is excluded.
5. `GET /api/accuracy` returns the persisted record (empty-safe when cold).
6. NBA/WNBA (offline stats) degrade to `pending`, never throw.
7. All deps injectable → tests hit zero network.
## Test plan
`tests/unit/outcomeService.test.js`:
- hit/miss/push for over + under; unmappable stat skipped; missing game skipped.
- idempotency (dedupe by key across two runs).
- accuracy bucketing + pct math + 30-day window filter + MIN_SAMPLE gating.
- `getAccuracy` cold-cache empty shape.
`tests/integration/accuracy.test.js`:
- `GET /api/accuracy` and `GET /api/ledger/accuracy` return valid shapes.
+4
View File
@@ -155,6 +155,10 @@ const snapshotReadRoutes = require('./routes/snapshot');
app.use('/api/snapshot', snapshotReadRoutes);
// Session 51 — Team Hub (roster + archetypes + graded props). Public, cached.
app.use('/api/team', require('./routes/team'));
// Session 55 — self-learning loop: the system's rolling accuracy record
// (settled snapshot grades vs real results). Public, cache-only.
app.use('/api/accuracy', require('./routes/accuracy'));
app.use('/api/ledger', require('./routes/ledger'));
const gameLinesRoutes = require('./routes/gameLines');
app.use('/api/gamelines', gameLinesRoutes);
const streaksRoutes = require('./routes/streaks');
+30
View File
@@ -0,0 +1,30 @@
'use strict';
/**
* GET /api/accuracy (Session 55) — the system's track record.
*
* Public, cache-only read of the rolling accuracy record written by
* outcomeService (settled snapshot grades vs real results). Powers the
* dashboard "A-rated: 68% hit rate" pill and the grade-card accuracy line.
* NEVER triggers settlement (that's the internal cron) → no API credits spent.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const outcomeService = require('../services/outcomeService');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
router.get('/', async (req, res) => {
try {
const acc = await outcomeService.getAccuracy();
res.set('Cache-Control', 'public, max-age=300');
return res.json(acc);
} catch (err) {
console.error('[accuracy]', err.message);
return res.status(200).json({ overall: null, sports: {}, min_sample: outcomeService.MIN_SAMPLE, updated_at: null });
}
});
module.exports = router;
+31
View File
@@ -159,4 +159,35 @@ router.post('/snapshot/:sport', async (req, res) => {
}
});
/**
* POST /api/internal/outcomes/all (Session 55) — settle every sport's latest
* snapshot against real results + recompute the overall accuracy record. This
* is the self-learning loop's write path (the public /api/accuracy is read-only).
* Registered BEFORE /outcomes/:sport so "all" isn't captured as a sport.
*/
router.post('/outcomes/all', async (req, res) => {
const outcomes = require('../services/outcomeService');
try {
const results = await outcomes.settleAllOutcomes();
return res.json({ ok: true, results });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/outcomes/all] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
router.post('/outcomes/:sport', async (req, res) => {
const outcomes = require('../services/outcomeService');
try {
const summary = await outcomes.settleSnapshot(req.params.sport);
await outcomes.recomputeOverall();
return res.json({ ok: true, summary: { sport: summary.sport, settled: summary.settled, pending: summary.pending, accuracy: summary.accuracy } });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/outcomes] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
module.exports = router;
+30
View File
@@ -0,0 +1,30 @@
'use strict';
/**
* GET /api/ledger/accuracy (Session 55) — grade-tier buckets for the ledger UI.
*
* The Next proxy `web/src/app/api/ledger/accuracy` has expected a `{ buckets }`
* shape since before a writer existed; the self-learning loop now fills it.
* Public, cache-only (reads outcomeService's persisted accuracy record).
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const outcomeService = require('../services/outcomeService');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
router.get('/accuracy', async (req, res) => {
try {
const acc = await outcomeService.getAccuracy();
const buckets = outcomeService.accuracyBuckets(acc.overall);
res.set('Cache-Control', 'public, max-age=300');
return res.json({ buckets, overall: acc.overall && acc.overall.overall, updated_at: acc.updated_at });
} catch (err) {
console.error('[ledger/accuracy]', err.message);
return res.status(200).json({ buckets: [] });
}
});
module.exports = router;
+27 -3
View File
@@ -12,23 +12,47 @@
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { cacheGet } = require('../utils/redis');
const { nameKey } = require('../utils/playerName');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
// Session 55 — overlay settled outcomes (self-learning loop) onto the grades so
// a completed prop can render "✅ HIT (2)" / "❌ MISS". Keyed by player+stat+line+side.
function outcomeIndex(log) {
const map = {};
for (const o of Array.isArray(log) ? log : []) {
const side = String(o.side || 'O').toUpperCase() === 'U' ? 'U' : 'O';
map[`${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${side}`] = o;
}
return map;
}
function attachOutcomes(grades, index) {
if (!index || Object.keys(index).length === 0) return grades;
return grades.map((g) => {
const side = String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O';
const o = index[`${nameKey(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${g.line}|${side}`];
return o ? { ...g, outcome: { result: o.result, actual: o.actual } } : g;
});
}
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
try {
const snap = await cacheGet(`snapshot:${sport}:latest`);
const [snap, outcomeLog] = await Promise.all([
cacheGet(`snapshot:${sport}:latest`),
cacheGet(`outcomes:${sport}:log`),
]);
const idx = outcomeIndex(outcomeLog);
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 || [] });
return res.json({ sport, updated_at: snap.updated_at, grades: attachOutcomes(snap.grades, idx), 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: [] });
return res.json({ sport, updated_at: env && env.updated_at, grades: attachOutcomes(grades, idx), deltas: [] });
} catch (err) {
console.error('[snapshot]', err.message);
return res.status(200).json({ sport, grades: [], deltas: [] });
+296
View File
@@ -0,0 +1,296 @@
'use strict';
/**
* outcomeService — the self-learning loop (Session 55).
*
* VYNDR grades props but never checked whether it was right. This closes the
* loop: after games complete, settle each locked snapshot grade against the
* REAL result (did the player clear the line?), record hit/miss/push, and
* aggregate a rolling accuracy record by grade tier. That powers the accuracy
* display ("A-rated props: 68% hit rate"), the #1 trust builder — a system that
* shows its misses, not just its hits.
*
* Data source: the FREE MLB Stats API game log (mlbStatsAdapter.getPlayerStats)
* — the same source the grade pipeline already uses. Presence of a game-log row
* for the graded date ⇒ the game is FINAL. NBA/WNBA degrade to `pending` when
* their (usually offline) Python stats service returns nothing — never throw.
* Zero new dependency, zero paid API credits.
*
* Redis keys written:
* outcomes:{sport}:log — settled outcomes, newest first, cap 1000, deduped
* accuracy:{sport} — { overall, byGrade } over a trailing 30-day window
* accuracy:overall — same, aggregated across sports (dashboard header)
*
* Everything is injectable → the whole cycle is unit-tested with zero network.
*/
const { nameKey } = require('../utils/playerName');
const LOG_CAP = 1000;
const LOG_TTL = 30 * 24 * 3600; // 30d — matches the accuracy window
const ACC_TTL = 7 * 24 * 3600;
const WINDOW_DAYS = 30;
const MIN_SAMPLE = 8; // below this, callers should hide the pct
const SPORTS = ['mlb', 'nba', 'wnba', 'soccer'];
// VYNDR stat_type → the per-game field in a statsapi.mlb.com game-log row.
// Mirrors featureCache.MLB_LOG_FIELD (settlement is a distinct concern, kept
// self-contained so this service doesn't depend on test-only internals).
const MLB_LOG_FIELD = {
total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi',
runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
innings_pitched: 'inningsPitched',
};
function statValue(statObj, statType) {
const f = MLB_LOG_FIELD[String(statType || '').toLowerCase()];
if (!f || !statObj) return null;
const n = parseFloat(statObj[f]);
return Number.isFinite(n) ? n : null;
}
// The UTC date AND the America/New_York date of an ISO timestamp — covers a
// late game whose ET calendar date differs from UTC, without heavy TZ math.
function dateStrings(ts) {
if (!ts) return [];
const d = new Date(ts);
if (isNaN(d.getTime())) return [];
const utc = d.toISOString().slice(0, 10);
let et = utc;
try {
et = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(d);
} catch { /* Intl missing → UTC only */ }
return [...new Set([utc, et])];
}
const sideOver = (side) => {
const s = String(side || 'over').toLowerCase();
return s === 'over' || s === 'o';
};
// hit / miss / push for a graded side given the actual result and the line.
function settleResult(side, actual, line) {
if (actual == null || line == null) return null;
const a = Number(actual), l = Number(line);
if (!Number.isFinite(a) || !Number.isFinite(l)) return null;
if (a === l) return 'push';
return sideOver(side) ? (a > l ? 'hit' : 'miss') : (a < l ? 'hit' : 'miss');
}
// Bucket a letter grade into a tier: A+ stands alone; A-/A → A; B±/B → B; …
function gradeBucket(grade) {
const g = String(grade || '').trim().toUpperCase();
if (!g) return null;
if (g === 'A+') return 'A+';
return g[0];
}
const TIERS = ['A+', 'A', 'B', 'C', 'D', 'F'];
function outcomeKey(o) {
return `${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${sideOver(o.side) ? 'O' : 'U'}|${o.date}`;
}
/**
* Settle one sport's latest snapshot against real results. Returns
* { sport, settled, pending, log } and persists the merged log + accuracy.
* Never throws; a player/stat that can't be resolved is simply left pending.
*
* opts (all injectable): cacheGet, cacheSet, getPlayerStats(name, sport),
* now (ISO string).
*/
async function settleSnapshot(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const deps = {
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
getPlayerStats: opts.getPlayerStats || defaultGetPlayerStats,
now: opts.now || (() => new Date().toISOString()),
};
const nowIso = deps.now();
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
const grades = snap && Array.isArray(snap.grades) ? snap.grades : [];
if (grades.length === 0) return { sport: sp, settled: 0, pending: 0, log: [] };
// Existing settled log (idempotency source).
const prevLog = normalizeLog(await deps.cacheGet(`outcomes:${sp}:log`));
const seen = new Set(prevLog.map(outcomeKey));
// Resolve each unique player's game log ONCE per run.
const players = [...new Set(grades.map((g) => g.player || g.player_name).filter(Boolean))];
const logByPlayer = {};
for (const player of players) {
try {
const stats = await deps.getPlayerStats(player, sp);
logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
} catch { logByPlayer[player] = []; }
}
const fresh = [];
let pending = 0;
for (const g of grades) {
const player = g.player || g.player_name;
const stat = g.stat_type || g.stat;
const side = g.direction || 'over';
const line = g.line;
const gradedTs = (g.gradedAt && g.gradedAt.timestamp) || snap.updated_at || nowIso;
const dates = dateStrings(gradedTs);
const log = logByPlayer[player] || [];
// Find the game played on the graded date.
const row = log.find((r) => r && r.date && dates.includes(r.date));
if (!row) { pending += 1; continue; }
const actual = statValue(row.stat, stat);
if (actual == null) { pending += 1; continue; }
const result = settleResult(side, actual, line);
if (!result) { pending += 1; continue; }
const outcome = {
player, stat, line, side: sideOver(side) ? 'O' : 'U',
grade: g.grade, actual, result, date: row.date,
gradedAt: gradedTs, settledAt: nowIso,
};
const key = outcomeKey(outcome);
if (seen.has(key)) continue; // idempotent — already settled
seen.add(key);
fresh.push({ ...outcome, key });
}
// Merge (newest first), cap.
const merged = [...fresh, ...prevLog].slice(0, LOG_CAP);
await deps.cacheSet(`outcomes:${sp}:log`, merged, LOG_TTL);
const accuracy = computeAccuracy(sp, merged, nowIso);
await deps.cacheSet(`accuracy:${sp}`, accuracy, ACC_TTL);
return { sport: sp, settled: fresh.length, pending, log: merged, accuracy };
}
// Aggregate a settled log into an accuracy record over the trailing window.
function computeAccuracy(sport, log, nowIso, windowDays = WINDOW_DAYS) {
const cutoff = new Date(nowIso).getTime() - windowDays * 24 * 3600 * 1000;
const inWindow = (log || []).filter((o) => {
const t = new Date(`${o.date}T12:00:00Z`).getTime();
return Number.isFinite(t) && t >= cutoff;
});
const bucketFor = (grade) => {
const b = gradeBucket(grade);
return TIERS.includes(b) ? b : null;
};
const blank = () => ({ hits: 0, misses: 0, pushes: 0, total: 0, pct: null });
const byGrade = {};
for (const t of TIERS) byGrade[t] = blank();
const overall = blank();
for (const o of inWindow) {
const tier = bucketFor(o.grade);
const targets = [overall];
if (tier) targets.push(byGrade[tier]);
for (const bucket of targets) {
if (o.result === 'hit') bucket.hits += 1;
else if (o.result === 'miss') bucket.misses += 1;
else if (o.result === 'push') bucket.pushes += 1;
}
}
const finalize = (b) => {
b.total = b.hits + b.misses + b.pushes;
const decided = b.hits + b.misses;
b.pct = decided > 0 ? Math.round((b.hits / decided) * 100) : null;
return b;
};
finalize(overall);
for (const t of TIERS) finalize(byGrade[t]);
return {
sport, updated_at: nowIso, window_days: windowDays,
sample: overall.total, min_sample: MIN_SAMPLE,
overall, byGrade,
};
}
function normalizeLog(raw) {
if (Array.isArray(raw)) return raw;
if (raw && Array.isArray(raw.log)) return raw.log;
return [];
}
async function defaultGetPlayerStats(name, sport) {
if (String(sport).toLowerCase() === 'mlb') {
return require('./adapters/mlbStatsAdapter').getPlayerStats(name);
}
// NBA/WNBA/soccer: no free settled-result feed here → pending.
return { found: false };
}
/**
* Recompute the cross-sport accuracy:overall record from every sport's log.
* Called after settling. Deps: cacheGet, cacheSet, now.
*/
async function recomputeOverall(opts = {}) {
const deps = {
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
now: opts.now || (() => new Date().toISOString()),
};
const nowIso = deps.now();
const logs = [];
for (const sp of SPORTS) {
const l = normalizeLog(await deps.cacheGet(`outcomes:${sp}:log`));
logs.push(...l);
}
const acc = computeAccuracy('overall', logs, nowIso);
await deps.cacheSet('accuracy:overall', acc, ACC_TTL);
return acc;
}
/** Settle every sport, then recompute the overall record. Cron entrypoint. */
async function settleAllOutcomes(opts = {}) {
const results = [];
for (const sp of SPORTS) {
try { results.push(await settleSnapshot(sp, opts)); }
catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); }
}
await recomputeOverall(opts);
return results;
}
/**
* Read the persisted accuracy record for the public endpoint. Cold-cache safe:
* returns an empty-but-valid shape. Deps: cacheGet.
*/
async function getAccuracy(opts = {}) {
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
const overall = await cacheGet('accuracy:overall');
const sports = {};
for (const sp of SPORTS) {
const a = await cacheGet(`accuracy:${sp}`);
if (a) sports[sp] = a;
}
return {
overall: overall || computeAccuracy('overall', [], new Date().toISOString()),
sports,
min_sample: MIN_SAMPLE,
updated_at: overall && overall.updated_at ? overall.updated_at : null,
};
}
/** Flatten an accuracy record into the ledger `buckets` shape. */
function accuracyBuckets(acc) {
if (!acc || !acc.byGrade) return [];
return TIERS
.map((tier) => {
const b = acc.byGrade[tier] || {};
return { grade: tier, hits: b.hits || 0, total: b.total || 0, pct: b.pct };
})
.filter((b) => b.total > 0);
}
module.exports = {
settleSnapshot,
settleAllOutcomes,
recomputeOverall,
getAccuracy,
computeAccuracy,
accuracyBuckets,
SPORTS,
MIN_SAMPLE,
__internals: { settleResult, gradeBucket, dateStrings, statValue, outcomeKey, MLB_LOG_FIELD, TIERS },
};
+5 -1
View File
@@ -31,7 +31,11 @@ const PRICE_UNCONFIGURED = '__unconfigured__';
// VYNDR is the canonical brand promo. BETONBLK stays in the default list so
// codes distributed before the rebrand keep redeeming during the transition.
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,VYNDR,BETONBLK,EARLYBIRD').split(',');
const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-06-30');
// Session 55 — founder pricing is still an active launch lever (the ClaimMeter
// scarcity meter + the $14.99/$34.99 founder tiers advertise it), so the default
// window runs through 2026. The 2026-06-30 default had silently lapsed (current
// date 2026-07-10), disabling every founder code. Operators override via env.
const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-12-31');
function isFounderCodeValid(code) {
if (!code) return false;
+11
View File
@@ -27,6 +27,10 @@ function startSnapshotScheduler(opts = {}) {
return null;
}
const runAll = opts.runAllSnapshots || require('./services/snapshotService').runAllSnapshots;
// Session 55 — self-learning loop. Settle the PRIOR snapshot's grades against
// real (now-completed) results BEFORE grading the fresh slate, so the accuracy
// record reflects yesterday's games each cycle.
const settleAll = opts.settleAllOutcomes || require('./services/outcomeService').settleAllOutcomes;
const now = opts.now || (() => new Date());
let lastFiredSlot = null;
@@ -38,6 +42,13 @@ function startSnapshotScheduler(opts = {}) {
const slot = `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${h}`;
if (slot === lastFiredSlot) return; // fire once per slot
lastFiredSlot = slot;
try {
const settled = await settleAll();
const totalSettled = settled.reduce((n, r) => n + (r.settled || 0), 0);
console.log(`[outcomes] cron fired ${h}:00 UTC — ${totalSettled} props settled vs real results`);
} catch (e) {
console.warn('[outcomes] settle run failed:', e.message);
}
try {
const results = await runAll();
const ok = results.filter((r) => r.status === 'ok');
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Session 55 — the self-learning loop's public read endpoints. Redis is mocked
// so these run offline; the store is seeded per-test via cacheGet.
const request = require('supertest');
let mockStore = {};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => ({}),
cacheGet: async (k) => (k in mockStore ? mockStore[k] : null),
cacheSet: async (k, v) => { mockStore[k] = v; return true; },
cacheDel: async () => true,
isDegraded: () => false,
}));
const app = require('../../src/app');
beforeEach(() => { mockStore = {}; });
describe('GET /api/accuracy', () => {
test('cold cache → valid empty-safe shape', async () => {
const res = await request(app).get('/api/accuracy');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('overall');
expect(res.body).toHaveProperty('sports');
expect(res.body.min_sample).toBeGreaterThan(0);
});
test('returns the persisted record when present', async () => {
mockStore['accuracy:overall'] = {
sport: 'overall', updated_at: '2026-07-10T00:00:00Z', window_days: 30, sample: 20,
overall: { hits: 14, misses: 6, pushes: 0, total: 20, pct: 70 },
byGrade: { 'A': { hits: 8, misses: 2, pushes: 0, total: 10, pct: 80 } },
};
mockStore['accuracy:mlb'] = mockStore['accuracy:overall'];
const res = await request(app).get('/api/accuracy');
expect(res.body.overall.overall.pct).toBe(70);
expect(res.body.sports.mlb).toBeTruthy();
});
});
describe('GET /api/ledger/accuracy', () => {
test('returns grade-tier buckets from the accuracy record', async () => {
mockStore['accuracy:overall'] = {
sport: 'overall', updated_at: '2026-07-10T00:00:00Z', window_days: 30, sample: 15,
overall: { hits: 10, misses: 5, pushes: 0, total: 15, pct: 67 },
byGrade: {
'A+': { hits: 3, misses: 0, pushes: 0, total: 3, pct: 100 },
'A': { hits: 5, misses: 2, pushes: 0, total: 7, pct: 71 },
'B': { hits: 2, misses: 3, pushes: 0, total: 5, pct: 40 },
},
};
const res = await request(app).get('/api/ledger/accuracy');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.buckets)).toBe(true);
const grades = res.body.buckets.map((b) => b.grade);
expect(grades).toContain('A+');
expect(grades).toContain('A');
});
test('cold cache → empty buckets, never 500', async () => {
const res = await request(app).get('/api/ledger/accuracy');
expect(res.status).toBe(200);
expect(res.body.buckets).toEqual([]);
});
});
+162
View File
@@ -0,0 +1,162 @@
'use strict';
const svc = require('../../src/services/outcomeService');
const { settleResult, gradeBucket, dateStrings, statValue, outcomeKey } = svc.__internals;
// A tiny in-memory Redis so settleSnapshot round-trips through cacheGet/cacheSet.
function memCache(seed = {}) {
const store = { ...seed };
return {
store,
cacheGet: async (k) => (k in store ? store[k] : null),
cacheSet: async (k, v) => { store[k] = v; return true; },
};
}
const ISO = '2026-07-10T02:00:00.000Z'; // ~10pm ET Jul 9 — exercises the ET rollover
function snapshot(grades) {
return { sport: 'mlb', updated_at: ISO, grades };
}
function grade(over = {}) {
return {
player: over.player || 'Aaron Judge',
stat_type: over.stat || 'hits',
line: over.line != null ? over.line : 1.5,
direction: over.side || 'over',
grade: over.grade || 'A',
gradedAt: { line: over.line != null ? over.line : 1.5, odds: -115, timestamp: ISO },
};
}
// Game-log rows keyed to the ET or UTC date of ISO.
function log(date, stat) { return [{ date, opponent: 'BOS', stat }]; }
describe('outcomeService — settlement math', () => {
test('over: actual above line = hit, below = miss, equal = push', () => {
expect(settleResult('over', 2, 1.5)).toBe('hit');
expect(settleResult('over', 1, 1.5)).toBe('miss');
expect(settleResult('over', 2, 2)).toBe('push');
});
test('under: actual below line = hit, above = miss', () => {
expect(settleResult('under', 1, 1.5)).toBe('hit');
expect(settleResult('under', 2, 1.5)).toBe('miss');
expect(settleResult('U', 3, 3)).toBe('push');
});
test('non-numeric actual/line → null (unsettleable)', () => {
expect(settleResult('over', null, 1.5)).toBeNull();
expect(settleResult('over', 2, undefined)).toBeNull();
});
test('gradeBucket tiers: A+ stands alone, letters collapse', () => {
expect(gradeBucket('A+')).toBe('A+');
expect(gradeBucket('A-')).toBe('A');
expect(gradeBucket('B+')).toBe('B');
expect(gradeBucket('C')).toBe('C');
expect(gradeBucket('')).toBeNull();
});
test('statValue maps VYNDR stat_type → game-log field', () => {
expect(statValue({ totalBases: 3 }, 'total_bases')).toBe(3);
expect(statValue({ homeRuns: 1 }, 'home_runs')).toBe(1);
expect(statValue({ strikeOuts: 7 }, 'strikeouts')).toBe(7);
expect(statValue({ hits: 2 }, 'unknown_stat')).toBeNull();
});
test('dateStrings yields both UTC and ET calendar dates', () => {
const ds = dateStrings(ISO);
expect(ds).toContain('2026-07-10'); // UTC
expect(ds).toContain('2026-07-09'); // ET (10pm prior day)
});
});
describe('outcomeService — settleSnapshot', () => {
const judgeStats = async (name) => {
if (name === 'Aaron Judge') return { found: true, last10: log('2026-07-09', { hits: 2, totalBases: 4 }) };
return { found: false };
};
test('settles a hit against the real game log', async () => {
const cache = memCache({ 'snapshot:mlb:latest': snapshot([grade({ stat: 'hits', line: 1.5, side: 'over', grade: 'A' })]) });
const res = await svc.settleSnapshot('mlb', { ...cache, getPlayerStats: judgeStats, now: () => '2026-07-10T15:00:00Z' });
expect(res.settled).toBe(1);
expect(res.log[0]).toMatchObject({ result: 'hit', actual: 2, grade: 'A', side: 'O' });
expect(cache.store['accuracy:mlb'].byGrade['A'].hits).toBe(1);
expect(cache.store['accuracy:mlb'].overall.pct).toBe(100);
});
test('settles a miss (under, actual above line)', async () => {
const cache = memCache({ 'snapshot:mlb:latest': snapshot([grade({ stat: 'hits', line: 1.5, side: 'under', grade: 'B+' })]) });
const res = await svc.settleSnapshot('mlb', { ...cache, getPlayerStats: judgeStats, now: () => '2026-07-10T15:00:00Z' });
expect(res.log[0].result).toBe('miss');
expect(cache.store['accuracy:mlb'].byGrade['B'].misses).toBe(1);
});
test('is idempotent — a second run does not double-count', async () => {
const cache = memCache({ 'snapshot:mlb:latest': snapshot([grade({ grade: 'A' })]) });
const deps = { ...cache, getPlayerStats: judgeStats, now: () => '2026-07-10T15:00:00Z' };
await svc.settleSnapshot('mlb', deps);
const res2 = await svc.settleSnapshot('mlb', deps);
expect(res2.settled).toBe(0);
expect(res2.log.length).toBe(1);
expect(cache.store['accuracy:mlb'].overall.total).toBe(1);
});
test('a prop with no matching game stays pending', async () => {
const noGame = async () => ({ found: true, last10: log('2026-01-01', { hits: 0 }) });
const cache = memCache({ 'snapshot:mlb:latest': snapshot([grade()]) });
const res = await svc.settleSnapshot('mlb', { ...cache, getPlayerStats: noGame, now: () => '2026-07-10T15:00:00Z' });
expect(res.settled).toBe(0);
expect(res.pending).toBe(1);
});
test('offline stats (found:false) → pending, never throws', async () => {
const cache = memCache({ 'snapshot:nba:latest': snapshot([grade()]) });
const res = await svc.settleSnapshot('nba', { ...cache, getPlayerStats: async () => ({ found: false }), now: () => '2026-07-10T15:00:00Z' });
expect(res.settled).toBe(0);
expect(res.pending).toBe(1);
});
});
describe('outcomeService — accuracy aggregation', () => {
test('pct excludes pushes and gates on the 30-day window', () => {
const nowIso = '2026-07-10T00:00:00Z';
const mk = (grade, result, date) => ({ grade, result, date, player: 'X', stat: 'hits', line: 1.5, side: 'O' });
const logRows = [
mk('A', 'hit', '2026-07-09'),
mk('A', 'hit', '2026-07-08'),
mk('A', 'miss', '2026-07-07'),
mk('A', 'push', '2026-07-06'),
mk('A', 'hit', '2026-01-01'), // outside 30d — excluded
];
const acc = svc.computeAccuracy('mlb', logRows, nowIso);
expect(acc.byGrade['A'].hits).toBe(2);
expect(acc.byGrade['A'].misses).toBe(1);
expect(acc.byGrade['A'].pushes).toBe(1);
expect(acc.byGrade['A'].pct).toBe(67); // 2/(2+1) rounded
expect(acc.sample).toBe(4); // 4 in-window, push counts toward sample
});
test('accuracyBuckets flattens only non-empty tiers', () => {
const acc = svc.computeAccuracy('mlb', [
{ grade: 'A', result: 'hit', date: '2026-07-09' },
{ grade: 'B', result: 'miss', date: '2026-07-09' },
], '2026-07-10T00:00:00Z');
const buckets = svc.accuracyBuckets(acc);
expect(buckets.map((b) => b.grade).sort()).toEqual(['A', 'B']);
});
test('getAccuracy is cold-cache safe', async () => {
const cache = memCache();
const out = await svc.getAccuracy(cache);
expect(out.overall.overall.total).toBe(0);
expect(out.sports).toEqual({});
});
test('recomputeOverall merges every sport log', async () => {
const cache = memCache({
'outcomes:mlb:log': [{ grade: 'A', result: 'hit', date: '2026-07-09' }],
'outcomes:nba:log': [{ grade: 'A', result: 'miss', date: '2026-07-09' }],
});
const acc = await svc.recomputeOverall({ ...cache, now: () => '2026-07-10T00:00:00Z' });
expect(acc.overall.total).toBe(2);
expect(acc.overall.pct).toBe(50);
expect(cache.store['accuracy:overall']).toBeTruthy();
});
});
+24
View File
@@ -33,6 +33,30 @@ describe('groupPropsByPlayer', () => {
});
});
describe('buildPlayerStripsFromProps — settled outcome passthrough (Session 55)', () => {
it('attaches the settled outcome from the snapshot grade onto the prop', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A',
gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' },
outcome: { result: 'hit', actual: 2 } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips[0].props[0].outcome).toEqual({ result: 'hit', actual: 2 });
expect(strips[0].props[0].grade).toBe('A');
});
it('leaves outcome null when the grade has not settled', () => {
const props = [{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A',
gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips[0].props[0].outcome).toBeNull();
});
});
describe('mapPitchers', () => {
it('maps MLB probable pitchers to the GameCard shape', () => {
const p = adapter.mapPitchers({
+1 -1
View File
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Accuracy proxy (Session 55) forwards GET /api/accuracy to Express (the
* self-learning loop's rolling track record). Thin pass-through; the dashboard
* header + grade card read this. Empty-safe on any upstream failure.
*/
export async function GET() {
try {
const upstream = await fetch(`${BACKEND_URL}/api/accuracy`, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ overall: null, sports: {} }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ overall: null, sports: {}, min_sample: 8, updated_at: null }, { status: 200 });
}
}
+7
View File
@@ -9,6 +9,8 @@ import { GradePill } from '@/components/GradeCard';
// existing dashboard sections (Most Parlayed, Recent Reads) stay
// below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate';
// Session 55 — the self-learning loop's track record, live in the header.
import { AccuracyBadge } from '@/components/vyndr';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -205,6 +207,10 @@ export default function DashboardPage() {
{new Date().toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }).toUpperCase()}
</p>
</div>
{/* Session 55 the system's live track record. Self-hides while it has
no data; reads "LEARNING" until the settled sample is honest. */}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<AccuracyBadge variant="chip" />
{tier === 'free' && scansRemaining != null && (
<div
className="mono"
@@ -220,6 +226,7 @@ export default function DashboardPage() {
{scansRemaining}/5 READS · MO
</div>
)}
</div>
</header>
{/* Session 13 Browse-first slate. Owns its own sport-tab UI,
+5
View File
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero';
import { ClaimMeter } from '@/components/vyndr';
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
// with the self-learning loop's accuracy line. The product shown, not described.
import TopSignals from '@/components/TopSignals';
// Session 17 — game-count strip mounted between the hero and the
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
// being graded right now" with a signup CTA. Hides itself when
@@ -45,6 +48,8 @@ export default function Home() {
return (
<>
<Hero />
{/* Session 55 — tonight's real top signals + live accuracy (the system works). */}
<TopSignals />
{/* Founder-seat scarcity meter (§12) */}
<div style={{ padding: '0 16px 8px' }}>
<ClaimMeter />
+7
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
import { AccuracyBadge } from '@/components/vyndr';
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
import { normalizeName, nameKey } from '@/lib/playerName';
@@ -736,6 +737,12 @@ export default function ScanPage() {
onReadAnother={reset}
/>
{/* Session 55 the self-learning loop's track record for this sport.
"The system learns" real hit rate on graded props, misses shown. */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<AccuracyBadge variant="chip" sport={sport?.toLowerCase()} />
</div>
{/* Sportsbook hand-off (preserved feature) */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
{SPORTSBOOKS.map((b) => (
+65 -3
View File
@@ -211,6 +211,16 @@ interface GameLinesResponse { games?: Record<string, GameLines> }
// Nickname token (last word) — the most stable cross-source identifier
// between ESPN full names and odds-api full names ("San Antonio Spurs"
// ↔ "spurs"). Falls back to the whole normalized string.
// Session 55 — relative freshness label ("updated 12s ago" → "3m ago").
function freshLabel(ts: number | null, now: number): string {
if (!ts) return '';
const s = Math.max(0, Math.round((now - ts) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
return `${Math.round(m / 60)}h ago`;
}
function nickToken(name?: string | null): string {
const w = String(name || '').trim().split(/\s+/);
const last = w[w.length - 1] || '';
@@ -365,6 +375,10 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
// Session 55 — real-time freshness: when the slate last pulled fresh data,
// and a ticking clock so "updated Xs ago" advances between polls.
const [lastRefreshed, setLastRefreshed] = useState<number | null>(null);
const [nowTick, setNowTick] = useState<number>(() => Date.now());
// Session 26 — per-sport schedule counts for the tab labels, fetched
// ONCE on mount for every schedule-backed sport (free ESPN, cached 60s).
// This makes "MLB (15)" / "WNBA (2)" show on their tabs even while the
@@ -383,10 +397,14 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
// Schedule is the foundation — games render even when odds are
// empty/503. Odds + lines overlay on top. The slate is never empty
// just because one provider is down.
const fetchSlate = useCallback(async (active: SlateTab) => {
// Session 55 — real-time layer. `silent` background refreshes keep the slate
// alive (polling) without the skeleton flash or clearing the current view.
const fetchSlate = useCallback(async (active: SlateTab, silent = false) => {
if (!silent) {
setLoading(true);
setFetchError(null);
setOddsNotice(false);
}
// Sports that carry a schedule/streaks feed (ESPN-backed). Soccer
// has no schedule endpoint, so it stays odds-only.
@@ -457,15 +475,23 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
if (s.hadSchedule) anyScheduleShown = true;
}
// A silent background poll that came back empty (transient blip) must NOT
// wipe the current view or flash an error — keep what the user is seeing.
if (silent && allGames.length === 0) {
setLoading(false);
return;
}
setGames(allGames);
setSnapGrades(allSnapGrades);
setSnapDeltas(allSnapDeltas);
setPitcherGames(allPitcherGames);
setLastRefreshed(Date.now());
// Odds down but schedule carried the slate → soft notice, not a wall.
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
if (!silent && !anyOddsOk && anyScheduleShown) setOddsNotice(true);
// Genuine total failure (no odds, no schedule, anywhere) → error.
if (!anyOddsOk && !anyScheduleShown && allGames.length === 0) {
if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) {
setFetchError('No games available right now. Check back soon.');
}
setLoading(false);
@@ -473,6 +499,19 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
// Session 55 — auto-refresh: poll the slate every 60s so fresh snapshot grades
// + schedule/score updates appear without a page reload. Silent (no skeleton).
useEffect(() => {
const id = setInterval(() => { fetchSlate(tab, true); }, 60_000);
return () => clearInterval(id);
}, [tab, fetchSlate]);
// A 15s ticking clock so the "updated Xs ago" freshness label stays honest.
useEffect(() => {
const id = setInterval(() => setNowTick(Date.now()), 15_000);
return () => clearInterval(id);
}, []);
// Session 24 — switching sport resets the stat filter. The categories
// differ per sport (Points vs Hits), so a stale "points" filter would
// silently blank the MLB panels. Always land back on 'all'.
@@ -574,6 +613,29 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
paddingBottom: 12,
}}
>
{/* Session 55 the live signal strip: proves the data is alive. A
pulsing dot, the graded-prop count, any in-progress games, and a
ticking "updated Xs ago" freshness stamp fed by the 60s poll. */}
<div
className="mono"
style={{
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-secondary, #8A8A9A)',
marginBottom: 10,
}}
>
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>SIGNAL LIVE</span>
{snapGrades.length > 0 && (
<><span style={{ color: '#3A3A48' }}>·</span><span>{snapGrades.length} PROPS GRADED</span></>
)}
{games.some((g) => g.status === 'in') && (
<><span style={{ color: '#3A3A48' }}>·</span><span style={{ color: 'var(--live, #FF4757)', fontWeight: 700 }}>{games.filter((g) => g.status === 'in').length} LIVE</span></>
)}
{lastRefreshed && (
<><span style={{ color: '#3A3A48' }}>·</span><span title="The slate auto-refreshes every 60 seconds">UPDATED {freshLabel(lastRefreshed, nowTick)}</span></>
)}
</div>
<input
type="search"
value={searchQuery}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useEffect, useState } from 'react';
import { GradeBadge, ArchetypeBadge, AccuracyBadge } from '@/components/vyndr';
/**
* TopSignals (Session 55) the landing hero's live intelligence preview.
*
* Pulls the top A-rated grades from tonight's REAL snapshot (not a mockup) and
* shows them as mini grade cards, with the self-learning loop's live accuracy
* line beneath. The product selling itself by working. Self-hides off-hours
* (no A-rated grades) so the landing never shows an empty shell.
*/
interface SnapGrade {
player?: string;
player_name?: string;
stat_type?: string;
stat?: string;
line?: number;
direction?: string;
grade?: string;
confidence?: number;
archetype?: string | null;
}
const SPORTS = ['mlb', 'nba', 'wnba'] as const;
const STAT_SHORT: Record<string, string> = {
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',
};
function shortStat(s?: string) {
if (!s) return '';
return STAT_SHORT[s] || s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
const isTop = (g?: string) => g === 'A+' || g === 'A';
export default function TopSignals() {
const [signals, setSignals] = useState<SnapGrade[] | null>(null);
useEffect(() => {
let active = true;
const load = async () => {
try {
const results = await Promise.all(
SPORTS.map((sp) =>
fetch(`/api/snapshot/${sp}`, { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
),
);
if (!active) return;
const all: SnapGrade[] = [];
for (const res of results) {
const grades = res && Array.isArray(res.grades) ? res.grades : [];
for (const g of grades) if (isTop(g.grade)) all.push(g);
}
all.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
setSignals(all.slice(0, 3));
} catch {
if (active) setSignals([]);
}
};
load();
const id = setInterval(load, 60_000);
return () => { active = false; clearInterval(id); };
}, []);
// Self-hide off-hours (nothing graded A yet) — never an empty shell.
if (!signals || signals.length === 0) return null;
return (
<section style={{ maxWidth: 960, margin: '0 auto', padding: '8px 16px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, letterSpacing: '0.1em', color: 'var(--text-secondary, #8A8A9A)' }}>
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>TONIGHT&apos;S TOP SIGNALS</span>
<span style={{ color: 'var(--text-tertiary, #707080)' }}>· LIVE FROM THE SLATE</span>
</div>
<AccuracyBadge variant="inline" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
{signals.map((g, i) => {
const player = g.player || g.player_name || '';
const side = String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O';
return (
<a
key={`${player}-${i}`}
href="/signup"
className="mono"
style={{
display: 'block', textDecoration: 'none', color: 'inherit',
padding: 14, borderRadius: 12,
background: 'var(--bg-surface, #12121A)',
border: '1px solid var(--border, #1A1A24)',
borderLeft: '3px solid var(--grade-a, #00D4A0)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
{g.archetype ? <ArchetypeBadge archetype={g.archetype} size="sm" variant="full" /> : <span style={{ fontSize: 10, color: 'var(--text-tertiary)' }} />}
{g.grade && <GradeBadge grade={g.grade} size="sm" />}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: '#fff', fontFamily: 'var(--sans, sans-serif)', marginBottom: 4 }}>{player}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary, #B8BCC8)' }}>
{shortStat(g.stat_type || g.stat)} {side}{g.line}
</div>
</a>
);
})}
</div>
</section>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useEffect, useState } from 'react';
/**
* AccuracyBadge (Session 55) the self-learning loop made visible.
*
* Reads the rolling accuracy record (`/api/accuracy`, written by outcomeService
* from settled grades vs REAL results) and renders the system's track record.
* This is the #1 trust builder: a system that shows its hit rate, including its
* misses. Honest by construction below MIN_SAMPLE it reads "LEARNING" rather
* than faking a number. All data (%) is mono per the brand rule; never glitches.
*/
interface Bucket { hits: number; misses: number; pushes: number; total: number; pct: number | null }
interface AccuracyRecord {
window_days?: number;
overall?: Bucket;
byGrade?: Record<string, Bucket>;
}
interface AccuracyResponse {
overall?: AccuracyRecord | null;
min_sample?: number;
}
const BLANK: Bucket = { hits: 0, misses: 0, pushes: 0, total: 0, pct: null };
function merge(a?: Bucket, b?: Bucket): Bucket {
const x = a || BLANK, y = b || BLANK;
const hits = x.hits + y.hits, misses = x.misses + y.misses, pushes = x.pushes + y.pushes;
const decided = hits + misses;
return { hits, misses, pushes, total: hits + misses + pushes, pct: decided > 0 ? Math.round((hits / decided) * 100) : null };
}
type Variant = 'inline' | 'chip';
export default function AccuracyBadge({ variant = 'chip', sport }: { variant?: Variant; sport?: string }) {
const [rec, setRec] = useState<AccuracyRecord | null>(null);
const [minSample, setMinSample] = useState(8);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let active = true;
fetch('/api/accuracy', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((data: AccuracyResponse | null) => {
if (!active) return;
setMinSample(data?.min_sample ?? 8);
// Prefer a specific sport's record when asked; else the overall.
const sportRec = sport && data && (data as unknown as { sports?: Record<string, AccuracyRecord> }).sports?.[sport];
setRec((sportRec as AccuracyRecord) || data?.overall || null);
setLoaded(true);
})
.catch(() => { if (active) setLoaded(true); });
return () => { active = false; };
}, [sport]);
if (!loaded || !rec) return null;
const aRated = merge(rec.byGrade?.['A+'], rec.byGrade?.['A']);
const overall = rec.overall || BLANK;
const window = rec.window_days || 30;
// Honest states: A-rated record → overall record → "LEARNING".
let label: string;
let value: string;
let color: string;
let calibrated = true;
if (aRated.hits + aRated.misses >= minSample && aRated.pct != null) {
label = 'A-RATED'; value = `${aRated.pct}% HIT`; color = 'var(--g-a, #00D4A0)';
} else if (overall.hits + overall.misses >= minSample && overall.pct != null) {
label = 'MODEL'; value = `${overall.pct}% HIT`; color = 'var(--g-a, #00D4A0)';
} else {
label = 'MODEL'; value = 'LEARNING'; color = 'var(--amber, #FFB347)'; calibrated = false;
}
const title = calibrated
? `VYNDR's ${label === 'A-RATED' ? 'A-rated props' : 'graded props'} over the last ${window} days — including misses. The system settles every grade against real results.`
: 'The self-learning loop is still collecting settled results. Hit rate appears once the sample is large enough to be honest.';
if (variant === 'inline') {
return (
<span className="mono" title={title} style={{ fontSize: 11, letterSpacing: '0.06em', color }}>
{label} · {value} {calibrated ? `· ${window}D` : ''}
</span>
);
}
return (
<div
className="mono"
title={title}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 999,
background: 'var(--bg-surface, #12121A)',
border: `1px solid ${color}`,
fontSize: 12, color,
letterSpacing: '0.06em', whiteSpace: 'nowrap',
}}
>
<span
aria-hidden
style={{
width: 7, height: 7, borderRadius: '50%', background: color,
boxShadow: `0 0 6px ${color}`,
animation: calibrated ? undefined : 'none',
}}
/>
<span style={{ fontWeight: 700 }}>{label}</span>
<span style={{ color: 'var(--text-1, #B8BCC8)' }}>·</span>
<span>{value}</span>
{calibrated && <><span style={{ color: 'var(--text-2, #707080)' }}>·</span><span style={{ color: 'var(--text-2, #707080)' }}>{window}D</span></>}
</div>
);
}
+23 -2
View File
@@ -14,6 +14,8 @@ export interface StripProp {
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"
// Session 55 — settled outcome from the self-learning loop (once the game is final).
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
}
export interface StripArchetype {
primary: string;
@@ -82,6 +84,24 @@ export default function StatStrip({
</button>
);
};
// Session 55 — settled outcome chip. The self-learning loop's transparency
// moment: show HIT and MISS, with the real stat. Data → mono, never glitches.
const OutcomeChip = ({ p }: { p: StripProp }) => {
if (!p.outcome || !p.outcome.result) return null;
const r = String(p.outcome.result).toLowerCase();
const map: Record<string, { label: string; color: string; bg: string }> = {
hit: { label: '✓ HIT', color: 'var(--hit, #00D4A0)', bg: 'color-mix(in srgb, var(--g-a, #00D4A0) 16%, transparent)' },
miss: { label: '✕ MISS', color: 'var(--miss, #FF4757)', bg: 'color-mix(in srgb, #FF4757 16%, transparent)' },
push: { label: 'PUSH', color: 'var(--text-1, #B8BCC8)', bg: 'var(--bg-2, #12121A)' },
};
const s = map[r] || map.push;
const actual = p.outcome.actual != null ? ` (${p.outcome.actual})` : '';
return (
<span className="mono" title="Settled against the real result" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.04em', color: s.color, background: s.bg, padding: '2px 6px', borderRadius: 4 }}>
{s.label}{actual}
</span>
);
};
// Session 52 — Push-to-Book teaser on graded props (feature not live yet).
const BookItTeaser = ({ p }: { p: StripProp }) => {
if (!p.grade) return null;
@@ -209,8 +229,9 @@ export default function StatStrip({
<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" />}
<ParlayBtn p={p} />
<BookItTeaser p={p} />
<OutcomeChip p={p} />
{!p.outcome && <ParlayBtn p={p} />}
{!p.outcome && <BookItTeaser p={p} />}
{p.gradedAt?.ago && (
<span style={{ color: 'var(--text-2)' }}>
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
+27 -1
View File
@@ -35,16 +35,25 @@ const TAG_COLORS: Record<string, string> = {
*/
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
const [feed, setFeed] = useState<TickerItem[] | null>(null);
// Session 55 — flash the LIVE dot when a fresh event slides in (breaking-news feel).
const [flash, setFlash] = useState(false);
useEffect(() => {
if (!live) return;
let active = true;
let lastHead = '';
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) {
const head = `${data.items[0]?.tag}|${data.items[0]?.text}`;
if (lastHead && head !== lastHead) {
setFlash(true);
setTimeout(() => { if (active) setFlash(false); }, 2500);
}
lastHead = head;
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
}
} catch {
@@ -89,7 +98,24 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
{content}
{content}
</div>
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
{/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */}
{live && (
<div
className="mono"
style={{
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 3,
display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
color: flash ? 'var(--g-ap, #00ffb8)' : 'var(--g-a, #00D4A0)',
transition: 'color 0.3s ease',
}}
>
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', display: 'inline-block', boxShadow: flash ? '0 0 8px currentColor' : 'none' }} />
LIVE
</div>
)}
<div style={{ position: 'absolute', left: live ? 66 : 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
</div>
);
+1
View File
@@ -14,6 +14,7 @@ export { default as ProcessingGrade } from './ProcessingGrade';
export { default as GameCard } from './GameCard';
export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } from './GameCard';
export { default as ClaimMeter } from './ClaimMeter';
export { default as AccuracyBadge } from './AccuracyBadge';
/* Player Intelligence (Session 42) */
export { default as ArchetypeBadge } from './ArchetypeBadge';
+3
View File
@@ -284,6 +284,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
: null,
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
// Session 55 — settled outcome from the self-learning loop (hit/miss/push
// + actual stat) once the game completes. null until settled.
outcome: rec.outcome ? { result: rec.outcome.result, actual: rec.outcome.actual } : null,
});
} else {
byPlayer[pk].props.push({