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