Files
vyndr/web/src/app/parlay/page.tsx
T
builtbykev 911cae4992 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>
2026-07-13 14:36:28 -04:00

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&apos;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&apos;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>
);
}