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
+68 -6
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) => {
setLoading(true);
setFetchError(null);
setOddsNotice(false);
// 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';