diff --git a/tests/unit/gradeShift.test.js b/tests/unit/gradeShift.test.js new file mode 100644 index 0000000..417fadd --- /dev/null +++ b/tests/unit/gradeShift.test.js @@ -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(' | null'); + expect(src).toContain('revisedFrom'); + }); +}); diff --git a/tests/unit/parlayLab.test.js b/tests/unit/parlayLab.test.js new file mode 100644 index 0000000..310ad7e --- /dev/null +++ b/tests/unit/parlayLab.test.js @@ -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); + }); +}); diff --git a/web/src/app/parlay/page.tsx b/web/src/app/parlay/page.tsx new file mode 100644 index 0000000..f6490dc --- /dev/null +++ b/web/src/app/parlay/page.tsx @@ -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("MLB"); + const [rawProps, setRawProps] = useState([]); + const [loading, setLoading] = useState(false); + const [q, setQ] = useState(""); + const [suggest, setSuggest] = useState([]); + + // 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(); + 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 ( +
+ {/* Header */} +
+

CORRELATION BUILDER

+

+ PARLAY MATH · THE STAKE-DOWN SIGNAL · TAP TO BUILD +

+
+ + {/* Sport tabs */} +
+ {SPORTS.map((s) => ( + + ))} +
+ + {/* Search — independent of the slate; resolves via /api/players/search */} + 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 && ( +
+ {suggest.join(" · ")} +
+ )} + +
+ {/* LEFT — tonight's A/B reads (the leg source) */} +
+
+ + TONIGHT'S GRADED READS + TAP TO ADD +
+ + {loading ? ( +
Loading tonight's reads…
+ ) : filtered.length === 0 ? ( +
+ {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.`} +
+ ) : ( +
+ {filtered.map((p) => { + const active = hasLeg(p.key); + return ( + + ); + })} +
+ )} +
+ + {/* RIGHT — the PARLAY SLIP (reads combined/correlation/payout from context) */} +
+
+ PARLAY SLIP + {legs.length} LEG{legs.length === 1 ? "" : "S"} +
+ + {/* legs */} +
+ {legs.length === 0 ? ( +
+ No legs yet — tap a graded read to start building. We grade the combined correlation and flag legs that secretly fight each other. +
+ ) : ( + legs.map((l) => ( +
+ {l.archetype && } + {l.player} + {statLabel(l.stat)} {l.direction === "under" ? "U" : "O"}{l.line} + + +
+ )) + )} +
+ + {/* leg-cap notice (tier-aware) */} + {legs.length >= maxLegs && ( +
+ {fullLab ? `Max ${maxLegs} legs on your plan.` : "Free tier caps at 2 legs — upgrade to Desk for 6."} +
+ )} + + {/* CAUTION · CORRELATION FLAG — surfaces parlayService's warning */} + {correlation?.warning && ( +
+
⚠ CAUTION · CORRELATION FLAG
+
{correlation.warning}
+
+ )} + + {/* combined / grade / stake */} +
+
+
CORRELATION
+
0.3 ? "var(--amber)" : "var(--g-a)" }}> + {legs.length >= 2 && correlation ? correlation.avg.toFixed(2) : "—"} +
+
+
+
GRADE
+ {legs.length >= 2 && combined ? : {grading ? "…" : "—"}} +
+
+
EST · $10
+ {legs.length < 2 ? ( + + ) : fullLab ? ( + {payout ? `$${payout.amount.toFixed(2)}` : grading ? "…" : "—"} + ) : ( + + $38.50 + + + )} +
+
+ + {legs.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/web/src/components/ParlayTray.tsx b/web/src/components/ParlayTray.tsx deleted file mode 100644 index 1fa6390..0000000 --- a/web/src/components/ParlayTray.tsx +++ /dev/null @@ -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(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 ( -
- - - - {legs.length === 0 ? ( - - ) : ( -
    - {legs.map((l) => ( - removeLeg(l.id)} /> - ))} -
- )} - - {parlayResult && ( -
-

- PARLAY GRADE -

-
- -
- {parlayResult.correlation_flags.length > 0 && ( -
-

- CORRELATION WARNINGS -

- {parlayResult.correlation_flags.map((f, i) => ( -

- {f.detail} -

- ))} -
- )} -
- )} - - {legs.length > 0 && ( -
- - -
- )} - -
- ); -} - -function EmptyTrayCopy() { - return ( -
-

- NO LEGS YET -

-

- Read a prop, hit Add to Parlay, and we'll build the slip here. - We grade overall correlation and surface the legs that secretly fight each other. -

-
- ); -} - -function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) { - return ( -
  • -
    -
    {leg.player}
    -
    - {leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')} -
    -
    -
    - - -
    -
  • - ); -} diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 143e38f..8d789e7 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -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({ ))} + {/* 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. */} + + {/* 5. SIGNAL BREAKDOWN */} {d.signals.length > 0 && (
    diff --git a/web/src/components/vyndr/GradeShift.tsx b/web/src/components/vyndr/GradeShift.tsx new file mode 100644 index 0000000..6abe937 --- /dev/null +++ b/web/src/components/vyndr/GradeShift.tsx @@ -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 | 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 ( +
    + {/* header + legend */} +
    + + GRADE HISTORY · LAST 24 + + + {tl.net.dir === "toward" ? "TOWARD" : tl.net.dir === "against" ? "AGAINST" : "FLAT"} {netSign}{tl.net.delta} + +
    + + {/* revision — original grade struck-through, never silently */} + {tl.revision && ( +
    + REVISED + {tl.revision.from} + + {tl.revision.to} +
    + )} + + {/* bars — one per capture, height by relative line, colored by segment dir */} +
    + {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 ( +
    0 ? "+" : ""}${p.delta})` : ""}`} + style={{ + flex: 1, + height: `${h}%`, + borderRadius: "3px 3px 0 0", + background: bg, + opacity: p.dir === "flat" ? 0.55 : 0.9, + }} + /> + ); + })} +
    + + {/* clock rail — lock -> now, mirrors the mockup's TIP/NOW */} +
    + + LOCK {tl.firstLine} + + + NOW {tl.lastLine} + +
    +
    + ); +} diff --git a/web/src/lib/gradeShift.js b/web/src/lib/gradeShift.js new file mode 100644 index 0000000..75436b3 --- /dev/null +++ b/web/src/lib/gradeShift.js @@ -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, +}; diff --git a/web/src/lib/routes.js b/web/src/lib/routes.js index a9f3228..dc1a5f3 100644 --- a/web/src/lib/routes.js +++ b/web/src/lib/routes.js @@ -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',