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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user