Session 50: Complete Parlay Lab (2215 tests)

Correlation-aware combined parlay grading — the Desk-tier differentiator.

- Correlation model (parlayService.js, added to S28 funcs): correlationScore
  (game-aware 0.7/0.4/0.2/0.0), combinedGrade (avg penalized by avgCorr*0.5),
  estimatedPayout (fair-odds product * (1-avgCorr) discount), correlationWarning,
  gradeParlay.
- POST /api/parlay/grade (public, 2-6 legs) -> {combined,correlation,payout,legs}.
  Fixed the Next proxy (was forwarding to /api/scan/parlay).
- ParlayContext: legs gained team/game/archetype; tier-aware maxLegs; auto-grades
  the slip (debounced) when legs>=2 -> live combined/correlation/payout; hasLeg/
  legKey/atCap.
- "+" button on every graded prop: StatStrip onAddLeg/isLegActive, wired by
  vyndr/GameCard via useParlay (builds leg w/ team + game). GradeResultCard feeds
  the same context from the scan page.
- ParlayPanel (replaces legacy ParlayTray): bottom slide-up w/ legs, combined
  grade, correlation warning, est payout, CLEAR ALL + floating leg-count badge.
  Tier-gated: free 2 legs (payout blurred -> Desk upsell), Analyst 4, Desk 6.

Backend 2185 -> 2215 tests (+30), 187 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-19 11:25:14 -04:00
parent 3b47b783dc
commit f1956dc953
14 changed files with 706 additions and 48 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -38
View File
@@ -1,56 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
interface Leg {
sport?: 'NBA' | 'MLB' | 'WNBA';
player: string;
stat_type: string;
line: number;
direction: 'over' | 'under';
}
interface Body {
legs: Leg[];
}
/**
* Parlay Lab grade proxy (Session 50) — forwards POST /api/parlay/grade to the
* Express correlation model. Public + stateless (the Lab UI is tier-gated on the
* frontend); body carries { legs, betAmount }.
*/
export async function POST(req: NextRequest) {
const user = await getUserFromRequest(req);
if (!user) return jsonError(401, 'Log in to grade parlays.');
let body: Body;
const body = await req.text();
try {
body = (await req.json()) as Body;
} catch {
return jsonError(400, 'Invalid JSON.');
}
if (!Array.isArray(body.legs) || body.legs.length < 2 || body.legs.length > 12) {
return jsonError(400, 'Send 212 legs.');
}
try {
const upstream = await fetch(`${BACKEND_URL}/api/scan/parlay`, {
const upstream = await fetch(`${BACKEND_URL}/api/parlay/grade`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
},
body: JSON.stringify({ legs: body.legs }),
body,
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return NextResponse.json(
{ error: (data as { error?: string }).error || 'The engine hit a wall on this parlay.' },
{ status: upstream.status },
);
}
return NextResponse.json(data);
return NextResponse.json(data, { status: upstream.status });
} catch {
return jsonError(502, 'The engine hit a wall on this parlay.');
return NextResponse.json({ error: 'The engine hit a wall on this parlay.' }, { status: 502 });
}
}
+3 -2
View File
@@ -9,7 +9,8 @@ import Footer from '@/components/Footer';
import AuthGate from '@/components/AuthGate';
import HashRedirect from '@/components/vyndr/HashRedirect';
import GlobalHosts from '@/components/vyndr/GlobalHosts';
import ParlayTray from '@/components/ParlayTray';
// Session 50 — the Parlay Lab supersedes the legacy ParlayTray.
import ParlayPanel from '@/components/vyndr/ParlayPanel';
import BottomTabBar from '@/components/BottomTabBar';
import InstallPrompt from '@/components/InstallPrompt';
import PushPrompt from '@/components/PushPrompt';
@@ -141,7 +142,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<main style={{ paddingTop: 124, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
</AuthGate>
<Footer />
<ParlayTray />
<ParlayPanel />
<BottomTabBar />
<InstallPrompt />
<PushPrompt />
+22
View File
@@ -7,6 +7,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
import { playerHref } from '@/lib/playerHref';
import { isPreferredBook } from '@/lib/books';
import { useParlay, legKey } from '@/contexts/ParlayContext';
export interface GameLine {
book: string;
@@ -120,6 +121,26 @@ function PropRow({ prop: p, onAddParlay }: { prop: GameProp; onAddParlay?: (p: G
/** Dashboard / Slate game card (§7) — game-lines grid w/ best-line highlight,
* graded props, inline streaks, live indicator. */
export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }: GameCardProps) {
// Session 50 — Parlay Lab "+" wiring. Builds a leg from the strip + game.
const { addLeg, removeLeg, legs, hasLeg } = useParlay();
const sportU = (g.sport || 'nba').toUpperCase();
const gameId = `${g.away.abbr} @ ${g.home.abbr}`;
const toLeg = (ps: PlayerStrip, p: StripProp) => ({
sport: (sportU === 'MLB' || sportU === 'WNBA' ? sportU : 'NBA') as 'NBA' | 'MLB' | 'WNBA',
player: ps.player, team: ps.team, game: gameId, archetype: ps.archetype?.primary,
stat: String(p.stat), line: Number(p.line) || 0,
direction: (String(p.side).toUpperCase() === 'U' ? 'under' : 'over') as 'over' | 'under',
grade: String(p.grade || 'C'), confidence: 60,
});
const stripHandlers = (ps: PlayerStrip) => ({
onAddLeg: (p: StripProp) => {
const leg = toLeg(ps, p);
const k = legKey(leg);
const existing = legs.find((l) => legKey(l) === k);
if (existing) removeLeg(existing.id); else addLeg(leg);
},
isLegActive: (p: StripProp) => hasLeg(legKey(toLeg(ps, p))),
});
return (
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{/* HEADER */}
@@ -211,6 +232,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
props={ps.props}
variant="compact"
onPlayerClick={() => { window.location.href = playerHref(ps.player, g.sport); }}
{...stripHandlers(ps)}
/>
))}
</div>
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useEffect } from 'react';
import { useParlay } from '@/contexts/ParlayContext';
import { useAuth } from '@/contexts/AuthContext';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
/**
* ParlayPanel (Session 50) — the Parlay Lab. A bottom slide-up that shows the
* selected legs, the correlation-aware combined grade, the correlation warning,
* and the estimated payout. Tier-gated: free 2 legs (payout blurred), Analyst 4,
* Desk 6. A floating badge (bottom-right) opens it when legs are pending.
* Mounted globally in the layout — visible across pages.
*/
const TIER_MAX = { free: 2, analyst: 4, desk: 6 } as const;
function tierMaxLegs(tier: string): number {
return TIER_MAX[(tier as keyof typeof TIER_MAX)] ?? 2;
}
export default function ParlayPanel() {
const { legs, isOpen, toggle, close, 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;
// Floating trigger when closed.
if (!isOpen) {
return (
<button
type="button"
onClick={toggle}
aria-label={`Open Parlay Lab (${legs.length} legs)`}
style={{
position: 'fixed', bottom: 84, right: 18, zIndex: 60, width: 56, height: 56, borderRadius: '50%',
background: 'var(--g-a)', color: '#06060B', fontWeight: 800, fontSize: 18, border: 'none', cursor: 'pointer',
boxShadow: '0 0 22px rgba(0,212,160,.45)', fontFamily: 'var(--mono)',
}}
>
{legs.length}
</button>
);
}
return (
<div
role="dialog"
aria-label="Parlay Lab"
style={{
position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 70,
maxWidth: 520, margin: '0 auto', background: 'var(--bg-1)',
borderTop: '1px solid var(--g-a)', borderLeft: '1px solid var(--border-hi)', borderRight: '1px solid var(--border-hi)',
borderTopLeftRadius: 16, borderTopRightRadius: 16, boxShadow: '0 -12px 40px rgba(0,0,0,.6)',
padding: '16px 16px 22px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span className="mono" style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)' }}>
PARLAY LAB ({legs.length})
</span>
<button type="button" onClick={close} aria-label="Close" className="mono" style={{ background: 'transparent', border: 'none', color: 'var(--text-1)', cursor: 'pointer', fontSize: 16 }}></button>
</div>
{/* Legs */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginBottom: 12, maxHeight: 220, overflowY: 'auto' }}>
{legs.map((l) => (
<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>
<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>
))}
</div>
{legs.length >= maxLegs && (
<div className="mono" style={{ fontSize: 11, color: 'var(--amber)', marginBottom: 10 }}>
{fullLab ? `Max ${maxLegs} legs on your plan.` : 'Free tier caps at 2 legs — upgrade to Desk for 6.'}
</div>
)}
{/* Correlation warning */}
{correlation?.warning && (
<div className="mono" style={{ fontSize: 12, color: 'var(--amber)', marginBottom: 10 }}>{correlation.warning}</div>
)}
{/* Metrics */}
{legs.length >= 2 ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 14 }}>
<div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.08em', marginBottom: 4 }}>COMBINED GRADE</div>
{combined ? <GradeBadge grade={combined.grade} size="md" glow /> : <span className="mono" style={{ color: 'var(--text-2)' }}>{grading ? '…' : '—'}</span>}
</div>
<div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.08em', marginBottom: 4 }}>CORRELATION</div>
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: (correlation?.avg ?? 0) > 0.3 ? 'var(--amber)' : 'var(--g-a)' }}>
{correlation ? `${correlation.avg > 0.5 ? 'High' : correlation.avg > 0.2 ? 'Medium' : 'Low'} (${correlation.avg.toFixed(2)})` : '—'}
</div>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.08em', marginBottom: 4 }}>EST. PAYOUT · $10</div>
{fullLab ? (
<div className="mono" style={{ fontSize: 15, fontWeight: 700, color: 'var(--g-a)' }}>
{payout ? `$${payout.amount.toFixed(2)} (${payout.multiplier.toFixed(2)}x)` : (grading ? '…' : '—')}
</div>
) : (
<div style={{ position: 'relative' }}>
<div className="mono" style={{ fontSize: 15, fontWeight: 700, color: 'var(--g-a)', filter: 'blur(6px)', userSelect: 'none' }}>$38.50 (3.85x)</div>
<button type="button" onClick={() => typeof window !== 'undefined' && window.__goPaywall && window.__goPaywall()} className="mono"
style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', color: 'var(--g-a)', cursor: 'pointer', fontSize: 11, fontWeight: 700 }}>
Upgrade to Desk for the full Parlay Lab
</button>
</div>
)}
</div>
</div>
) : (
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>Add another leg to see the combined grade.</div>
)}
<button type="button" onClick={clear} className="mono"
style={{ width: '100%', padding: '11px', borderRadius: 9, fontWeight: 700, letterSpacing: '0.06em', fontSize: 12, cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', color: 'var(--miss)' }}>
CLEAR ALL
</button>
</div>
);
}
+29
View File
@@ -31,6 +31,9 @@ interface StatStripProps {
meta?: string; // expanded: "ATL · 3B · #27"
variant?: 'compact' | 'expanded';
onPlayerClick?: () => void;
// Session 50 — Parlay Lab "+" per graded prop (wired by the card via useParlay).
onAddLeg?: (p: StripProp) => void;
isLegActive?: (p: StripProp) => boolean;
}
const Sep = ({ ch = '|' }: { ch?: string }) => (
@@ -54,7 +57,31 @@ export default function StatStrip({
meta,
variant = 'compact',
onPlayerClick,
onAddLeg,
isLegActive,
}: StatStripProps) {
const ParlayBtn = ({ p }: { p: StripProp }) => {
if (!onAddLeg || !p.grade) return null;
const active = isLegActive ? isLegActive(p) : false;
return (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onAddLeg(p); }}
title={active ? 'Remove from Parlay' : 'Add to Parlay'}
aria-label={active ? 'Remove from Parlay' : 'Add to Parlay'}
className="mono"
style={{
cursor: 'pointer', width: 20, height: 20, borderRadius: 5, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--g-a) 18%, transparent)' : 'var(--bg-2)',
border: `1px solid ${active ? 'var(--g-a)' : 'var(--border-hi)'}`,
color: 'var(--g-a)', fontSize: 13, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}
>
{active ? '✓' : '+'}
</button>
);
};
const last10Str = typeof last10 === 'string'
? last10
: Array.isArray(last10)
@@ -144,6 +171,7 @@ export default function StatStrip({
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
{p.stat} {p.side}{p.line} {p.grade && <GradeBadge grade={p.grade} size="sm" />}
<ParlayBtn p={p} />
</span>
</span>
))}
@@ -167,6 +195,7 @@ export default function StatStrip({
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
<ParlayBtn p={p} />
{p.gradedAt?.ago && (
<span style={{ color: 'var(--text-2)' }}>
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
+86 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
export interface ParlayLeg {
id: string;
@@ -11,8 +11,17 @@ export interface ParlayLeg {
direction: 'over' | 'under';
grade: string;
confidence: number;
// Session 50 — needed for the correlation model.
team?: string;
game?: string;
archetype?: string;
}
// Session 50 — the combined analysis from /api/parlay/grade.
export interface ParlayCombined { grade: string; score: number; penalty: number }
export interface ParlayCorrelation { max: number; avg: number; warning: string | null }
export interface ParlayPayout { amount: number; multiplier: number; fairMultiplier?: number; discount?: number }
interface ParlayContextValue {
legs: ParlayLeg[];
legCount: number;
@@ -23,16 +32,35 @@ interface ParlayContextValue {
addLeg: (leg: Omit<ParlayLeg, 'id'>) => void;
removeLeg: (id: string) => void;
clear: () => void;
// Session 50 — live combined metrics (auto-graded when legs ≥ 2).
combined: ParlayCombined | null;
correlation: ParlayCorrelation | null;
payout: ParlayPayout | null;
grading: boolean;
hasLeg: (key: string) => boolean;
// Session 50 — tier-aware leg cap (set by the panel from useAuth).
maxLegs: number;
setMaxLegs: (n: number) => void;
atCap: boolean;
}
const STORAGE_KEY = 'bbk:parlay';
const MAX_LEGS = 12;
const MAX_LEGS = 6; // Session 50 — Desk cap; lower tiers gated in the UI.
/** Stable de-dupe key for a leg (player|stat|line|direction). */
export function legKey(l: { player: string; stat: string; line: number; direction: string }) {
return `${l.player}|${l.stat}|${l.line}|${l.direction}`;
}
const ParlayContext = createContext<ParlayContextValue | null>(null);
export default function ParlayProvider({ children }: { children: React.ReactNode }) {
const [legs, setLegs] = useState<ParlayLeg[]>([]);
const [isOpen, setOpen] = useState(false);
// Session 50 — tier cap (default Desk 6; the panel lowers it for free/analyst).
const [maxLegs, setMaxLegs] = useState(MAX_LEGS);
const maxLegsRef = useRef(MAX_LEGS);
useEffect(() => { maxLegsRef.current = maxLegs; }, [maxLegs]);
// Restore from localStorage so a refresh doesn't drop the tray
useEffect(() => {
@@ -59,7 +87,7 @@ export default function ParlayProvider({ children }: { children: React.ReactNode
const addLeg = useCallback((leg: Omit<ParlayLeg, 'id'>) => {
setLegs((prev) => {
if (prev.length >= MAX_LEGS) return prev;
if (prev.length >= maxLegsRef.current) return prev;
// De-dupe by player+stat+line+direction
const key = `${leg.player}|${leg.stat}|${leg.line}|${leg.direction}`;
if (prev.some((p) => `${p.player}|${p.stat}|${p.line}|${p.direction}` === key)) return prev;
@@ -87,6 +115,44 @@ export default function ParlayProvider({ children }: { children: React.ReactNode
const clear = useCallback(() => setLegs([]), []);
const hasLeg = useCallback((key: string) => legs.some((l) => legKey(l) === key), [legs]);
// Session 50 — auto-grade the slip whenever the legs change (≥ 2 legs).
// Debounced so rapid adds make one request; clears when below 2.
const [combined, setCombined] = useState<ParlayCombined | null>(null);
const [correlation, setCorrelation] = useState<ParlayCorrelation | null>(null);
const [payout, setPayout] = useState<ParlayPayout | null>(null);
const [grading, setGrading] = useState(false);
useEffect(() => {
if (legs.length < 2) { setCombined(null); setCorrelation(null); setPayout(null); return; }
let active = true;
setGrading(true);
const t = setTimeout(() => {
fetch('/api/parlay/grade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legs: legs.map((l) => ({
player: l.player, team: l.team || '', game: l.game || '',
stat: l.stat, line: l.line, grade: l.grade,
})),
betAmount: 10,
}),
})
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!active || !d || d.error) return;
setCombined(d.combined ?? null);
setCorrelation(d.correlation ?? null);
setPayout(d.payout ?? null);
})
.catch(() => { /* keep last-known on failure */ })
.finally(() => { if (active) setGrading(false); });
}, 250);
return () => { active = false; clearTimeout(t); setGrading(false); };
}, [legs]);
const value = useMemo<ParlayContextValue>(() => ({
legs,
legCount: legs.length,
@@ -97,7 +163,15 @@ export default function ParlayProvider({ children }: { children: React.ReactNode
addLeg,
removeLeg,
clear,
}), [legs, isOpen, addLeg, removeLeg, clear]);
combined,
correlation,
payout,
grading,
hasLeg,
maxLegs,
setMaxLegs,
atCap: legs.length >= maxLegs,
}), [legs, isOpen, addLeg, removeLeg, clear, combined, correlation, payout, grading, hasLeg, maxLegs]);
return <ParlayContext.Provider value={value}>{children}</ParlayContext.Provider>;
}
@@ -117,6 +191,14 @@ export function useParlay(): ParlayContextValue {
addLeg: () => {},
removeLeg: () => {},
clear: () => {},
combined: null,
correlation: null,
payout: null,
grading: false,
hasLeg: () => false,
maxLegs: 6,
setMaxLegs: () => {},
atCap: false,
};
}
return ctx;