Merge Wave 4B (wiring/data): Parlay Lab page + live grade-shift timeline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 15:00:24 -04:00
8 changed files with 770 additions and 231 deletions
+123
View File
@@ -0,0 +1,123 @@
// Wave 4B — LIVE GRADE-SHIFT timeline helper + component (source-asserted).
// Locks the color law (green toward / amber against / dim flat), the revision
// strike-through, and the <3-point self-hide.
const fs = require('fs');
const path = require('path');
const {
buildGradeTimeline,
classifyMove,
cleanHistory,
TOWARD_COLOR,
AGAINST_COLOR,
FLAT_COLOR,
MIN_POINTS,
} = require('../../web/src/lib/gradeShift');
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
describe('gradeShift helper — color law', () => {
it('color tokens are green / amber / dim (never red)', () => {
expect(TOWARD_COLOR).toBe('var(--g-a)');
expect(AGAINST_COLOR).toBe('var(--amber)');
expect(FLAT_COLOR).toBe('var(--text-1)');
for (const c of [TOWARD_COLOR, AGAINST_COLOR, FLAT_COLOR]) {
expect(c).not.toBe('var(--miss)');
}
});
it('OVER: line up = TOWARD (green), line down = AGAINST (amber), no move = FLAT (dim)', () => {
expect(classifyMove(0.5, 'Over')).toEqual({ dir: 'toward', color: TOWARD_COLOR });
expect(classifyMove(-0.5, 'Over')).toEqual({ dir: 'against', color: AGAINST_COLOR });
expect(classifyMove(0, 'Over')).toEqual({ dir: 'flat', color: FLAT_COLOR });
});
it('UNDER inverts the sign: line down = TOWARD (green), line up = AGAINST (amber)', () => {
expect(classifyMove(-0.5, 'Under')).toEqual({ dir: 'toward', color: TOWARD_COLOR });
expect(classifyMove(0.5, 'Under')).toEqual({ dir: 'against', color: AGAINST_COLOR });
});
});
describe('gradeShift helper — timeline assembly', () => {
it('self-hides below 3 real captured points', () => {
expect(buildGradeTimeline({ history: null }).show).toBe(false);
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }] }).show).toBe(false);
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 5.5 }] }).show).toBe(false);
expect(buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }] }).show).toBe(true);
});
it('drops non-numeric points (Number(null)===0 guard)', () => {
const tl = buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: null }, { t: '3', line: 6.5 }] });
expect(tl.points.length).toBe(2); // the null point is dropped, not coerced to 0
expect(cleanHistory([{ line: null }, { line: 3 }])).toHaveLength(1);
});
it('net move on an OVER that rose reads TOWARD (green)', () => {
const tl = buildGradeTimeline({ history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }], side: 'Over' });
expect(tl.net.dir).toBe('toward');
expect(tl.net.color).toBe(TOWARD_COLOR);
expect(tl.firstLine).toBe(5.5);
expect(tl.lastLine).toBe(6.5);
});
it('net move on an OVER that fell reads AGAINST (amber)', () => {
const tl = buildGradeTimeline({ history: [{ t: '1', line: 6.5 }, { t: '2', line: 6 }, { t: '3', line: 5.5 }], side: 'Over' });
expect(tl.net.dir).toBe('against');
expect(tl.net.color).toBe(AGAINST_COLOR);
});
it('a revision surfaces the ORIGINAL grade struck (from) alongside the new (to)', () => {
const tl = buildGradeTimeline({
history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }],
grade: 'B',
revisedFrom: 'A',
});
expect(tl.revision).toEqual({ from: 'A', to: 'B' });
});
it('no revision when there was no prior grade or it is unchanged', () => {
const base = [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }];
expect(buildGradeTimeline({ history: base, grade: 'A' }).revision).toBeNull();
expect(buildGradeTimeline({ history: base, grade: 'A', revisedFrom: 'A' }).revision).toBeNull();
});
it('accepts the snapshot field name revised_from_grade too', () => {
const tl = buildGradeTimeline({
history: [{ t: '1', line: 5.5 }, { t: '2', line: 6 }, { t: '3', line: 6.5 }],
grade: 'C',
revised_from_grade: 'B',
});
expect(tl.revision).toEqual({ from: 'B', to: 'C' });
});
it('MIN_POINTS matches the LineSparkline floor of 3', () => {
expect(MIN_POINTS).toBe(3);
});
});
describe('GradeShift component', () => {
const src = read('components/vyndr/GradeShift.tsx');
it('self-hides on !tl.show (returns null)', () => {
expect(src).toContain('if (!tl.show) return null');
});
it('renders the original grade struck-through when revised', () => {
expect(src).toContain('tl.revision');
expect(src).toContain('line-through');
expect(src).toContain('tl.revision.from');
expect(src).toContain('tl.revision.to');
});
it('is a data surface — mono, no glitch classes', () => {
expect(src).toContain('mono');
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
});
});
describe('GradeResultCard mounts the timeline', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
it('imports and renders GradeShift with the optional history fields', () => {
expect(src).toContain("import GradeShift from '@/components/vyndr/GradeShift'");
expect(src).toContain('<GradeShift');
expect(src).toContain('history?: Array<{ t: string; line: number }> | null');
expect(src).toContain('revisedFrom');
});
});
+87
View File
@@ -0,0 +1,87 @@
// Wave 4B — Parlay Lab page (/parlay). Source-asserts the dedicated builder:
// an independent (non-slate) leg source, useParlay/legKey wiring, the
// correlation-flag caution, and the tier-aware leg cap.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('Parlay Lab page exists at /parlay', () => {
it('the route file is present', () => {
expect(fs.existsSync(path.join(WEB, 'app', 'parlay', 'page.tsx'))).toBe(true);
});
});
describe('/parlay — independent leg source (not the live slate)', () => {
const src = read('app/parlay/page.tsx');
it('browses pre-graded props from /api/snapshot/:sport', () => {
expect(src).toContain('/api/snapshot/');
});
it('resolves players via /api/players/search (per sport)', () => {
expect(src).toContain('/api/players/search');
});
it('does NOT depend on the live Slate component', () => {
expect(src).not.toContain("from '@/components/Slate'");
expect(src).not.toContain('slateAdapter');
});
it('offers the three parlay sports', () => {
for (const s of ['MLB', 'NBA', 'WNBA']) expect(src).toContain(s);
});
});
describe('/parlay — assembles a slip via useParlay + legKey', () => {
const src = read('app/parlay/page.tsx');
it('imports useParlay and legKey', () => {
expect(src).toContain('useParlay');
expect(src).toContain('legKey');
});
it('adds legs through addLeg and dedupes/toggles by leg key', () => {
expect(src).toContain('addLeg(');
expect(src).toContain('hasLeg(');
expect(src).toContain('removeLeg(');
});
it('renders the PARLAY SLIP with combined grade + correlation + payout from context', () => {
expect(src).toContain('PARLAY SLIP');
expect(src).toContain('combined');
expect(src).toContain('correlation');
expect(src).toContain('payout');
});
});
describe('/parlay — correlation-flag caution', () => {
const src = read('app/parlay/page.tsx');
it('surfaces parlayService correlation warning as a caution flag', () => {
expect(src).toContain('correlation?.warning');
expect(src).toContain('CORRELATION FLAG');
});
});
describe('/parlay — tier-aware leg cap + free upsell', () => {
const src = read('app/parlay/page.tsx');
it('drives the leg cap from the user tier (free 2 / analyst 4 / desk 6)', () => {
expect(src).toContain('useAuth');
expect(src).toContain('setMaxLegs');
expect(src).toContain('free: 2');
expect(src).toContain('desk: 6');
});
it('honors atCap and blurs the payout for free tier with the paywall upsell', () => {
expect(src).toContain('atCap');
expect(src).toContain('__goPaywall');
expect(src).toContain("blur(");
});
});
describe('/parlay is reachable (open route, not gated)', () => {
const routes = require('../../web/src/lib/routes.js');
it('is an OPEN route (free funnel, like scan/dashboard)', () => {
expect(routes.OPEN_ROUTES).toContain('/parlay');
expect(routes.isGatedRoute('/parlay')).toBe(false);
});
});
describe('legacy ParlayTray is retired', () => {
it('the dead component file is gone', () => {
expect(fs.existsSync(path.join(WEB, 'components', 'ParlayTray.tsx'))).toBe(false);
});
});
+340
View File
@@ -0,0 +1,340 @@
"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>
);
}
-231
View File
@@ -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&apos;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' }}>
+109
View File
@@ -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>
);
}
+94
View File
@@ -0,0 +1,94 @@
/* ============================================================
VYNDR — LIVE GRADE-SHIFT timeline helper (Wave 4B).
Pure CommonJS so the client component imports it (allowJs)
AND the Jest suite requires it directly.
Builds a grade/line-movement timeline from ALREADY-EMITTED data
(intradayRefreshService line history {t,line} + revised_from_grade).
It NEVER fabricates: absent/short history => { show:false }.
COLOR LAW (mirrors ROW-GRAMMAR + StatStrip.LineSparkline):
line movement is green = net move TOWARD the graded side,
amber = AGAINST, dim = flat. Never red — nothing here is settled.
For an OVER the "toward" sign is the raw line delta; for an UNDER
it is inverted (a line dropping steams the under).
============================================================ */
const TOWARD_COLOR = 'var(--g-a)'; // green
const AGAINST_COLOR = 'var(--amber)'; // amber
const FLAT_COLOR = 'var(--text-1)'; // dim — never red
const MIN_POINTS = 3; // self-hide below this (same floor as LineSparkline)
function isUnder(side) {
return String(side || 'O').toUpperCase().startsWith('U');
}
/** Classify a signed line delta relative to the graded side. */
function classifyMove(delta, side) {
const d = typeof delta === 'number' && Number.isFinite(delta) ? delta : 0;
const toward = isUnder(side) ? -d : d;
if (toward > 0) return { dir: 'toward', color: TOWARD_COLOR };
if (toward < 0) return { dir: 'against', color: AGAINST_COLOR };
return { dir: 'flat', color: FLAT_COLOR };
}
/** Keep only real {t, line} points (strict number guard — Number(null)===0). */
function cleanHistory(history) {
if (!Array.isArray(history)) return [];
return history
.filter((pt) => pt && typeof pt.line === 'number' && Number.isFinite(pt.line))
.map((pt) => ({ t: pt.t != null ? String(pt.t) : '', line: pt.line }));
}
/**
* Build the grade-shift timeline.
* prop: { history:[{t,line}], side, grade, revisedFrom|revised_from_grade, gradedLine }
* -> { show, points:[{t,line,delta,dir,color}], net:{delta,dir,color},
* revision:{from,to}|null, firstLine, lastLine }
*/
function buildGradeTimeline(prop = {}) {
const history = cleanHistory(prop.history);
const show = history.length >= MIN_POINTS;
const side = prop.side;
const revisedFrom = prop.revisedFrom || prop.revised_from_grade || null;
const grade = prop.grade || null;
const points = history.map((pt, i) => {
if (i === 0) return { t: pt.t, line: pt.line, delta: 0, dir: 'flat', color: FLAT_COLOR };
const delta = Math.round((pt.line - history[i - 1].line) * 100) / 100;
const cls = classifyMove(delta, side);
return { t: pt.t, line: pt.line, delta, dir: cls.dir, color: cls.color };
});
let net = { delta: 0, dir: 'flat', color: FLAT_COLOR };
if (history.length >= 2) {
const raw = Math.round((history[history.length - 1].line - history[0].line) * 100) / 100;
const cls = classifyMove(raw, side);
net = { delta: raw, dir: cls.dir, color: cls.color };
}
// A revision is real only when a prior grade was preserved AND it differs.
const revision = revisedFrom && grade && String(revisedFrom) !== String(grade)
? { from: String(revisedFrom), to: String(grade) }
: null;
return {
show,
points,
net,
revision,
firstLine: history.length ? history[0].line : null,
lastLine: history.length ? history[history.length - 1].line : null,
};
}
module.exports = {
buildGradeTimeline,
classifyMove,
cleanHistory,
isUnder,
TOWARD_COLOR,
AGAINST_COLOR,
FLAT_COLOR,
MIN_POINTS,
};
+4
View File
@@ -35,6 +35,10 @@ const OPEN_ROUTES = [
'/dashboard',
'/slate',
'/scan',
/* Wave 4B — the Parlay Lab is the parlay-building funnel: anon/free reach it
(free 2-leg cap + payout-blur upsell), same monetization logic as scan +
dashboard, so it stays OPEN, not gated. */
'/parlay',
'/compare',
'/game',
'/pricing',