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}