911cae4992
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>
341 lines
17 KiB
TypeScript
341 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useParlay, legKey, type ParlayLeg } from "@/contexts/ParlayContext";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import GradeBadge from "@/components/vyndr/GradeBadge";
|
|
import SportBadge from "@/components/vyndr/SportBadge";
|
|
import ArchetypeBadge from "@/components/vyndr/ArchetypeBadge";
|
|
import { statLabel } from "@/lib/gradeAdapter";
|
|
|
|
/**
|
|
* Parlay Lab (Wave 4B) — the CORRELATION BUILDER / PARLAY SLIP surface.
|
|
*
|
|
* The correlation MATH already lives in parlayService + /api/parlay/grade +
|
|
* ParlayContext (combined grade, correlation, payout, tier-aware maxLegs). The
|
|
* only thing missing was a leg source INDEPENDENT of the live slate: this page
|
|
* browses tonight's PRE-GRADED props from /api/snapshot/:sport (and resolves a
|
|
* player via /api/players/search), so a slip can be assembled even with no games
|
|
* on screen. Legs go in via useParlay().addLeg (deduped by legKey); the slip
|
|
* reads the combined grade / correlation flag / payout straight off the context.
|
|
*/
|
|
|
|
type Sport = ParlayLeg["sport"]; // 'NBA' | 'MLB' | 'WNBA'
|
|
const SPORTS: Sport[] = ["MLB", "NBA", "WNBA"];
|
|
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;
|
|
}
|
|
|
|
interface SnapshotGrade {
|
|
player?: string;
|
|
player_name?: string;
|
|
stat_type?: string;
|
|
stat?: string;
|
|
line?: number;
|
|
side?: string;
|
|
grade?: string;
|
|
confidence?: number;
|
|
team?: string;
|
|
game?: string;
|
|
archetype?: string;
|
|
}
|
|
|
|
interface BrowseProp {
|
|
player: string;
|
|
stat: string; // canonical stat key (for addLeg + statLabel)
|
|
line: number;
|
|
direction: "over" | "under";
|
|
grade: string;
|
|
confidence: number;
|
|
team?: string;
|
|
game?: string;
|
|
archetype?: string;
|
|
key: string; // legKey
|
|
}
|
|
|
|
function toBrowseProp(g: SnapshotGrade): BrowseProp | null {
|
|
const player = String(g.player || g.player_name || "").trim();
|
|
const stat = String(g.stat_type || g.stat || "").toLowerCase().trim();
|
|
const line = typeof g.line === "number" && Number.isFinite(g.line) ? g.line : NaN;
|
|
if (!player || !stat || Number.isNaN(line)) return null;
|
|
const direction = String(g.side || "over").toLowerCase().startsWith("u") ? "under" : "over";
|
|
const grade = String(g.grade || "").trim();
|
|
if (!grade || grade === "—") return null; // only graded props are addable legs
|
|
return {
|
|
player,
|
|
stat,
|
|
line,
|
|
direction,
|
|
grade,
|
|
confidence: typeof g.confidence === "number" ? g.confidence : 0,
|
|
team: g.team ? String(g.team) : undefined,
|
|
game: g.game ? String(g.game) : undefined,
|
|
archetype: g.archetype ? String(g.archetype) : undefined,
|
|
key: legKey({ player, stat, line, direction }),
|
|
};
|
|
}
|
|
|
|
export default function ParlayLabPage() {
|
|
const { legs, addLeg, removeLeg, clear, combined, correlation, payout, grading, hasLeg, maxLegs, setMaxLegs, atCap } = useParlay();
|
|
const { tier } = useAuth();
|
|
const fullLab = tier === "desk" || tier === "analyst";
|
|
|
|
const [sport, setSport] = useState<Sport>("MLB");
|
|
const [rawProps, setRawProps] = useState<BrowseProp[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [q, setQ] = useState("");
|
|
const [suggest, setSuggest] = useState<string[]>([]);
|
|
|
|
// Tier -> leg cap (free 2 / analyst 4 / desk 6), same wiring as ParlayPanel.
|
|
useEffect(() => { setMaxLegs(tierMaxLegs(tier || "free")); }, [tier, setMaxLegs]);
|
|
|
|
// Leg source #1 — the pre-graded slate (independent of the live board).
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
fetch(`/api/snapshot/${sport.toLowerCase()}`)
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((d) => {
|
|
if (!alive) return;
|
|
const grades: SnapshotGrade[] = d && Array.isArray(d.grades) ? d.grades : [];
|
|
const mapped = grades.map(toBrowseProp).filter((p): p is BrowseProp => !!p);
|
|
// Dedupe by legKey, keep highest confidence, sort A+ -> F then confidence.
|
|
const byKey = new Map<string, BrowseProp>();
|
|
for (const p of mapped) {
|
|
const prev = byKey.get(p.key);
|
|
if (!prev || p.confidence > prev.confidence) byKey.set(p.key, p);
|
|
}
|
|
setRawProps(Array.from(byKey.values()));
|
|
})
|
|
.catch(() => { if (alive) setRawProps([]); })
|
|
.finally(() => { if (alive) setLoading(false); });
|
|
return () => { alive = false; };
|
|
}, [sport]);
|
|
|
|
// Leg source #2 — the canonical player resolver, so search works even for a
|
|
// player with no graded prop tonight (honest "no reads yet" instead of blank).
|
|
useEffect(() => {
|
|
const term = q.trim();
|
|
if (term.length < 2) { setSuggest([]); return; }
|
|
let alive = true;
|
|
const t = setTimeout(() => {
|
|
fetch(`/api/players/search?sport=${sport}&q=${encodeURIComponent(term)}`)
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((d) => {
|
|
if (!alive) return;
|
|
const players = d && Array.isArray(d.players) ? d.players : [];
|
|
setSuggest(players.map((p: { full_name?: string }) => String(p.full_name || "")).filter(Boolean).slice(0, 6));
|
|
})
|
|
.catch(() => { if (alive) setSuggest([]); });
|
|
}, 220);
|
|
return () => { alive = false; clearTimeout(t); };
|
|
}, [q, sport]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const term = q.trim().toLowerCase();
|
|
const list = term ? rawProps.filter((p) => p.player.toLowerCase().includes(term)) : rawProps;
|
|
return list.slice(0, 40);
|
|
}, [rawProps, q]);
|
|
|
|
const toggle = useCallback((p: BrowseProp) => {
|
|
const existing = legs.find((l) => legKey(l) === p.key);
|
|
if (existing) { removeLeg(existing.id); return; }
|
|
if (atCap) { if (typeof window !== "undefined" && !fullLab && window.__goPaywall) window.__goPaywall(); return; }
|
|
addLeg({
|
|
sport,
|
|
player: p.player,
|
|
stat: p.stat,
|
|
line: p.line,
|
|
direction: p.direction,
|
|
grade: p.grade,
|
|
confidence: p.confidence,
|
|
team: p.team,
|
|
game: p.game,
|
|
archetype: p.archetype,
|
|
});
|
|
}, [legs, removeLeg, addLeg, atCap, fullLab, sport]);
|
|
|
|
const searchedButEmpty = q.trim().length >= 2 && filtered.length === 0;
|
|
|
|
return (
|
|
<div style={{ maxWidth: 1040, margin: "0 auto", padding: "12px 16px 120px" }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: 14 }}>
|
|
<h1 className="mono" style={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.28em", color: "var(--text-0)" }}>CORRELATION BUILDER</h1>
|
|
<p className="mono" style={{ fontSize: 10.5, letterSpacing: "0.14em", color: "var(--text-2)", marginTop: 4 }}>
|
|
PARLAY MATH · THE STAKE-DOWN SIGNAL · TAP TO BUILD
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sport tabs */}
|
|
<div style={{ display: "flex", gap: 6, marginBottom: 12, flexWrap: "wrap" }}>
|
|
{SPORTS.map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setSport(s)}
|
|
className="mono"
|
|
style={{
|
|
padding: "6px 12px", borderRadius: 8, fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", cursor: "pointer",
|
|
background: s === sport ? "var(--g-a)" : "var(--bg-2)",
|
|
color: s === sport ? "#06060B" : "var(--text-1)",
|
|
border: `1px solid ${s === sport ? "var(--g-a)" : "var(--border)"}`,
|
|
}}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search — independent of the slate; resolves via /api/players/search */}
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Search a player…"
|
|
aria-label="Search a player"
|
|
className="mono"
|
|
style={{
|
|
width: "100%", padding: "10px 12px", marginBottom: 6, borderRadius: 9, fontSize: 13,
|
|
background: "var(--bg-2)", border: "1px solid var(--border)", color: "var(--text-0)",
|
|
}}
|
|
/>
|
|
{suggest.length > 0 && (
|
|
<div className="mono" style={{ fontSize: 10.5, color: "var(--text-2)", marginBottom: 10, letterSpacing: "0.04em" }}>
|
|
{suggest.join(" · ")}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, alignItems: "start" }} className="parlay-lab-grid">
|
|
{/* LEFT — tonight's A/B reads (the leg source) */}
|
|
<div style={{ borderRadius: 16, background: "var(--bg-1)", border: "1px solid var(--border)", overflow: "hidden" }}>
|
|
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
|
|
<SportBadge sport={sport} />
|
|
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.18em", color: "var(--text-0)" }}>TONIGHT'S GRADED READS</span>
|
|
<span className="mono" style={{ fontSize: 9, letterSpacing: "0.12em", color: "var(--text-2)", marginLeft: "auto" }}>TAP TO ADD</span>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="mono" style={{ padding: 24, textAlign: "center", fontSize: 12, color: "var(--text-2)" }}>Loading tonight's reads…</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="mono" style={{ padding: 24, textAlign: "center", fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>
|
|
{searchedButEmpty
|
|
? `No graded ${sport} reads for “${q.trim()}” yet — grades post on the next snapshot.`
|
|
: `No graded ${sport} reads on the board right now. Check back after the next snapshot.`}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{filtered.map((p) => {
|
|
const active = hasLeg(p.key);
|
|
return (
|
|
<button
|
|
key={p.key}
|
|
type="button"
|
|
onClick={() => toggle(p)}
|
|
style={{
|
|
width: "100%", display: "flex", alignItems: "center", gap: 12, padding: "11px 16px",
|
|
borderTop: "1px solid var(--border)", background: active ? "rgba(0,212,160,.08)" : "transparent",
|
|
cursor: "pointer", textAlign: "left",
|
|
}}
|
|
aria-pressed={active}
|
|
>
|
|
<span style={{
|
|
width: 18, height: 18, flex: "none", borderRadius: 5, display: "flex", alignItems: "center", justifyContent: "center",
|
|
border: `1.5px solid ${active ? "var(--g-a)" : "var(--border-hi)"}`, background: active ? "var(--g-a)" : "transparent",
|
|
color: "#06060B", fontWeight: 800, fontSize: 12,
|
|
}} className="mono">{active ? "✓" : ""}</span>
|
|
<span style={{ flex: 1, minWidth: 0 }}>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--text-0)" }}>{p.player} </span>
|
|
<span className="mono" style={{ fontSize: 12, color: "var(--text-1)" }}>{p.direction === "under" ? "u" : "o"}{p.line} {statLabel(p.stat)}</span>
|
|
<span className="mono" style={{ display: "block", fontSize: 8.5, letterSpacing: "0.1em", color: "var(--text-2)", marginTop: 2 }}>
|
|
{sport}{p.team ? ` · ${p.team}` : ""}
|
|
</span>
|
|
</span>
|
|
{p.archetype && <ArchetypeBadge archetype={p.archetype} size="sm" variant="tint" />}
|
|
<GradeBadge grade={p.grade} size="sm" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* RIGHT — the PARLAY SLIP (reads combined/correlation/payout from context) */}
|
|
<div style={{ borderRadius: 16, background: "var(--bg-1)", border: "1px solid var(--border-hi)", overflow: "hidden", position: "sticky", top: 96 }}>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
|
|
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.22em", color: "var(--text-0)" }}>PARLAY SLIP</span>
|
|
<span className="mono" style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text-2)" }}>{legs.length} LEG{legs.length === 1 ? "" : "S"}</span>
|
|
</div>
|
|
|
|
{/* legs */}
|
|
<div style={{ padding: "8px 10px", minHeight: 96, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
{legs.length === 0 ? (
|
|
<div className="mono" style={{ padding: "22px 6px", textAlign: "center", fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>
|
|
No legs yet — tap a graded read to start building. We grade the combined correlation and flag legs that secretly fight each other.
|
|
</div>
|
|
) : (
|
|
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="tint" />}
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-0)", whiteSpace: "nowrap" }}>{l.player}</span>
|
|
<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 ${l.player}`} className="mono" style={{ marginLeft: "auto", background: "transparent", border: "none", color: "var(--miss)", cursor: "pointer", fontSize: 14 }}>✕</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* leg-cap notice (tier-aware) */}
|
|
{legs.length >= maxLegs && (
|
|
<div className="mono" style={{ fontSize: 11, color: "var(--amber)", padding: "0 12px 10px" }}>
|
|
{fullLab ? `Max ${maxLegs} legs on your plan.` : "Free tier caps at 2 legs — upgrade to Desk for 6."}
|
|
</div>
|
|
)}
|
|
|
|
{/* CAUTION · CORRELATION FLAG — surfaces parlayService's warning */}
|
|
{correlation?.warning && (
|
|
<div style={{ margin: "0 12px 12px", padding: "11px 13px", borderRadius: 10, background: "rgba(255,179,71,.08)", border: "1px solid rgba(255,179,71,.28)" }}>
|
|
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.16em", color: "var(--amber)", fontWeight: 700, marginBottom: 5 }}>⚠ CAUTION · CORRELATION FLAG</div>
|
|
<div className="mono" style={{ fontSize: 10.5, lineHeight: 1.45, color: "var(--text-1)" }}>{correlation.warning}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* combined / grade / stake */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 1, background: "var(--border)", borderTop: "1px solid var(--border)" }}>
|
|
<div style={{ background: "var(--bg-1)", padding: "13px 15px" }}>
|
|
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.14em", color: "var(--text-2)", marginBottom: 6 }}>CORRELATION</div>
|
|
<div className="mono" style={{ fontSize: 15, fontWeight: 800, color: (correlation?.avg ?? 0) > 0.3 ? "var(--amber)" : "var(--g-a)" }}>
|
|
{legs.length >= 2 && correlation ? correlation.avg.toFixed(2) : "—"}
|
|
</div>
|
|
</div>
|
|
<div style={{ background: "var(--bg-1)", padding: "13px 15px" }}>
|
|
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.14em", color: "var(--text-2)", marginBottom: 6 }}>GRADE</div>
|
|
{legs.length >= 2 && combined ? <GradeBadge grade={combined.grade} size="md" glow /> : <span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--text-2)" }}>{grading ? "…" : "—"}</span>}
|
|
</div>
|
|
<div style={{ background: "var(--bg-1)", padding: "13px 15px" }}>
|
|
<div className="mono" style={{ fontSize: 9, letterSpacing: "0.14em", color: "var(--text-2)", marginBottom: 6 }}>EST · $10</div>
|
|
{legs.length < 2 ? (
|
|
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--text-2)" }}>—</span>
|
|
) : fullLab ? (
|
|
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--g-a)" }}>{payout ? `$${payout.amount.toFixed(2)}` : grading ? "…" : "—"}</span>
|
|
) : (
|
|
<span style={{ position: "relative", display: "inline-block" }}>
|
|
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: "var(--g-a)", filter: "blur(6px)", userSelect: "none" }}>$38.50</span>
|
|
<button type="button" onClick={() => typeof window !== "undefined" && window.__goPaywall && window.__goPaywall()} className="mono" style={{ position: "absolute", inset: 0, background: "transparent", border: "none", color: "var(--g-a)", cursor: "pointer", fontSize: 10, fontWeight: 700 }}>Upgrade →</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{legs.length > 0 && (
|
|
<button type="button" onClick={clear} className="mono" style={{ width: "100%", padding: 11, borderRadius: 0, fontWeight: 700, letterSpacing: "0.06em", fontSize: 11, cursor: "pointer", background: "transparent", border: "none", borderTop: "1px solid var(--border)", color: "var(--miss)" }}>
|
|
CLEAR SLIP
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|