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
+12 -9
View File
@@ -20,16 +20,19 @@ export async function GET(req: NextRequest) {
if (q.length < 2) return NextResponse.json({ players: [] });
try {
// NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
const url =
sport === 'MLB'
? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
: `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
// Session 60 (night2/E, audit 4.1) — Express is the canonical resolver
// for EVERY sport now (nameKey fuzzy match; MLB = full statsapi list,
// others = the platform's cached names). The Python NBA service is a
// best-effort second try only when the canonical index has nothing.
const url = `${BACKEND_URL}/api/players/search?sport=${encodeURIComponent(sport)}&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return NextResponse.json({ players: [] });
const data = await res.json().catch(() => ({}));
let res = await fetch(url, { headers: { Accept: 'application/json' } });
let data = res.ok ? await res.json().catch(() => ({})) : {};
const canonical = Array.isArray((data as { players?: unknown[] }).players) ? (data as { players: unknown[] }).players : [];
if (canonical.length === 0 && sport !== 'MLB') {
res = await fetch(`${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' } }).catch(() => new Response(null, { status: 502 }));
data = res.ok ? await res.json().catch(() => ({})) : {};
}
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
? (data as { results: unknown[] }).results
: Array.isArray((data as { players?: unknown[] }).players)
+30
View File
@@ -1293,3 +1293,33 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
.gl-full:not(.gl-expanded) { display: none !important; }
.gl-full.gl-expanded { margin-top: 10px; }
}
/* ============================================================
Session 60 (night2/E) — §7 reveal choreography.
Stamp slam + 90ms staggered context panels. Entrance keyframes floor at
a VISIBLE state (Phase-0 rule: a paused frame is never invisible), and
reduced-motion kills the choreography entirely (ProcessingGrade also
skips straight to the card).
============================================================ */
@keyframes stamp-in {
0% { opacity: .5; transform: rotate(-7deg) scale(1.7); }
60% { opacity: 1; transform: rotate(-7deg) scale(0.96); }
100% { opacity: 1; transform: rotate(-7deg) scale(1); }
}
.stamp-in { animation: stamp-in .34s cubic-bezier(.2,1.6,.4,1) both; }
@keyframes panel-in {
from { opacity: .55; transform: translateY(7px); }
to { opacity: 1; transform: translateY(0); }
}
.grade-reveal > div > * { animation: panel-in .3s ease-out both; }
.grade-reveal > div > *:nth-child(2) { animation-delay: 90ms; }
.grade-reveal > div > *:nth-child(3) { animation-delay: 180ms; }
.grade-reveal > div > *:nth-child(4) { animation-delay: 270ms; }
.grade-reveal > div > *:nth-child(5) { animation-delay: 360ms; }
.grade-reveal > div > *:nth-child(6) { animation-delay: 450ms; }
.grade-reveal > div > *:nth-child(7) { animation-delay: 540ms; }
.grade-reveal > div > *:nth-child(n+8) { animation-delay: 630ms; }
@media (prefers-reduced-motion: reduce) {
.stamp-in, .grade-reveal > div > * { animation: none !important; }
}
+5
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
import PriorReads from '@/components/vyndr/PriorReads';
import { AccuracyBadge } from '@/components/vyndr';
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
@@ -762,6 +763,10 @@ export default function ScanPage() {
onReadAnother={reset}
/>
{/* Session 60 (4.3) — the model's public history on this player.
Deferred-render: shows only when real ledger rows exist. */}
<PriorReads player={selectedPlayer} stat={stat} />
{/* Session 55 — the self-learning loop's track record for this sport.
"The system learns" — real hit rate on graded props, misses shown. */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
+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 }}>