Wave 4B: Parlay Lab page + Live Grade-Shift timeline
TASK 1 — Parlay Lab (/parlay): a dedicated Correlation Builder with a
leg source INDEPENDENT of the live slate. Browses tonight's pre-graded
props from /api/snapshot/:sport (resolves players via /api/players/search),
adds legs through useParlay().addLeg (deduped by legKey), and renders the
PARLAY SLIP — combined grade, correlation, and payout read straight off
ParlayContext. Surfaces parlayService's correlation warning as the
CAUTION · CORRELATION FLAG, honors the tier leg-cap (free 2 / analyst 4 /
desk 6) and blurs the payout for free tier with the __goPaywall upsell.
Added /parlay to OPEN_ROUTES (free funnel, like scan/dashboard). Retired
the cleanly-dead ParlayTray.tsx (unmounted since Session 50). No new
proxies — reuses existing snapshot/search/parlay-grade endpoints.
TASK 2 — Live Grade-Shift timeline: web/src/lib/gradeShift.js (pure,
testable) builds a line/grade-movement timeline from already-emitted data
(intraday {t,line} history + revised_from_grade). Color law mirrors
ROW-GRAMMAR / StatStrip.LineSparkline: green = toward the graded side,
amber = against, dim = flat (never red). GradeShift.tsx renders it, shows
the original grade struck-through on a revision, and self-hides below 3
real points. Mounted in GradeResultCard (self-hides on the scan path,
which carries no captured history — honest, never fabricated).
Tests: tests/unit/gradeShift.test.js (15) + tests/unit/parlayLab.test.js
(13). Full suite 245 suites / 2989 tests green (baseline 243/2961).
Next build EXIT=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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'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' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user