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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user