Session E (night2): Phase 4 — scan + parlay polish

4.1 ROOT CAUSE of 'Ohtani returns nothing': no backend
    /api/players/search existed (MLB 404'd; NBA/WNBA hit the offline
    Python service). New Express route + mlbStatsAdapter.matchPlayers —
    canonical nameKey fuzzy match (exact > last-name prefix > folded
    substring). LIVE-VERIFIED vs the real 1,299-player list: Ohtani /
    Aaron Judge / Sánchez / sanchez / Chisholm Jr all resolve; accented
    and unaccented return identical results. Non-MLB matches the
    platform's cached names (rosterlogs + grades), cache-only.
4.2 Reveal choreography per §7: analyzing steps → DECLASSIFIED stamp →
    90ms-staggered context panels (entrance floors visible per the
    Phase-0 rule); prefers-reduced-motion skips straight to the card.
4.3 PRIOR READS chips on scan results — the model's public ledger
    history for the player (deferred-render, outcomes + pending, never
    invented). /api/ledger/model gains ?player= on entries.
4.4 Parlay Lab: humanized stat labels via the ONE shared formatter
    (lib/gradeAdapter.statLabel); 1-leg provisional grade ('Leg grade:
    B — add a leg for the combined read'); discoverable entry — Nav
    'Parlay Lab' item opens the drawer via window.__openParlay, and the
    open drawer now renders an honest empty state at 0 legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 00:59:17 -04:00
parent 9ae64516e6
commit 5d19660f8e
12 changed files with 325 additions and 26 deletions
+11 -1
View File
@@ -24,6 +24,8 @@ const PRIMARY = [
];
const MORE = [
{ label: 'Explore', href: '/explore' },
// Session 60 (4.4) — discoverable Parlay Lab entry (opens the drawer).
{ label: 'Parlay Lab', href: '#parlay', action: 'parlay' as const },
{ label: 'Compare', href: '/compare' },
{ label: 'Tracker', href: '/tracker' },
{ label: 'The Report', href: '/blog' },
@@ -153,7 +155,15 @@ export default function Nav() {
key={l.href}
href={l.href}
role="menuitem"
onClick={() => setMoreOpen(false)}
onClick={(e) => {
if ('action' in l && l.action === 'parlay') {
e.preventDefault();
if (typeof window !== 'undefined' && (window as Window & { __openParlay?: () => void }).__openParlay) {
(window as Window & { __openParlay?: () => void }).__openParlay!();
}
}
setMoreOpen(false);
}}
style={{
display: 'block',
padding: '9px 10px',
+25 -5
View File
@@ -5,6 +5,8 @@ import { useParlay } from '@/contexts/ParlayContext';
import { useAuth } from '@/contexts/AuthContext';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
// 4.4 — the ONE shared stat-label formatter (lib/gradeAdapter, unit-tested).
import { statLabel } from '@/lib/gradeAdapter';
/**
* ParlayPanel (Session 50) — the Parlay Lab. A bottom slide-up that shows the
@@ -20,17 +22,25 @@ function tierMaxLegs(tier: string): number {
}
export default function ParlayPanel() {
const { legs, isOpen, toggle, close, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay();
const { legs, isOpen, toggle, close, open, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay();
const { tier } = useAuth();
const fullLab = tier === 'desk' || tier === 'analyst';
// Sync the leg cap to the user's tier.
useEffect(() => { setMaxLegs(tierMaxLegs(tier || 'free')); }, [tier, setMaxLegs]);
if (legs.length === 0) return null;
// Session 60 (night2/E, 4.4) — a discoverable entry point: the Nav's
// PARLAY LAB item opens the drawer even with zero legs.
useEffect(() => {
if (typeof window === 'undefined') return;
(window as Window & { __openParlay?: () => void }).__openParlay = open;
return () => { delete (window as Window & { __openParlay?: () => void }).__openParlay; };
}, [open]);
if (legs.length === 0 && !isOpen) return null;
// Floating trigger when closed.
if (!isOpen) {
if (!isOpen && legs.length > 0) {
return (
<button
type="button"
@@ -72,7 +82,8 @@ export default function ParlayPanel() {
<div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 10px', background: 'var(--bg-2)', borderRadius: 8, border: '1px solid var(--border)' }}>
{l.archetype && <ArchetypeBadge archetype={l.archetype} size="sm" variant="full" />}
<span style={{ fontSize: 13, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }}>{l.player}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{l.stat} {l.direction === 'under' ? 'U' : 'O'}{l.line}</span>
{/* 4.4 — humanized stat labels via the ONE shared formatter. */}
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{statLabel(l.stat)} {l.direction === 'under' ? 'U' : 'O'}{l.line}</span>
<GradeBadge grade={l.grade} size="sm" />
<button type="button" onClick={() => removeLeg(l.id)} aria-label="Remove leg" className="mono" style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: 'var(--miss)', cursor: 'pointer', fontSize: 14 }}></button>
</div>
@@ -120,8 +131,17 @@ export default function ParlayPanel() {
)}
</div>
</div>
) : legs.length === 1 ? (
// 4.4 — the 1-leg provisional read instead of a dead panel.
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 9, fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>
<span>Leg grade:</span>
<GradeBadge grade={legs[0].grade} size="sm" />
<span> add a leg for the combined read.</span>
</div>
) : (
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>Add another leg to see the combined grade.</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>
No legs yet tap <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>+</span> on any graded prop to start building.
</div>
)}
<button type="button" onClick={clear} className="mono"
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { useEffect, useState } from 'react';
/**
* PriorReads (Session 60, night2/E — work-order 4.3, spec §7).
* "PRIOR READS: [chips]" under a scan result — the model's own public
* history on this player from ledger_entries. Deferred-render: nothing
* shows until real rows exist; settled rows carry their outcome, pending
* rows say pending. Never invented.
*/
interface PriorRow {
id: string;
stat: string;
line: number;
side: string;
grade: string;
game_date: string;
outcome?: 'hit' | 'miss' | 'push' | null;
actual_value?: number | null;
}
export default function PriorReads({ player, stat }: { player: string; stat?: string }) {
const [rows, setRows] = useState<PriorRow[] | null>(null);
useEffect(() => {
let active = true;
const params = new URLSearchParams({ player, limit: '8' });
fetch(`/api/ledger/model?${params}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => { if (active && data) setRows(Array.isArray(data.entries) ? data.entries : []); })
.catch(() => { /* self-hide */ });
return () => { active = false; };
}, [player]);
if (!rows || rows.length === 0) return null;
// Same-stat reads first — the most relevant history for this scan.
const sorted = stat
? [...rows].sort((a, b) => Number(b.stat === stat.toLowerCase()) - Number(a.stat === stat.toLowerCase()))
: rows;
return (
<div style={{ padding: '4px 4px 0' }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 8 }}>
PRIOR READS · {rows.length}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{sorted.slice(0, 6).map((r) => {
const color = r.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
: r.outcome === 'miss' ? 'var(--miss, #FF5252)'
: r.outcome === 'push' ? 'var(--text-1)' : 'var(--text-2)';
const mark = r.outcome === 'hit' ? '✓' : r.outcome === 'miss' ? '✕' : r.outcome === 'push' ? '' : '…';
return (
<span
key={r.id}
className="mono"
title={`${r.game_date} · graded ${r.grade}${r.actual_value != null ? ` · actual ${r.actual_value}` : ''}`}
style={{ fontSize: 10.5, fontWeight: 700, padding: '3px 8px', borderRadius: 5, border: `1px solid ${color}`, color, letterSpacing: '0.03em' }}
>
{mark} {String(r.side).toUpperCase() === 'UNDER' ? 'U' : 'O'}{r.line} {r.stat.replace(/_/g, ' ')} · {r.grade}
</span>
);
})}
</div>
</div>
);
}
+32 -11
View File
@@ -36,31 +36,41 @@ interface ProcessingGradeProps {
* then the GradeResultCard reveals. Pure chrome — the underlying grade is fixed.
*/
export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToParlay, onReadAnother }: ProcessingGradeProps) {
const [proc, setProc] = useState(true);
// Session 60 (night2/E, spec §7) — full reveal choreography:
// analyzing steps → DECLASSIFIED stamp → card with 90ms-staggered panels.
// Reduced-motion users skip straight to the card (chrome, never data).
const [phase, setPhase] = useState<'proc' | 'stamp' | 'card'>('proc');
const [lit, setLit] = useState(0);
const factors = (data.signals || []).slice(0, 5);
const total = factors.length || 1;
useEffect(() => {
setProc(true);
if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setPhase('card');
return;
}
setPhase('proc');
setLit(0);
const stepMs = 190;
const timers: ReturnType<typeof setTimeout>[] = [];
for (let i = 1; i <= total; i++) timers.push(setTimeout(() => setLit(i), stepMs * i));
timers.push(setTimeout(() => setProc(false), stepMs * total + 360));
timers.push(setTimeout(() => setPhase('stamp'), stepMs * total + 360));
timers.push(setTimeout(() => setPhase('card'), stepMs * total + 360 + 700));
return () => timers.forEach(clearTimeout);
}, [replayKey, total]);
if (!proc) {
if (phase === 'card') {
return (
<GradeResultCard
data={data}
replayKey={replayKey}
onShare={onShare}
onAddToParlay={onAddToParlay}
onReadAnother={onReadAnother}
/>
<div className="grade-reveal">
<GradeResultCard
data={data}
replayKey={replayKey}
onShare={onShare}
onAddToParlay={onAddToParlay}
onReadAnother={onReadAnother}
/>
</div>
);
}
@@ -71,6 +81,17 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
style={{ width: '100%', maxWidth: 640, margin: '0 auto', borderRadius: 12, overflow: 'hidden', border: '1px solid rgba(0,255,184,.25)', minHeight: 360, position: 'relative', boxShadow: '0 0 0 1px rgba(0,212,160,.18), 0 24px 70px -28px rgba(0,212,160,.35)' }}
aria-label="Processing grade"
>
{/* §7 — the DECLASSIFIED stamp lands between analysis and the card. */}
{phase === 'stamp' && (
<div style={{ position: 'absolute', inset: 0, zIndex: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(4,20,15,.55)' }}>
<span
className="mono stamp-in"
style={{ fontSize: 30, fontWeight: 900, letterSpacing: '0.22em', color: 'var(--g-ap)', border: '3px solid var(--g-ap)', borderRadius: 6, padding: '10px 26px', transform: 'rotate(-7deg)', textShadow: '0 0 18px rgba(0,255,184,.7)', boxShadow: '0 0 30px rgba(0,255,184,.35), inset 0 0 22px rgba(0,255,184,.12)' }}
>
DECLASSIFIED
</span>
</div>
)}
<div className="proc-scan" style={{ position: 'absolute', left: 0, right: 0, top: 0, height: '30%', background: 'linear-gradient(180deg, transparent, rgba(0,255,184,.12) 50%, transparent)', zIndex: 1 }} />
<div style={{ position: 'relative', zIndex: 2, padding: '26px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 22 }}>