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