Merge Wave 4B (wiring/data): Parlay Lab page + live grade-shift timeline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 15:00:24 -04:00
8 changed files with 770 additions and 231 deletions
-231
View File
@@ -1,231 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParlay, type ParlayLeg } from '@/contexts/ParlayContext';
import { GradePill } from './GradeCard';
import { trackParlayBuilt } from '@/lib/analytics';
interface ParlayGradeResponse {
parlay_grade: string;
parlay_confidence: number;
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
decimal_odds?: number;
}
export default function ParlayTray() {
const { legs, isOpen, close, removeLeg, clear } = useParlay();
const [grading, setGrading] = useState(false);
const [parlayResult, setParlayResult] = useState<ParlayGradeResponse | null>(null);
// Reset the parlay grade whenever the leg set changes
useEffect(() => {
setParlayResult(null);
}, [legs]);
const sports = useMemo(() => Array.from(new Set(legs.map((l) => l.sport))), [legs]);
const gradeParlay = async () => {
if (legs.length < 2) return;
setGrading(true);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/parlay/grade', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
legs: legs.map((l) => ({
sport: l.sport,
player: l.player,
stat_type: l.stat,
line: l.line,
direction: l.direction,
})),
}),
});
const data = (await res.json()) as ParlayGradeResponse;
if (res.ok) {
setParlayResult(data);
trackParlayBuilt({ legs: legs.length, sports, grade: data.parlay_grade });
}
} finally {
setGrading(false);
}
};
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Parlay tray"
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
>
<button
aria-label="Close parlay tray"
onClick={close}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
border: 'none',
cursor: 'pointer',
}}
/>
<section
className="surface-elevated diagonal-cut animate-fade-up"
style={{
position: 'relative',
width: '100%',
maxWidth: 560,
maxHeight: '85vh',
margin: '0 auto',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 16,
overflowY: 'auto',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Parlay tray</h2>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
{legs.length} LEG{legs.length === 1 ? '' : 'S'} · {sports.join(' · ') || 'ADD A LEG'}
</p>
</div>
<button onClick={close} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 12 }}>
Close
</button>
</header>
{legs.length === 0 ? (
<EmptyTrayCopy />
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{legs.map((l) => (
<LegRow key={l.id} leg={l} onRemove={() => removeLeg(l.id)} />
))}
</ul>
)}
{parlayResult && (
<div
className="surface diagonal-cut"
style={{
padding: 16,
textAlign: 'center',
border: '1px solid var(--border-focus)',
}}
>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
PARLAY GRADE
</p>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
<GradePill grade={parlayResult.parlay_grade} confidence={parlayResult.parlay_confidence} />
</div>
{parlayResult.correlation_flags.length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
textAlign: 'left',
borderRadius: 8,
background: 'rgba(255,179,71,0.10)',
border: '1px solid rgba(255,179,71,0.30)',
}}
>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--grade-c)', marginBottom: 4 }}>
CORRELATION WARNINGS
</p>
{parlayResult.correlation_flags.map((f, i) => (
<p key={i} style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
{f.detail}
</p>
))}
</div>
)}
</div>
)}
{legs.length > 0 && (
<footer style={{ display: 'grid', gap: 8 }}>
<button
onClick={gradeParlay}
disabled={legs.length < 2 || grading}
className={grading ? 'shimmer-loading' : 'btn-primary'}
style={{ padding: 14, fontWeight: 600, fontSize: 14, border: 'none', borderRadius: 12, color: 'var(--text-primary)', cursor: legs.length < 2 ? 'not-allowed' : 'pointer', opacity: legs.length < 2 ? 0.4 : 1 }}
>
{grading ? 'Running correlation analysis…' : legs.length < 2 ? 'Add 2+ legs to grade' : 'Grade parlay'}
</button>
<button onClick={clear} className="btn-ghost" style={{ padding: 12, fontSize: 13 }}>
Clear tray
</button>
</footer>
)}
</section>
</div>
);
}
function EmptyTrayCopy() {
return (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
NO LEGS YET
</p>
<p style={{ marginTop: 12, color: 'var(--text-secondary)', fontSize: 14, lineHeight: 1.6 }}>
Read a prop, hit <strong>Add to Parlay</strong>, and we&apos;ll build the slip here.
We grade overall correlation and surface the legs that secretly fight each other.
</p>
</div>
);
}
function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) {
return (
<li
className="surface"
style={{
padding: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{leg.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<GradePill grade={leg.grade} />
<button
onClick={onRemove}
aria-label={`Remove ${leg.player}`}
className="btn-ghost"
style={{ padding: '4px 10px', fontSize: 11 }}
>
</button>
</div>
</li>
);
}
@@ -6,6 +6,7 @@ import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import GradeBadge from '@/components/vyndr/GradeBadge';
import GradeShift from '@/components/vyndr/GradeShift';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
@@ -39,6 +40,12 @@ export interface GradeResultData {
propDNA?: { reliable: string[]; volatile: string[] };
statContext?: { season?: string; last10?: string; vsOpp?: string };
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
// Wave 4B — LIVE GRADE-SHIFT timeline (all optional; GradeShift self-hides
// below 3 real captured points). Fed by the snapshot pipeline's already-
// emitted line history + public revision; the scan path leaves them absent.
history?: Array<{ t: string; line: number }> | null;
revisedFrom?: string | null;
gradedLine?: number | null;
}
interface GradeResultCardProps {
@@ -198,6 +205,12 @@ export default function GradeResultCard({
))}
</div>
{/* 4b. LIVE GRADE-SHIFT (Wave 4B) — the line/grade movement timeline over
the snapshot pipeline's already-emitted history. Self-hides below 3
real captured points, so the scan path (no captured history) shows
nothing rather than a fabricated timeline. */}
<GradeShift history={d.history} side={d.side} grade={d.grade} revisedFrom={d.revisedFrom} gradedLine={d.gradedLine ?? d.line} />
{/* 5. SIGNAL BREAKDOWN */}
{d.signals.length > 0 && (
<div style={{ padding: '16px 20px' }}>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { buildGradeTimeline } from "@/lib/gradeShift";
/**
* GradeShift (Wave 4B) — the LIVE GRADE-SHIFT / GRADE HISTORY · LAST 24 view.
* A timeline VIEW over ALREADY-EMITTED data: the intraday line-history points
* ({t, line}) + any public revision (revised_from_grade). No new backend.
*
* Doctrine:
* - Self-hides below 3 real history points (buildGradeTimeline.show).
* - Color law (ROW-GRAMMAR): green = toward the graded side, amber = against,
* dim = flat. NEVER red — nothing here is settled.
* - A revision shows the ORIGINAL grade struck-through (never silently dropped).
* - Data is mono; nothing glitches.
*/
export interface GradeShiftHistoryPoint {
t: string;
line: number;
}
interface GradeShiftProps {
history?: Array<GradeShiftHistoryPoint> | null;
side?: string;
grade?: string | null;
revisedFrom?: string | null;
gradedLine?: number | null;
}
export default function GradeShift({ history, side, grade, revisedFrom, gradedLine }: GradeShiftProps) {
const tl = buildGradeTimeline({ history, side, grade, revisedFrom, gradedLine });
if (!tl.show) return null; // honest self-hide — no fabricated timeline
const lines = tl.points.map((p) => p.line);
const min = Math.min(...lines);
const max = Math.max(...lines);
const rng = max - min || 1;
const netSign = tl.net.delta > 0 ? "+" : "";
return (
<div
style={{
margin: "0 20px 16px",
padding: "14px 16px",
borderRadius: 12,
border: "1px solid var(--border)",
background: "var(--bg-2)",
}}
aria-label="Line history since lock"
>
{/* header + legend */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.16em", color: "var(--text-1)" }}>
GRADE HISTORY · LAST 24
</span>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: tl.net.color, letterSpacing: "0.04em" }}>
{tl.net.dir === "toward" ? "TOWARD" : tl.net.dir === "against" ? "AGAINST" : "FLAT"} {netSign}{tl.net.delta}
</span>
</div>
{/* revision — original grade struck-through, never silently */}
{tl.revision && (
<div className="mono" style={{ fontSize: 11, color: "var(--text-2)", marginBottom: 10, display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ letterSpacing: "0.08em" }}>REVISED</span>
<span style={{ textDecoration: "line-through", color: "var(--text-2)" }}>{tl.revision.from}</span>
<span style={{ color: "var(--text-1)" }}></span>
<span style={{ color: "var(--text-0)", fontWeight: 700 }}>{tl.revision.to}</span>
</div>
)}
{/* bars — one per capture, height by relative line, colored by segment dir */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: 44 }}>
{tl.points.map((p, i) => {
const h = 30 + ((p.line - min) / rng) * 70; // 30%..100%
const isGreen = p.color === "var(--g-a)";
const isAmber = p.color === "var(--amber)";
const bg = isGreen
? "var(--g-a)"
: isAmber
? "var(--amber)"
: "var(--border-hi)";
return (
<div
key={i}
title={`${p.line}${p.delta ? ` (${p.delta > 0 ? "+" : ""}${p.delta})` : ""}`}
style={{
flex: 1,
height: `${h}%`,
borderRadius: "3px 3px 0 0",
background: bg,
opacity: p.dir === "flat" ? 0.55 : 0.9,
}}
/>
);
})}
</div>
{/* clock rail — lock -> now, mirrors the mockup's TIP/NOW */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 6 }}>
<span className="mono" style={{ fontSize: 8.5, color: "var(--text-2)", letterSpacing: "0.1em" }}>
LOCK {tl.firstLine}
</span>
<span className="mono" style={{ fontSize: 8.5, color: "var(--text-2)", letterSpacing: "0.1em" }}>
NOW {tl.lastLine}
</span>
</div>
</div>
);
}