Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+348
View File
@@ -0,0 +1,348 @@
'use client';
import { useEffect, useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext';
import GradeCard, { GradePill } from '@/components/GradeCard';
type Sport = 'NBA' | 'MLB' | 'WNBA';
interface Player {
name: string;
position?: string;
injury_status?: 'OUT' | 'DOUBTFUL' | 'QUESTIONABLE' | null;
}
interface GameDetail {
id: string;
sport: Sport;
away: string;
home: string;
start_time: string;
spread?: number;
spread_favorite?: 'home' | 'away';
total?: number;
moneyline_home?: number;
moneyline_away?: number;
away_lineup?: Player[];
home_lineup?: Player[];
pace?: number;
pace_rank?: number;
matchup_notes?: string[];
}
interface PropEntry {
id: string;
player: string;
stat: string;
line: number;
direction: 'over' | 'under';
grade: string;
projection?: number;
confidence?: number;
sample_size?: number;
factors?: Record<string, string>;
kill_conditions?: { code: string; reason: string }[];
reasoning?: string;
historical_hit_rate?: number;
alt_lines?: { line: number; grade: string; hit_rate?: number }[];
}
const SPORT_COLOR: Record<Sport, string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
WNBA: '#FFB347',
};
export default function GamePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const { user, tier, loading: authLoading } = useAuth();
const { addLeg, open } = useParlay();
const [game, setGame] = useState<GameDetail | null>(null);
const [props, setProps] = useState<PropEntry[] | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const [error, setError] = useState('');
useEffect(() => {
if (!authLoading && !user) router.replace(`/login?next=/game/${id}`);
}, [authLoading, user, id, router]);
useEffect(() => {
let cancelled = false;
setError('');
Promise.all([
fetch(`/api/games/${id}`).then((r) => r.json()).catch(() => null),
fetch(`/api/games/${id}/props`).then((r) => r.json()).catch(() => ({ props: [] })),
]).then(([g, p]) => {
if (cancelled) return;
if (!g || g.error) {
setError(g?.error || 'Game not found.');
return;
}
setGame(g);
setProps(Array.isArray(p?.props) ? p.props : []);
});
return () => {
cancelled = true;
};
}, [id]);
if (authLoading || !user || (!game && !error)) {
return (
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading game</p>
</section>
);
}
if (error) {
return (
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
That matchup isn&apos;t on tonight&apos;s slate. Maybe the line moved off the board.
</p>
<a href="/dashboard" className="btn-primary">Back to slate</a>
</section>
);
}
if (!game) return null;
return (
<section style={{ maxWidth: 900, margin: '0 auto', padding: '24px 16px 120px' }}>
{/* Back nav */}
<button
onClick={() => router.push('/dashboard')}
className="btn-ghost"
style={{ padding: '6px 12px', fontSize: 12, marginBottom: 16 }}
>
Slate
</button>
{/* Game header */}
<header
className="surface diagonal-cut-strong animate-fade-up"
style={{
padding: 24,
marginBottom: 16,
borderColor: SPORT_COLOR[game.sport],
background: 'var(--bg-elevated)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span
className="mono"
style={{
fontSize: 11,
fontWeight: 700,
padding: '2px 10px',
borderRadius: 999,
background: `${SPORT_COLOR[game.sport]}1F`,
color: SPORT_COLOR[game.sport],
letterSpacing: '0.08em',
}}
>
{game.sport}
</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{formatTime(game.start_time)}
</span>
</div>
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 12 }}>
{game.away} <span style={{ color: 'var(--text-tertiary)' }}>@</span> {game.home}
</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
{game.spread != null && (
<Stat label="Spread" value={`${game.spread_favorite === 'home' ? game.home : game.away} ${game.spread}`} />
)}
{game.total != null && <Stat label="Total" value={game.total.toString()} />}
{game.moneyline_home != null && (
<Stat label="Moneyline" value={`${game.home} ${formatOdds(game.moneyline_home)} / ${game.away} ${formatOdds(game.moneyline_away ?? 0)}`} />
)}
</div>
</header>
{/* Starting lineups */}
{(game.away_lineup?.length || game.home_lineup?.length) && (
<Card title="Starting lineups">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Lineup teamName={game.away} players={game.away_lineup ?? []} />
<Lineup teamName={game.home} players={game.home_lineup ?? []} />
</div>
</Card>
)}
{/* Matchup stats */}
{game.matchup_notes && game.matchup_notes.length > 0 && (
<Card title="Key matchup stats">
<ul style={{ display: 'grid', gap: 8 }}>
{game.matchup_notes.map((note, i) => (
<li key={i} style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
· {note}
</li>
))}
</ul>
</Card>
)}
{/* Props list */}
<Card title={`All props for this game`} subtitle={`${props?.length ?? 0} graded`}>
{props === null ? (
<p style={{ color: 'var(--text-tertiary)' }}>Loading props</p>
) : props.length === 0 ? (
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
No props posted for this game yet. Books usually open player props 23 hours before tip. Check back closer to game time.
</p>
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{props.map((p) => {
const isOpen = expanded === p.id;
return (
<li key={p.id} className="surface" style={{ overflow: 'hidden' }}>
<div
style={{
padding: 14,
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: 12,
alignItems: 'center',
cursor: 'pointer',
}}
onClick={() => setExpanded(isOpen ? null : p.id)}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
</div>
</div>
<GradePill grade={p.grade} />
<button
onClick={(e) => {
e.stopPropagation();
addLeg({
sport: game.sport,
player: p.player,
stat: p.stat,
line: p.line,
direction: p.direction,
grade: p.grade,
confidence: p.confidence ?? 55,
});
open();
}}
className="btn-ghost"
style={{ padding: '6px 12px', fontSize: 11 }}
aria-label="Add to parlay"
>
+
</button>
</div>
{isOpen && (
<div style={{ padding: 16, borderTop: '1px solid var(--border)' }}>
<GradeCard
sport={game.sport}
player={p.player}
stat={p.stat}
line={p.line}
direction={p.direction}
grade={p.grade}
projection={p.projection}
confidence={p.confidence}
sample_size={p.sample_size}
factors={p.factors}
alt_lines={p.alt_lines}
kill_conditions={p.kill_conditions}
reasoning={p.reasoning}
historical_hit_rate={p.historical_hit_rate}
tier={tier}
onUpgradeClick={(target) => router.push(`/api/checkout?tier=${target}`)}
/>
</div>
)}
</li>
);
})}
</ul>
)}
</Card>
<div style={{ marginTop: 24 }}>
<a
href={`/scan?sport=${game.sport}`}
className="btn-primary"
style={{ padding: '12px 24px' }}
>
Read a custom prop
</a>
</div>
</section>
);
}
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 20, marginBottom: 16 }}>
<header style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h2 style={{ fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
{subtitle && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{subtitle}</span>
)}
</header>
{children}
</section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div style={{ padding: '10px 14px', background: 'var(--bg-surface)', borderRadius: 10, border: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
{label.toUpperCase()}
</div>
<div className="mono" style={{ fontSize: 13, fontWeight: 700, marginTop: 4 }}>
{value}
</div>
</div>
);
}
function Lineup({ teamName, players }: { teamName: string; players: Player[] }) {
return (
<div>
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-tertiary)', marginBottom: 8 }}>
{teamName.toUpperCase()}
</div>
<ul style={{ display: 'grid', gap: 6 }}>
{players.map((p) => (
<li key={p.name} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 8 }}>
<span>{p.name}</span>
<span className="mono" style={{
fontSize: 11,
color: p.injury_status === 'OUT' ? 'var(--grade-d)' : p.injury_status ? 'var(--grade-c)' : 'var(--text-tertiary)',
}}>
{p.injury_status ? `${p.injury_status === 'OUT' ? '❌' : '⚠'} ${p.injury_status}` : p.position}
</span>
</li>
))}
</ul>
</div>
);
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
} catch {
return iso;
}
}
function formatOdds(odds: number): string {
if (!odds) return '—';
return odds > 0 ? `+${odds}` : `${odds}`;
}