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