1b4f2772d6
6.1 FIRST-PAINT ROOT CAUSE: the landing blocked its ENTIRE render on
Supabase auth init ('loading || user') — anonymous visitors stared at
'LOADING THE SLATE' for the whole auth roundtrip (~3-4s). Now a
synchronous localStorage session check gates the suppression: only
visitors who actually hold a session (and will redirect) wait;
anonymous traffic paints the hero immediately. Full RSC conversion of
the hero is deferred and logged — the blocker itself is dead.
Proof Strip rules: top-3 by grade whatever they are; 'TONIGHT'S TOP
SIGNALS' only with >=1 A-tier, else 'TONIGHT'S BOARD'; nothing graded
yet → yesterday's SETTLED reads with outcome chips (misses included).
6.2 Content routes: /api/content/top-signals/:sport,
/streak-watch/:sport (the zero-grade daily format off the aggregator),
/daily-report/:sport — built but self-flagging do_not_post until the
record clears n>=20. Flag, don't fake.
6.3 Per-player OG images: app/player/[name]/opengraph-image.tsx (Node
runtime per the S53 rule) + server layout generateMetadata — every
shared player link unfurls as an intelligence card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
6.4 KiB
TypeScript
144 lines
6.4 KiB
TypeScript
'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;
|
||
// Session 60 (6.1) — settled outcome (yesterday's-proof fallback rows).
|
||
outcome?: { result: string; actual?: number | null } | 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);
|
||
const [header, setHeader] = useState<'signals' | 'board' | 'proof'>('board');
|
||
|
||
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[] = [];
|
||
const settled: SnapGrade[] = [];
|
||
for (const res of results) {
|
||
const grades = res && Array.isArray(res.grades) ? res.grades : [];
|
||
for (const g of grades) {
|
||
if (g.outcome) settled.push(g);
|
||
else if (g.grade) all.push(g);
|
||
}
|
||
}
|
||
all.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
|
||
// Session 60 (6.1) — never oversell: top 3 by grade whatever they
|
||
// are, but the TOP SIGNALS header only with ≥1 A-tier; otherwise
|
||
// "TONIGHT'S BOARD". Nothing graded yet → yesterday's settled reads
|
||
// WITH outcome chips (the proof strip carries the slate).
|
||
if (all.length > 0) {
|
||
setSignals(all.slice(0, 3));
|
||
setHeader(all.some((g) => isTop(g.grade)) ? 'signals' : 'board');
|
||
} else if (settled.length > 0) {
|
||
setSignals(settled.slice(0, 3));
|
||
setHeader('proof');
|
||
} else {
|
||
setSignals([]);
|
||
}
|
||
} catch {
|
||
if (active) setSignals([]);
|
||
}
|
||
};
|
||
load();
|
||
const id = setInterval(load, 60_000);
|
||
return () => { active = false; clearInterval(id); };
|
||
}, []);
|
||
|
||
// Self-hide only when there's NOTHING real (no grades and no settles).
|
||
if (!signals || signals.length === 0) return null;
|
||
const headerText = header === 'signals' ? "TONIGHT'S TOP SIGNALS"
|
||
: header === 'proof' ? "YESTERDAY'S SETTLED READS" : "TONIGHT'S BOARD";
|
||
const headerSub = header === 'proof' ? '· MISSES INCLUDED' : '· LIVE FROM THE SLATE';
|
||
|
||
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 }}>{headerText}</span>
|
||
<span style={{ color: 'var(--text-tertiary, #707080)' }}>{headerSub}</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}
|
||
{/* Outcome chip on the proof-strip fallback — misses included. */}
|
||
{g.outcome && (
|
||
<span style={{ marginLeft: 8, fontWeight: 700, color: g.outcome.result === 'hit' ? 'var(--grade-a, #00D4A0)' : g.outcome.result === 'miss' ? 'var(--miss, #FF5252)' : 'var(--text-tertiary)' }}>
|
||
{g.outcome.result === 'hit' ? '✓ HIT' : g.outcome.result === 'miss' ? '✕ MISS' : '– PUSH'}
|
||
{g.outcome.actual != null ? ` (${g.outcome.actual})` : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|