Wave 4B: Parlay Lab page + Live Grade-Shift timeline
TASK 1 — Parlay Lab (/parlay): a dedicated Correlation Builder with a
leg source INDEPENDENT of the live slate. Browses tonight's pre-graded
props from /api/snapshot/:sport (resolves players via /api/players/search),
adds legs through useParlay().addLeg (deduped by legKey), and renders the
PARLAY SLIP — combined grade, correlation, and payout read straight off
ParlayContext. Surfaces parlayService's correlation warning as the
CAUTION · CORRELATION FLAG, honors the tier leg-cap (free 2 / analyst 4 /
desk 6) and blurs the payout for free tier with the __goPaywall upsell.
Added /parlay to OPEN_ROUTES (free funnel, like scan/dashboard). Retired
the cleanly-dead ParlayTray.tsx (unmounted since Session 50). No new
proxies — reuses existing snapshot/search/parlay-grade endpoints.
TASK 2 — Live Grade-Shift timeline: web/src/lib/gradeShift.js (pure,
testable) builds a line/grade-movement timeline from already-emitted data
(intraday {t,line} history + revised_from_grade). Color law mirrors
ROW-GRAMMAR / StatStrip.LineSparkline: green = toward the graded side,
amber = against, dim = flat (never red). GradeShift.tsx renders it, shows
the original grade struck-through on a revision, and self-hides below 3
real points. Mounted in GradeResultCard (self-hides on the scan path,
which carries no captured history — honest, never fabricated).
Tests: tests/unit/gradeShift.test.js (15) + tests/unit/parlayLab.test.js
(13). Full suite 245 suites / 2989 tests green (baseline 243/2961).
Next build EXIT=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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