Session 50: Complete Parlay Lab (2215 tests)
Correlation-aware combined parlay grading — the Desk-tier differentiator.
- Correlation model (parlayService.js, added to S28 funcs): correlationScore
(game-aware 0.7/0.4/0.2/0.0), combinedGrade (avg penalized by avgCorr*0.5),
estimatedPayout (fair-odds product * (1-avgCorr) discount), correlationWarning,
gradeParlay.
- POST /api/parlay/grade (public, 2-6 legs) -> {combined,correlation,payout,legs}.
Fixed the Next proxy (was forwarding to /api/scan/parlay).
- ParlayContext: legs gained team/game/archetype; tier-aware maxLegs; auto-grades
the slip (debounced) when legs>=2 -> live combined/correlation/payout; hasLeg/
legKey/atCap.
- "+" button on every graded prop: StatStrip onAddLeg/isLegActive, wired by
vyndr/GameCard via useParlay (builds leg w/ team + game). GradeResultCard feeds
the same context from the scan page.
- ParlayPanel (replaces legacy ParlayTray): bottom slide-up w/ legs, combined
grade, correlation warning, est payout, CLEAR ALL + floating leg-count badge.
Tier-gated: free 2 legs (payout blurred -> Desk upsell), Analyst 4, Desk 6.
Backend 2185 -> 2215 tests (+30), 187 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-2
@@ -4,8 +4,41 @@
|
||||
2026-06-18
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v49.0 — Complete onboarding flow (prefs API, 3-step page, redirect,
|
||||
dashboard personalization, settings editor) + name micro-fixes.
|
||||
SHIP BUILD v50.0 — Parlay Lab: correlation-aware combined grading, live slip,
|
||||
"+" on every graded prop, tier-gated panel. The Desk-tier differentiator.
|
||||
|
||||
## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB
|
||||
|
||||
Backend 2185 → **2215 tests** (+30), 187 suites. Web build clean (exit 0).
|
||||
|
||||
### Phase 1 — correlation-score model (parlayService.js, ADDED to S28 funcs)
|
||||
Numeric, GAME-AWARE model: `correlationScore(l1,l2)` → 0.7 same-player/game /
|
||||
0.4 same-team/game / 0.2 same-game-diff-team / 0.0 diff-game. `combinedGrade` =
|
||||
leg-grade avg penalized by `avgCorrelation * 0.5`. `estimatedPayout` = product of
|
||||
per-grade fair odds × (1 − avgCorrelation) discount. `correlationWarning` →
|
||||
"⚠ N legs from {TEAM}…". `gradeParlay` bundles it all.
|
||||
|
||||
### Phase 2 — POST /api/parlay/grade
|
||||
Returns `{ combined, correlation, payout, legs }` (2–6 legs, public/stateless).
|
||||
The Next `/api/parlay/grade` proxy was forwarding to the wrong upstream
|
||||
(`/api/scan/parlay`) — fixed to the new route.
|
||||
|
||||
### Phase 3 — ParlayContext extended
|
||||
`ParlayLeg` gained team/game/archetype; MAX_LEGS now 6 with a tier-aware
|
||||
`maxLegs` (set by the panel). Auto-grades the slip (debounced) via the endpoint
|
||||
when legs ≥ 2 → live `combined`/`correlation`/`payout`. + `hasLeg`/`legKey`/`atCap`.
|
||||
|
||||
### Phase 4 — "+" buttons
|
||||
`StatStrip` renders a "+"/"✓" per graded prop (toggles add/remove); `vyndr/GameCard`
|
||||
wires it via `useParlay` (builds a leg with team + game id). `GradeResultCard`'s
|
||||
existing "Add to Parlay" feeds the same context from the scan page.
|
||||
|
||||
### Phase 5 — ParlayPanel (replaces legacy ParlayTray in the layout)
|
||||
Bottom slide-up: legs (archetype + grade + remove), correlation warning, combined
|
||||
grade, est. payout, CLEAR ALL + a floating leg-count badge (bottom-right) when
|
||||
closed. Tier-gated: free 2 legs (payout blurred → Desk upgrade), Analyst 4, Desk 6.
|
||||
|
||||
## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW
|
||||
|
||||
## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW
|
||||
|
||||
|
||||
@@ -533,6 +533,29 @@ snapshot, locked to the line, and read from cache.
|
||||
- **Name micro-fix:** `playerName.js` `collapseInitials` merges "J C" → "JC"
|
||||
(display + key) so space-separated initials dedupe.
|
||||
|
||||
## Parlay Lab (Session 50 — non-obvious)
|
||||
- **Correlation model** lives in `src/services/parlayService.js` (ADDED to the
|
||||
S28 categorical matrix — both coexist). `correlationScore(l1,l2)` is numeric +
|
||||
GAME-aware (0.7 same-player/game, 0.4 same-team/game, 0.2 same-game, 0.0 diff-
|
||||
game). `combinedGrade` penalizes the leg-grade avg by `avgCorrelation*0.5`;
|
||||
`estimatedPayout` = Πfair-odds × (1−avgCorr) discount. `gradeParlay` is the
|
||||
bundle the route returns. Leg shape: `{ player, team, game, stat, grade }`.
|
||||
- **POST /api/parlay/grade** (`src/routes/parlay.js`) — public, 2–6 legs, returns
|
||||
`{ combined, correlation, payout, legs }`. The Next proxy was pointed at the
|
||||
WRONG upstream (`/api/scan/parlay`); it now forwards to `/api/parlay/grade`.
|
||||
- **ParlayContext auto-grades** the slip (debounced 250ms) via that endpoint
|
||||
whenever legs change (≥2). `combined`/`correlation`/`payout` are live on the
|
||||
context — don't call the endpoint from components, read the context.
|
||||
- **Leg cap is tier-aware via the context.** `maxLegs` defaults 6; `ParlayPanel`
|
||||
sets it from `useAuth().tier` (free 2 / analyst 4 / desk 6). `addLeg` reads a
|
||||
ref so it stays a stable callback. The "+" buttons no-op at the cap.
|
||||
- **"+" wiring:** `StatStrip` takes `onAddLeg`/`isLegActive`; `vyndr/GameCard`
|
||||
provides them via `useParlay` (it owns the game id + team). `legKey` =
|
||||
`player|stat|line|direction` is the dedupe key (exported from the context).
|
||||
- **ParlayPanel** (mounted in layout, REPLACED `ParlayTray` which called a
|
||||
then-missing `/grade` endpoint). Floating badge bottom-right when closed; free
|
||||
tier blurs the payout with a `window.__goPaywall` upsell.
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -33,6 +33,22 @@ router.post('/calculate', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/parlay/grade (Session 50) — the Parlay Lab. Returns
|
||||
// { combined, correlation, payout, legs }. Public + stateless (the lightweight
|
||||
// math); the Lab UI is tier-gated on the frontend. 2–6 legs.
|
||||
router.post('/grade', (req, res) => {
|
||||
const legs = req.body?.legs;
|
||||
if (!Array.isArray(legs) || legs.length < 2 || legs.length > 6) {
|
||||
return res.status(400).set(MISSION_HEADER).json({ error: 'A parlay needs 2–6 legs.' });
|
||||
}
|
||||
try {
|
||||
const betAmount = Number(req.body?.betAmount) || 10;
|
||||
return res.set(MISSION_HEADER).json(parlayService.gradeParlay(legs, betAmount));
|
||||
} catch (err) {
|
||||
return res.status(400).set(MISSION_HEADER).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/suggestions', (req, res) => {
|
||||
try {
|
||||
const { props, legs, max } = req.body || {};
|
||||
|
||||
@@ -185,9 +185,148 @@ function suggestParlays(props, { legs = 3, max = 3 } = {}) {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Session 50 — Parlay Lab correlation-score model.
|
||||
//
|
||||
// A numeric, GAME-AWARE correlation model (0.0 independent … 1.0 perfectly
|
||||
// correlated) layered on top of the S28 categorical matrix. Legs are the light
|
||||
// UI shape { player, team, game, stat, grade }.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grade → 0..1 score (A+ = 1.0 … F = 0.0), reusing the 13-step order. */
|
||||
function gradeScore(grade) {
|
||||
return gradeToNumeric(grade) / (GRADE_ORDER.length - 1);
|
||||
}
|
||||
/** 0..1 score → letter grade. */
|
||||
function scoreToGrade(score) {
|
||||
const s = Math.max(0, Math.min(1, Number(score) || 0));
|
||||
return numericToGrade(s * (GRADE_ORDER.length - 1));
|
||||
}
|
||||
|
||||
const sameVal = (a, b) => a != null && b != null && String(a).toLowerCase() === String(b).toLowerCase();
|
||||
|
||||
/**
|
||||
* Pairwise correlation between two legs (0.0–1.0):
|
||||
* 0.7 same player, same game (different stats move together)
|
||||
* 0.4 same team, same game (team performance drives both)
|
||||
* 0.2 same game, different teams (game pace, mostly independent)
|
||||
* 0.0 different games (fully independent — what books price)
|
||||
*/
|
||||
function correlationScore(leg1, leg2) {
|
||||
if (!leg1 || !leg2) return 0;
|
||||
if (!sameVal(leg1.game, leg2.game)) return 0; // different (or unknown) game
|
||||
if (sameVal(leg1.player, leg2.player)) return 0.7;
|
||||
if (sameVal(leg1.team, leg2.team)) return 0.4;
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
function pairwise(legs) {
|
||||
const out = [];
|
||||
for (let i = 0; i < legs.length; i += 1) {
|
||||
for (let j = i + 1; j < legs.length; j += 1) out.push(correlationScore(legs[i], legs[j]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined parlay grade — the leg-grade average PENALIZED by correlation.
|
||||
* penalty = avgCorrelation * 0.5 (each 0.1 correlation ≈ 0.05 grade-score drop).
|
||||
*/
|
||||
function combinedGrade(legs) {
|
||||
const list = Array.isArray(legs) ? legs : [];
|
||||
if (list.length === 0) return { grade: '—', score: 0, penalty: 0, maxCorrelation: 0, avgCorrelation: 0 };
|
||||
const rawAvg = list.reduce((s, l) => s + gradeScore(l.grade), 0) / list.length;
|
||||
const pairs = pairwise(list);
|
||||
const maxCorrelation = pairs.length ? Math.max(...pairs) : 0;
|
||||
const avgCorrelation = pairs.length ? pairs.reduce((a, b) => a + b, 0) / pairs.length : 0;
|
||||
const penalty = avgCorrelation * 0.5;
|
||||
const score = Math.max(0, Math.min(1, rawAvg - penalty));
|
||||
return {
|
||||
grade: scoreToGrade(score),
|
||||
score: Math.round(score * 1000) / 1000,
|
||||
penalty: Math.round(penalty * 1000) / 1000,
|
||||
maxCorrelation: Math.round(maxCorrelation * 100) / 100,
|
||||
avgCorrelation: Math.round(avgCorrelation * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// Fair decimal odds the model assigns each grade (lower grade = longer odds).
|
||||
const FAIR_ODDS = {
|
||||
'A+': 1.15, A: 1.25, 'A-': 1.35, 'B+': 1.45, B: 1.70, 'B-': 1.90,
|
||||
'C+': 2.0, C: 2.10, 'C-': 2.5, 'D+': 2.8, D: 3.0, 'D-': 4.0, F: 5.0,
|
||||
};
|
||||
function fairOdds(grade) {
|
||||
return FAIR_ODDS[String(grade || 'C').toUpperCase()] ?? 2.1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated payout. Books price parlays as independent (product of fair odds);
|
||||
* VYNDR discounts that by the slip's average correlation.
|
||||
*/
|
||||
function estimatedPayout(legs, betAmount = 10) {
|
||||
const list = Array.isArray(legs) ? legs : [];
|
||||
const bet = Number(betAmount) || 10;
|
||||
const fairMultiplier = list.reduce((m, l) => m * fairOdds(l.grade), 1);
|
||||
const pairs = pairwise(list);
|
||||
const avgCorrelation = pairs.length ? pairs.reduce((a, b) => a + b, 0) / pairs.length : 0;
|
||||
const correlationDiscount = Math.max(0.5, Math.min(1, 1 - avgCorrelation));
|
||||
const multiplier = fairMultiplier * correlationDiscount;
|
||||
return {
|
||||
payout: Math.round(bet * multiplier * 100) / 100,
|
||||
multiplier: Math.round(multiplier * 100) / 100,
|
||||
fairMultiplier: Math.round(fairMultiplier * 100) / 100,
|
||||
correlationDiscount: Math.round(correlationDiscount * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Human warning for the most-correlated cluster, or null when independent. */
|
||||
function correlationWarning(legs) {
|
||||
const list = Array.isArray(legs) ? legs : [];
|
||||
if (list.length < 2) return null;
|
||||
const byTeam = {};
|
||||
for (const l of list) {
|
||||
if (!l.team || !l.game) continue;
|
||||
const k = `${String(l.team).toUpperCase()}|${l.game}`;
|
||||
(byTeam[k] = byTeam[k] || []).push(l);
|
||||
}
|
||||
let worst = null;
|
||||
for (const [k, group] of Object.entries(byTeam)) {
|
||||
if (group.length >= 2 && (!worst || group.length > worst.count)) worst = { team: k.split('|')[0], count: group.length };
|
||||
}
|
||||
if (worst) return `⚠ ${worst.count} legs from ${worst.team} — high correlation`;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
for (let j = i + 1; j < list.length; j += 1) {
|
||||
if (sameVal(list[i].game, list[j].game)) return '⚠ 2 legs from the same game — correlated';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The full Parlay-Lab analysis the /grade route returns. */
|
||||
function gradeParlay(legs, betAmount = 10) {
|
||||
const combined = combinedGrade(legs);
|
||||
const payout = estimatedPayout(legs, betAmount);
|
||||
return {
|
||||
combined: { grade: combined.grade, score: combined.score, penalty: combined.penalty },
|
||||
correlation: { max: combined.maxCorrelation, avg: combined.avgCorrelation, warning: correlationWarning(legs) },
|
||||
payout: { amount: payout.payout, multiplier: payout.multiplier, fairMultiplier: payout.fairMultiplier, discount: payout.correlationDiscount },
|
||||
legs: (Array.isArray(legs) ? legs : []).map((l) => ({ ...l, score: gradeScore(l.grade) })),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculateParlay,
|
||||
detectCorrelation,
|
||||
suggestParlays,
|
||||
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS },
|
||||
// Session 50 — Parlay Lab model
|
||||
correlationScore,
|
||||
combinedGrade,
|
||||
estimatedPayout,
|
||||
correlationWarning,
|
||||
gradeParlay,
|
||||
gradeToNumeric,
|
||||
numericToGrade,
|
||||
gradeScore,
|
||||
scoreToGrade,
|
||||
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS, FAIR_ODDS },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Session 50 — POST /api/parlay/grade (the Parlay Lab endpoint).
|
||||
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
|
||||
const legs = (n) => Array.from({ length: n }, (_, i) => ({ player: `P${i}`, team: i % 2 ? 'BOS' : 'NYY', game: 'NYY@BOS', stat: 'hits', line: 1.5, grade: i % 2 ? 'B+' : 'A' }));
|
||||
|
||||
describe('POST /api/parlay/grade', () => {
|
||||
it('returns combined grade + correlation + payout for a valid parlay', async () => {
|
||||
const res = await request(app).post('/api/parlay/grade').send({ legs: legs(3), betAmount: 10 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.combined.grade).toBeTruthy();
|
||||
expect(typeof res.body.correlation.avg).toBe('number');
|
||||
expect(res.body.payout.amount).toBeGreaterThan(0);
|
||||
expect(res.body.legs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles 2 and 6 legs', async () => {
|
||||
expect((await request(app).post('/api/parlay/grade').send({ legs: legs(2) })).status).toBe(200);
|
||||
expect((await request(app).post('/api/parlay/grade').send({ legs: legs(6) })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects < 2 or > 6 legs (400)', async () => {
|
||||
expect((await request(app).post('/api/parlay/grade').send({ legs: legs(1) })).status).toBe(400);
|
||||
expect((await request(app).post('/api/parlay/grade').send({ legs: legs(7) })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Session 50 — Parlay Lab correlation-score model.
|
||||
|
||||
const svc = require('../../src/services/parlayService');
|
||||
|
||||
const leg = (over) => ({ player: 'P', team: 'NYY', game: 'NYY@BOS', stat: 'hits', grade: 'A', ...over });
|
||||
|
||||
describe('correlationScore', () => {
|
||||
it('same player, same game → 0.7', () => {
|
||||
expect(svc.correlationScore(leg({ stat: 'hits' }), leg({ stat: 'total_bases' }))).toBe(0.7);
|
||||
});
|
||||
it('same team, same game, different players → 0.4', () => {
|
||||
expect(svc.correlationScore(leg({ player: 'A' }), leg({ player: 'B' }))).toBe(0.4);
|
||||
});
|
||||
it('same game, different teams → 0.2', () => {
|
||||
expect(svc.correlationScore(leg({ player: 'A', team: 'NYY' }), leg({ player: 'B', team: 'BOS' }))).toBe(0.2);
|
||||
});
|
||||
it('different games → 0.0', () => {
|
||||
expect(svc.correlationScore(leg({ game: 'NYY@BOS' }), leg({ game: 'LAD@SF', player: 'B', team: 'LAD' }))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combinedGrade', () => {
|
||||
it('3 independent A legs → A (no penalty)', () => {
|
||||
const r = svc.combinedGrade([
|
||||
{ grade: 'A', game: 'g1', team: 't1', player: 'a' },
|
||||
{ grade: 'A', game: 'g2', team: 't2', player: 'b' },
|
||||
{ grade: 'A', game: 'g3', team: 't3', player: 'c' },
|
||||
]);
|
||||
expect(r.grade).toBe('A');
|
||||
expect(r.penalty).toBe(0);
|
||||
});
|
||||
it('3 same-team same-game A legs → penalized below A', () => {
|
||||
const r = svc.combinedGrade([
|
||||
{ grade: 'A', game: 'g1', team: 'NYY', player: 'a' },
|
||||
{ grade: 'A', game: 'g1', team: 'NYY', player: 'b' },
|
||||
{ grade: 'A', game: 'g1', team: 'NYY', player: 'c' },
|
||||
]);
|
||||
expect(r.avgCorrelation).toBe(0.4);
|
||||
expect(r.penalty).toBeGreaterThan(0);
|
||||
expect(['B+', 'B', 'B-', 'A-']).toContain(r.grade);
|
||||
expect(svc.gradeScore(r.grade)).toBeLessThan(svc.gradeScore('A'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatedPayout', () => {
|
||||
it('returns multiplier + discount for a 3-leg parlay', () => {
|
||||
const r = svc.estimatedPayout([{ grade: 'A' }, { grade: 'B+' }, { grade: 'A' }], 10);
|
||||
expect(r.fairMultiplier).toBeCloseTo(1.25 * 1.45 * 1.25, 2);
|
||||
expect(r.correlationDiscount).toBe(1); // independent
|
||||
expect(r.payout).toBeGreaterThan(10);
|
||||
});
|
||||
it('discounts correlated slips', () => {
|
||||
const corr = svc.estimatedPayout([{ grade: 'A', game: 'g', team: 'NYY', player: 'a' }, { grade: 'A', game: 'g', team: 'NYY', player: 'b' }], 10);
|
||||
expect(corr.correlationDiscount).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('correlationWarning', () => {
|
||||
it('flags 2+ legs from the same team', () => {
|
||||
const w = svc.correlationWarning([
|
||||
{ game: 'g', team: 'PHI', player: 'a' }, { game: 'g', team: 'PHI', player: 'b' }, { game: 'g2', team: 'NYY', player: 'c' },
|
||||
]);
|
||||
expect(w).toBe('⚠ 2 legs from PHI — high correlation');
|
||||
});
|
||||
it('flags same-game legs on different teams', () => {
|
||||
expect(svc.correlationWarning([{ game: 'g', team: 'A', player: 'x' }, { game: 'g', team: 'B', player: 'y' }]))
|
||||
.toBe('⚠ 2 legs from the same game — correlated');
|
||||
});
|
||||
it('null when all legs are independent', () => {
|
||||
expect(svc.correlationWarning([{ game: 'g1', team: 'A', player: 'x' }, { game: 'g2', team: 'B', player: 'y' }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeParlay (full analysis)', () => {
|
||||
it('returns combined + correlation + payout + legs', () => {
|
||||
const r = svc.gradeParlay([
|
||||
{ grade: 'A', game: 'g', team: 'NYY', player: 'a', stat: 'hits', line: 1.5 },
|
||||
{ grade: 'B+', game: 'g', team: 'NYY', player: 'b', stat: 'tb', line: 1.5 },
|
||||
], 10);
|
||||
expect(r.combined.grade).toBeTruthy();
|
||||
expect(r.correlation.warning).toContain('NYY');
|
||||
expect(r.payout.amount).toBeGreaterThan(0);
|
||||
expect(r.legs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// Session 50 — Parlay Lab UI: context behaviors + components (source-asserted,
|
||||
// matching the repo's frontend test pattern) + legKey logic.
|
||||
|
||||
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('ParlayContext (Session 50 extensions)', () => {
|
||||
const src = read('contexts/ParlayContext.tsx');
|
||||
it('legs carry team/game/archetype for the correlation model', () => {
|
||||
expect(src).toContain('team?: string;');
|
||||
expect(src).toContain('game?: string;');
|
||||
expect(src).toContain('archetype?: string;');
|
||||
});
|
||||
it('addLeg dedupes by player|stat|line|direction and caps at maxLegs', () => {
|
||||
expect(src).toContain('legKey');
|
||||
expect(src).toContain('prev.length >= maxLegsRef.current');
|
||||
});
|
||||
it('auto-grades via /api/parlay/grade when legs >= 2', () => {
|
||||
expect(src).toContain("fetch('/api/parlay/grade'");
|
||||
expect(src).toContain('if (legs.length < 2)');
|
||||
expect(src).toContain('setCombined');
|
||||
});
|
||||
it('exposes combined/correlation/payout + tier cap', () => {
|
||||
for (const k of ['combined', 'correlation', 'payout', 'maxLegs', 'setMaxLegs', 'atCap', 'hasLeg']) {
|
||||
expect(src).toContain(k);
|
||||
}
|
||||
});
|
||||
it('removeLeg + clear reset state', () => {
|
||||
expect(src).toContain('const removeLeg');
|
||||
expect(src).toContain('const clear = useCallback(() => setLegs([])');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatStrip "+" button', () => {
|
||||
const src = read('components/vyndr/StatStrip.tsx');
|
||||
it('renders an add/remove parlay button on graded props', () => {
|
||||
expect(src).toContain('onAddLeg');
|
||||
expect(src).toContain('isLegActive');
|
||||
expect(src).toContain('<ParlayBtn p={p} />');
|
||||
expect(src).toContain("active ? '✓' : '+'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameCard wires the "+" to the parlay', () => {
|
||||
const src = read('components/vyndr/GameCard.tsx');
|
||||
it('uses useParlay and builds a leg with team + game', () => {
|
||||
expect(src).toContain('useParlay');
|
||||
expect(src).toContain('stripHandlers');
|
||||
expect(src).toContain('game: gameId');
|
||||
});
|
||||
it('toggles the leg (add if absent, remove if present)', () => {
|
||||
expect(src).toContain('if (existing) removeLeg(existing.id); else addLeg(leg);');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradeResultCard add-to-parlay (scan page)', () => {
|
||||
it('GradeResultCard has the Add to Parlay action', () => {
|
||||
expect(read('components/vyndr/GradeResultCard.tsx')).toContain('Add to Parlay');
|
||||
});
|
||||
it('scan page wires onAddToParlay → addLeg', () => {
|
||||
const src = read('app/scan/page.tsx');
|
||||
expect(src).toContain('onAddToParlay');
|
||||
expect(src).toContain('addLeg(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParlayPanel', () => {
|
||||
const src = read('components/vyndr/ParlayPanel.tsx');
|
||||
it('renders legs (player + grade), combined grade, correlation warning, payout', () => {
|
||||
expect(src).toContain('PARLAY LAB');
|
||||
expect(src).toContain('COMBINED GRADE');
|
||||
expect(src).toContain('correlation?.warning');
|
||||
expect(src).toContain('EST. PAYOUT');
|
||||
});
|
||||
it('floating badge with leg count when closed', () => {
|
||||
expect(src).toContain('Open Parlay Lab');
|
||||
expect(src).toContain('{legs.length}');
|
||||
});
|
||||
it('tier-gates: free blurs payout + caps at 2, desk/analyst full', () => {
|
||||
expect(src).toContain('TIER_MAX = { free: 2, analyst: 4, desk: 6 }');
|
||||
expect(src).toContain("filter: 'blur(6px)'");
|
||||
expect(src).toContain('__goPaywall');
|
||||
expect(src).toContain('setMaxLegs(tierMaxLegs');
|
||||
});
|
||||
it('CLEAR ALL resets the slip', () => {
|
||||
expect(src).toContain('CLEAR ALL');
|
||||
expect(src).toContain('onClick={clear}');
|
||||
});
|
||||
it('is mounted globally in the layout', () => {
|
||||
const layout = read('app/layout.tsx');
|
||||
expect(layout).toContain('<ParlayPanel />');
|
||||
expect(layout).not.toContain('<ParlayTray />');
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,56 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
interface Leg {
|
||||
sport?: 'NBA' | 'MLB' | 'WNBA';
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
}
|
||||
|
||||
interface Body {
|
||||
legs: Leg[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parlay Lab grade proxy (Session 50) — forwards POST /api/parlay/grade to the
|
||||
* Express correlation model. Public + stateless (the Lab UI is tier-gated on the
|
||||
* frontend); body carries { legs, betAmount }.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Log in to grade parlays.');
|
||||
|
||||
let body: Body;
|
||||
const body = await req.text();
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return jsonError(400, 'Invalid JSON.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.legs) || body.legs.length < 2 || body.legs.length > 12) {
|
||||
return jsonError(400, 'Send 2–12 legs.');
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/scan/parlay`, {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/parlay/grade`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}),
|
||||
},
|
||||
body: JSON.stringify({ legs: body.legs }),
|
||||
body,
|
||||
});
|
||||
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (data as { error?: string }).error || 'The engine hit a wall on this parlay.' },
|
||||
{ status: upstream.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return jsonError(502, 'The engine hit a wall on this parlay.');
|
||||
return NextResponse.json({ error: 'The engine hit a wall on this parlay.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import Footer from '@/components/Footer';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import HashRedirect from '@/components/vyndr/HashRedirect';
|
||||
import GlobalHosts from '@/components/vyndr/GlobalHosts';
|
||||
import ParlayTray from '@/components/ParlayTray';
|
||||
// Session 50 — the Parlay Lab supersedes the legacy ParlayTray.
|
||||
import ParlayPanel from '@/components/vyndr/ParlayPanel';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import InstallPrompt from '@/components/InstallPrompt';
|
||||
import PushPrompt from '@/components/PushPrompt';
|
||||
@@ -141,7 +142,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<main style={{ paddingTop: 124, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
||||
</AuthGate>
|
||||
<Footer />
|
||||
<ParlayTray />
|
||||
<ParlayPanel />
|
||||
<BottomTabBar />
|
||||
<InstallPrompt />
|
||||
<PushPrompt />
|
||||
|
||||
@@ -7,6 +7,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { isPreferredBook } from '@/lib/books';
|
||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||
|
||||
export interface GameLine {
|
||||
book: string;
|
||||
@@ -120,6 +121,26 @@ function PropRow({ prop: p, onAddParlay }: { prop: GameProp; onAddParlay?: (p: G
|
||||
/** Dashboard / Slate game card (§7) — game-lines grid w/ best-line highlight,
|
||||
* graded props, inline streaks, live indicator. */
|
||||
export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }: GameCardProps) {
|
||||
// Session 50 — Parlay Lab "+" wiring. Builds a leg from the strip + game.
|
||||
const { addLeg, removeLeg, legs, hasLeg } = useParlay();
|
||||
const sportU = (g.sport || 'nba').toUpperCase();
|
||||
const gameId = `${g.away.abbr} @ ${g.home.abbr}`;
|
||||
const toLeg = (ps: PlayerStrip, p: StripProp) => ({
|
||||
sport: (sportU === 'MLB' || sportU === 'WNBA' ? sportU : 'NBA') as 'NBA' | 'MLB' | 'WNBA',
|
||||
player: ps.player, team: ps.team, game: gameId, archetype: ps.archetype?.primary,
|
||||
stat: String(p.stat), line: Number(p.line) || 0,
|
||||
direction: (String(p.side).toUpperCase() === 'U' ? 'under' : 'over') as 'over' | 'under',
|
||||
grade: String(p.grade || 'C'), confidence: 60,
|
||||
});
|
||||
const stripHandlers = (ps: PlayerStrip) => ({
|
||||
onAddLeg: (p: StripProp) => {
|
||||
const leg = toLeg(ps, p);
|
||||
const k = legKey(leg);
|
||||
const existing = legs.find((l) => legKey(l) === k);
|
||||
if (existing) removeLeg(existing.id); else addLeg(leg);
|
||||
},
|
||||
isLegActive: (p: StripProp) => hasLeg(legKey(toLeg(ps, p))),
|
||||
});
|
||||
return (
|
||||
<div className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{/* HEADER */}
|
||||
@@ -211,6 +232,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
props={ps.props}
|
||||
variant="compact"
|
||||
onPlayerClick={() => { window.location.href = playerHref(ps.player, g.sport); }}
|
||||
{...stripHandlers(ps)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
|
||||
/**
|
||||
* ParlayPanel (Session 50) — the Parlay Lab. A bottom slide-up that shows the
|
||||
* selected legs, the correlation-aware combined grade, the correlation warning,
|
||||
* and the estimated payout. Tier-gated: free 2 legs (payout blurred), Analyst 4,
|
||||
* Desk 6. A floating badge (bottom-right) opens it when legs are pending.
|
||||
* Mounted globally in the layout — visible across pages.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ParlayPanel() {
|
||||
const { legs, isOpen, toggle, close, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay();
|
||||
const { tier } = useAuth();
|
||||
const fullLab = tier === 'desk' || tier === 'analyst';
|
||||
|
||||
// Sync the leg cap to the user's tier.
|
||||
useEffect(() => { setMaxLegs(tierMaxLegs(tier || 'free')); }, [tier, setMaxLegs]);
|
||||
|
||||
if (legs.length === 0) return null;
|
||||
|
||||
// Floating trigger when closed.
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={`Open Parlay Lab (${legs.length} legs)`}
|
||||
style={{
|
||||
position: 'fixed', bottom: 84, right: 18, zIndex: 60, width: 56, height: 56, borderRadius: '50%',
|
||||
background: 'var(--g-a)', color: '#06060B', fontWeight: 800, fontSize: 18, border: 'none', cursor: 'pointer',
|
||||
boxShadow: '0 0 22px rgba(0,212,160,.45)', fontFamily: 'var(--mono)',
|
||||
}}
|
||||
>
|
||||
{legs.length}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Parlay Lab"
|
||||
style={{
|
||||
position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 70,
|
||||
maxWidth: 520, margin: '0 auto', background: 'var(--bg-1)',
|
||||
borderTop: '1px solid var(--g-a)', borderLeft: '1px solid var(--border-hi)', borderRight: '1px solid var(--border-hi)',
|
||||
borderTopLeftRadius: 16, borderTopRightRadius: 16, boxShadow: '0 -12px 40px rgba(0,0,0,.6)',
|
||||
padding: '16px 16px 22px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span className="mono" style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)' }}>
|
||||
PARLAY LAB ({legs.length})
|
||||
</span>
|
||||
<button type="button" onClick={close} aria-label="Close" className="mono" style={{ background: 'transparent', border: 'none', color: 'var(--text-1)', cursor: 'pointer', fontSize: 16 }}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Legs */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginBottom: 12, maxHeight: 220, overflowY: 'auto' }}>
|
||||
{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="full" />}
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }}>{l.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{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 leg" className="mono" style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: 'var(--miss)', cursor: 'pointer', fontSize: 14 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{legs.length >= maxLegs && (
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--amber)', marginBottom: 10 }}>
|
||||
{fullLab ? `Max ${maxLegs} legs on your plan.` : 'Free tier caps at 2 legs — upgrade to Desk for 6.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Correlation warning */}
|
||||
{correlation?.warning && (
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--amber)', marginBottom: 10 }}>{correlation.warning}</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
{legs.length >= 2 ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 14 }}>
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.08em', marginBottom: 4 }}>COMBINED GRADE</div>
|
||||
{combined ? <GradeBadge grade={combined.grade} size="md" glow /> : <span className="mono" style={{ color: 'var(--text-2)' }}>{grading ? '…' : '—'}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.08em', marginBottom: 4 }}>CORRELATION</div>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: (correlation?.avg ?? 0) > 0.3 ? 'var(--amber)' : 'var(--g-a)' }}>
|
||||
{correlation ? `${correlation.avg > 0.5 ? 'High' : correlation.avg > 0.2 ? 'Medium' : 'Low'} (${correlation.avg.toFixed(2)})` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.08em', marginBottom: 4 }}>EST. PAYOUT · $10</div>
|
||||
{fullLab ? (
|
||||
<div className="mono" style={{ fontSize: 15, fontWeight: 700, color: 'var(--g-a)' }}>
|
||||
{payout ? `$${payout.amount.toFixed(2)} (${payout.multiplier.toFixed(2)}x)` : (grading ? '…' : '—')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="mono" style={{ fontSize: 15, fontWeight: 700, color: 'var(--g-a)', filter: 'blur(6px)', userSelect: 'none' }}>$38.50 (3.85x)</div>
|
||||
<button type="button" onClick={() => typeof window !== 'undefined' && window.__goPaywall && window.__goPaywall()} className="mono"
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', color: 'var(--g-a)', cursor: 'pointer', fontSize: 11, fontWeight: 700 }}>
|
||||
Upgrade to Desk for the full Parlay Lab →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>Add another leg to see the combined grade.</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={clear} className="mono"
|
||||
style={{ width: '100%', padding: '11px', borderRadius: 9, fontWeight: 700, letterSpacing: '0.06em', fontSize: 12, cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', color: 'var(--miss)' }}>
|
||||
CLEAR ALL
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ interface StatStripProps {
|
||||
meta?: string; // expanded: "ATL · 3B · #27"
|
||||
variant?: 'compact' | 'expanded';
|
||||
onPlayerClick?: () => void;
|
||||
// Session 50 — Parlay Lab "+" per graded prop (wired by the card via useParlay).
|
||||
onAddLeg?: (p: StripProp) => void;
|
||||
isLegActive?: (p: StripProp) => boolean;
|
||||
}
|
||||
|
||||
const Sep = ({ ch = '|' }: { ch?: string }) => (
|
||||
@@ -54,7 +57,31 @@ export default function StatStrip({
|
||||
meta,
|
||||
variant = 'compact',
|
||||
onPlayerClick,
|
||||
onAddLeg,
|
||||
isLegActive,
|
||||
}: StatStripProps) {
|
||||
const ParlayBtn = ({ p }: { p: StripProp }) => {
|
||||
if (!onAddLeg || !p.grade) return null;
|
||||
const active = isLegActive ? isLegActive(p) : false;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onAddLeg(p); }}
|
||||
title={active ? 'Remove from Parlay' : 'Add to Parlay'}
|
||||
aria-label={active ? 'Remove from Parlay' : 'Add to Parlay'}
|
||||
className="mono"
|
||||
style={{
|
||||
cursor: 'pointer', width: 20, height: 20, borderRadius: 5, lineHeight: 1,
|
||||
background: active ? 'color-mix(in srgb, var(--g-a) 18%, transparent)' : 'var(--bg-2)',
|
||||
border: `1px solid ${active ? 'var(--g-a)' : 'var(--border-hi)'}`,
|
||||
color: 'var(--g-a)', fontSize: 13, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{active ? '✓' : '+'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const last10Str = typeof last10 === 'string'
|
||||
? last10
|
||||
: Array.isArray(last10)
|
||||
@@ -144,6 +171,7 @@ export default function StatStrip({
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||
{p.stat} {p.side}{p.line} {p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||
<ParlayBtn p={p} />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
@@ -167,6 +195,7 @@ export default function StatStrip({
|
||||
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
|
||||
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
|
||||
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||
<ParlayBtn p={p} />
|
||||
{p.gradedAt?.ago && (
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface ParlayLeg {
|
||||
id: string;
|
||||
@@ -11,8 +11,17 @@ export interface ParlayLeg {
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
confidence: number;
|
||||
// Session 50 — needed for the correlation model.
|
||||
team?: string;
|
||||
game?: string;
|
||||
archetype?: string;
|
||||
}
|
||||
|
||||
// Session 50 — the combined analysis from /api/parlay/grade.
|
||||
export interface ParlayCombined { grade: string; score: number; penalty: number }
|
||||
export interface ParlayCorrelation { max: number; avg: number; warning: string | null }
|
||||
export interface ParlayPayout { amount: number; multiplier: number; fairMultiplier?: number; discount?: number }
|
||||
|
||||
interface ParlayContextValue {
|
||||
legs: ParlayLeg[];
|
||||
legCount: number;
|
||||
@@ -23,16 +32,35 @@ interface ParlayContextValue {
|
||||
addLeg: (leg: Omit<ParlayLeg, 'id'>) => void;
|
||||
removeLeg: (id: string) => void;
|
||||
clear: () => void;
|
||||
// Session 50 — live combined metrics (auto-graded when legs ≥ 2).
|
||||
combined: ParlayCombined | null;
|
||||
correlation: ParlayCorrelation | null;
|
||||
payout: ParlayPayout | null;
|
||||
grading: boolean;
|
||||
hasLeg: (key: string) => boolean;
|
||||
// Session 50 — tier-aware leg cap (set by the panel from useAuth).
|
||||
maxLegs: number;
|
||||
setMaxLegs: (n: number) => void;
|
||||
atCap: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bbk:parlay';
|
||||
const MAX_LEGS = 12;
|
||||
const MAX_LEGS = 6; // Session 50 — Desk cap; lower tiers gated in the UI.
|
||||
|
||||
/** Stable de-dupe key for a leg (player|stat|line|direction). */
|
||||
export function legKey(l: { player: string; stat: string; line: number; direction: string }) {
|
||||
return `${l.player}|${l.stat}|${l.line}|${l.direction}`;
|
||||
}
|
||||
|
||||
const ParlayContext = createContext<ParlayContextValue | null>(null);
|
||||
|
||||
export default function ParlayProvider({ children }: { children: React.ReactNode }) {
|
||||
const [legs, setLegs] = useState<ParlayLeg[]>([]);
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
// Session 50 — tier cap (default Desk 6; the panel lowers it for free/analyst).
|
||||
const [maxLegs, setMaxLegs] = useState(MAX_LEGS);
|
||||
const maxLegsRef = useRef(MAX_LEGS);
|
||||
useEffect(() => { maxLegsRef.current = maxLegs; }, [maxLegs]);
|
||||
|
||||
// Restore from localStorage so a refresh doesn't drop the tray
|
||||
useEffect(() => {
|
||||
@@ -59,7 +87,7 @@ export default function ParlayProvider({ children }: { children: React.ReactNode
|
||||
|
||||
const addLeg = useCallback((leg: Omit<ParlayLeg, 'id'>) => {
|
||||
setLegs((prev) => {
|
||||
if (prev.length >= MAX_LEGS) return prev;
|
||||
if (prev.length >= maxLegsRef.current) return prev;
|
||||
// De-dupe by player+stat+line+direction
|
||||
const key = `${leg.player}|${leg.stat}|${leg.line}|${leg.direction}`;
|
||||
if (prev.some((p) => `${p.player}|${p.stat}|${p.line}|${p.direction}` === key)) return prev;
|
||||
@@ -87,6 +115,44 @@ export default function ParlayProvider({ children }: { children: React.ReactNode
|
||||
|
||||
const clear = useCallback(() => setLegs([]), []);
|
||||
|
||||
const hasLeg = useCallback((key: string) => legs.some((l) => legKey(l) === key), [legs]);
|
||||
|
||||
// Session 50 — auto-grade the slip whenever the legs change (≥ 2 legs).
|
||||
// Debounced so rapid adds make one request; clears when below 2.
|
||||
const [combined, setCombined] = useState<ParlayCombined | null>(null);
|
||||
const [correlation, setCorrelation] = useState<ParlayCorrelation | null>(null);
|
||||
const [payout, setPayout] = useState<ParlayPayout | null>(null);
|
||||
const [grading, setGrading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (legs.length < 2) { setCombined(null); setCorrelation(null); setPayout(null); return; }
|
||||
let active = true;
|
||||
setGrading(true);
|
||||
const t = setTimeout(() => {
|
||||
fetch('/api/parlay/grade', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legs: legs.map((l) => ({
|
||||
player: l.player, team: l.team || '', game: l.game || '',
|
||||
stat: l.stat, line: l.line, grade: l.grade,
|
||||
})),
|
||||
betAmount: 10,
|
||||
}),
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (!active || !d || d.error) return;
|
||||
setCombined(d.combined ?? null);
|
||||
setCorrelation(d.correlation ?? null);
|
||||
setPayout(d.payout ?? null);
|
||||
})
|
||||
.catch(() => { /* keep last-known on failure */ })
|
||||
.finally(() => { if (active) setGrading(false); });
|
||||
}, 250);
|
||||
return () => { active = false; clearTimeout(t); setGrading(false); };
|
||||
}, [legs]);
|
||||
|
||||
const value = useMemo<ParlayContextValue>(() => ({
|
||||
legs,
|
||||
legCount: legs.length,
|
||||
@@ -97,7 +163,15 @@ export default function ParlayProvider({ children }: { children: React.ReactNode
|
||||
addLeg,
|
||||
removeLeg,
|
||||
clear,
|
||||
}), [legs, isOpen, addLeg, removeLeg, clear]);
|
||||
combined,
|
||||
correlation,
|
||||
payout,
|
||||
grading,
|
||||
hasLeg,
|
||||
maxLegs,
|
||||
setMaxLegs,
|
||||
atCap: legs.length >= maxLegs,
|
||||
}), [legs, isOpen, addLeg, removeLeg, clear, combined, correlation, payout, grading, hasLeg, maxLegs]);
|
||||
|
||||
return <ParlayContext.Provider value={value}>{children}</ParlayContext.Provider>;
|
||||
}
|
||||
@@ -117,6 +191,14 @@ export function useParlay(): ParlayContextValue {
|
||||
addLeg: () => {},
|
||||
removeLeg: () => {},
|
||||
clear: () => {},
|
||||
combined: null,
|
||||
correlation: null,
|
||||
payout: null,
|
||||
grading: false,
|
||||
hasLeg: () => false,
|
||||
maxLegs: 6,
|
||||
setMaxLegs: () => {},
|
||||
atCap: false,
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
|
||||
Reference in New Issue
Block a user