Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)

This commit is contained in:
Kev
2026-06-13 12:37:08 -04:00
parent 66fafd8429
commit c48aecd510
23 changed files with 1567 additions and 1 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Book-comparison proxy (Session 28). Forwards /api/books/* to Express
* (best lines + per-prop book grid). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/books/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ bestLines: [], books: [] }, { status: 200 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Line-movement proxy (Session 28). Forwards /api/lines/* to Express
* (movers + per-prop history). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/lines/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ movers: [], snapshots: [] }, { status: 200 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Parlay calculate proxy (Session 28). Forwards builder legs to Express
* `/api/parlay/calculate` for combined odds, grade, and correlation flags.
* Zero-credit pure math — no auth gate (the math reveals nothing private).
*/
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON.' }, { status: 400 });
}
try {
const upstream = await fetch(`${BACKEND_URL}/api/parlay/calculate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'Parlay service unreachable.' }, { status: 502 });
}
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useEffect, useState } from 'react';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* BestLinesPanel (Session 28).
*
* "Best lines tonight" — the prop + sportsbook offering the highest payout,
* with the dollars-per-$100 a user saves by line-shopping. Reads
* /api/books/:sport (cached odds, zero credits). Self-hides when empty.
*/
interface BestLine {
player: string;
stat: string;
line: number | null;
bestBook: string;
bestOdds: number;
savings: number;
}
export interface BestLinesPanelProps {
sport: string;
tier?: Tier;
limit?: number;
}
export default function BestLinesPanel({ sport, tier = 'free', limit }: BestLinesPanelProps) {
const [lines, setLines] = useState<BestLine[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/books/${sport}`);
if (!res.ok) { if (!cancelled) setLines([]); return; }
const data = await res.json();
if (!cancelled) setLines(Array.isArray(data?.bestLines) ? data.bestLines : []);
} catch {
if (!cancelled) setLines([]);
}
}
load();
return () => { cancelled = true; };
}, [sport]);
if (!lines || lines.length === 0) return null;
const tierCount = getVisibleCount(tier, lines.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = lines.slice(0, cap);
const hidden = limit ? lines.length - visible.length : getHiddenCount(tier, lines.length);
const fmtOdds = (o: number) => (o > 0 ? `+${o}` : `${o}`);
return (
<section className="best-lines-panel" style={{ margin: '16px 0' }}>
<h3 style={heading}>💰 BEST LINES TONIGHT</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((bl) => (
<div key={`${bl.player}-${bl.stat}`} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{bl.player}</div>
<div style={propLine}>
{bl.stat.replace(/_/g, ' ')}{bl.line != null ? ` ${bl.line}` : ''}
</div>
</div>
<span style={book}>
{bl.bestBook} <span style={{ color: 'var(--grade-a, #00D4A0)' }}>{fmtOdds(bl.bestOdds)}</span>
</span>
{bl.savings > 0 && <span style={savings}>save ${bl.savings.toFixed(2)}</span>}
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsell}>{hidden} more upgrade to compare every book </a>
)}
</section>
);
}
const heading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6B6B7B)', margin: '0 0 10px',
};
const row: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 10px', borderRadius: 10,
background: 'var(--bg-2, #12121A)', border: '1px solid var(--border, #1A1A24)',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-0, #F0F0F5)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const propLine: React.CSSProperties = { fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', textTransform: 'capitalize' };
const book: React.CSSProperties = { flex: '0 0 auto', fontSize: 12, fontWeight: 700, color: 'var(--text-0, #F0F0F5)', textTransform: 'capitalize' };
const savings: React.CSSProperties = {
flex: '0 0 auto', fontSize: 11, fontWeight: 700, padding: '2px 7px', borderRadius: 6,
background: 'rgba(0,212,160,0.12)', color: 'var(--grade-a, #00D4A0)', whiteSpace: 'nowrap',
};
const upsell: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--grade-a, #00D4A0)', textDecoration: 'none',
};
+73
View File
@@ -0,0 +1,73 @@
'use client';
/**
* BookComparison (Session 28).
*
* Presentational book-by-book grid for a single prop with the best line
* highlighted. Fed by /api/books/:sport/:player/:stat (or the prop grid
* from a parent). Pure render — no fetching here, so it drops cleanly into
* a modal or an expanded prop row.
*/
export interface BookRow {
book: string;
line?: number | null;
over_odds?: number | null;
under_odds?: number | null;
isBest?: boolean;
}
export interface BookComparisonProps {
player: string;
stat: string;
line?: number | null;
side?: 'over' | 'under';
books: BookRow[];
savings?: number;
}
function fmt(o?: number | null) {
if (o == null) return '—';
return o > 0 ? `+${o}` : `${o}`;
}
export default function BookComparison({ player, stat, line, side = 'over', books, savings }: BookComparisonProps) {
if (!books || books.length === 0) return null;
return (
<div className="book-comparison" style={{ display: 'grid', gap: 8 }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side}
</div>
<div style={{ display: 'grid', gap: 4 }}>
{books.map((b) => (
<div
key={b.book}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: 10,
alignItems: 'center',
padding: '6px 10px',
borderRadius: 8,
background: b.isBest ? 'rgba(0,212,160,0.10)' : 'var(--bg-2, #12121A)',
border: `1px solid ${b.isBest ? 'var(--grade-a, #00D4A0)' : 'var(--border, #1A1A24)'}`,
}}
>
<span style={{ fontSize: 13, fontWeight: 700, textTransform: 'capitalize', color: 'var(--text-0, #F0F0F5)' }}>{b.book}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)' }}>
{fmt(b.over_odds)} / {fmt(b.under_odds)}
</span>
{b.isBest ? (
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.08em', color: '#06060B', background: 'var(--grade-a, #00D4A0)', padding: '2px 6px', borderRadius: 4 }}>BEST</span>
) : <span />}
</div>
))}
</div>
{savings != null && savings > 0 && (
<div style={{ fontSize: 12, color: 'var(--grade-a, #00D4A0)' }}>
Betting the best line saves ~${savings.toFixed(2)} per $100.
</div>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
/**
* LineMovementChart (Session 28).
*
* Dependency-free SVG sparkline of a prop's line through the day. Green
* when the line rose, red when it dropped. Renders open + current labels.
* Degrades to a flat midline for <2 points.
*/
export interface Snapshot {
time?: number;
line: number;
}
export interface LineMovementChartProps {
snapshots: Snapshot[];
width?: number;
height?: number;
}
export default function LineMovementChart({ snapshots, width = 120, height = 32 }: LineMovementChartProps) {
const pts = (snapshots || []).map((s) => Number(s.line)).filter((n) => Number.isFinite(n));
if (pts.length === 0) return null;
const opening = pts[0];
const current = pts[pts.length - 1];
const delta = current - opening;
const stroke = Math.abs(delta) < 0.5 ? 'var(--text-tertiary, #6B6B7B)' : delta > 0 ? '#00D4A0' : '#FF4D4D';
const min = Math.min(...pts);
const max = Math.max(...pts);
const range = max - min || 1;
const pad = 4;
const innerH = height - pad * 2;
const stepX = pts.length > 1 ? width / (pts.length - 1) : 0;
const yScale = (v: number) => pad + innerH - ((v - min) / range) * innerH;
const polyline =
pts.length > 1
? pts.map((v, i) => `${(i * stepX).toFixed(1)},${yScale(v).toFixed(1)}`).join(' ')
: `0,${(height / 2).toFixed(1)} ${width},${(height / 2).toFixed(1)}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
role="img"
aria-label={`Line moved from ${opening} to ${current}`}
style={{ display: 'block' }}
>
<polyline points={polyline} fill="none" stroke={stroke} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{pts.length > 1 && <circle cx={width} cy={yScale(current)} r={2.5} fill={stroke} />}
</svg>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import LineMovementChart, { Snapshot } from '@/components/LineMovementChart';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* MoversPanel (Session 28).
*
* Biggest line moves for a sport — the market confirming (or contradicting)
* a grade. Reads /api/lines/:sport/movers (Redis snapshot history, zero
* credits). Self-hides when there's nothing moving. Free users see the top
* 3; paid see the full board.
*/
interface Mover {
player: string;
stat: string;
opening: number;
current: number;
delta: number;
movement: 'stable' | 'rising' | 'dropping';
sharpSignal: boolean;
snapshots: Snapshot[];
}
export interface MoversPanelProps {
sport: string;
tier?: Tier;
limit?: number;
}
export default function MoversPanel({ sport, tier = 'free', limit }: MoversPanelProps) {
const [movers, setMovers] = useState<Mover[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/lines/${sport}/movers`);
if (!res.ok) { if (!cancelled) setMovers([]); return; }
const data = await res.json();
if (!cancelled) setMovers(Array.isArray(data?.movers) ? data.movers : []);
} catch {
if (!cancelled) setMovers([]);
}
}
load();
return () => { cancelled = true; };
}, [sport]);
if (!movers || movers.length === 0) return null;
const tierCount = getVisibleCount(tier, movers.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = movers.slice(0, cap);
const hidden = limit ? movers.length - visible.length : getHiddenCount(tier, movers.length);
return (
<section className="movers-panel" style={{ margin: '16px 0' }}>
<h3 style={heading}>📈 BIGGEST MOVERS</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((m) => (
<div key={`${m.player}-${m.stat}`} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{m.player}</div>
<div style={propLine}>{m.stat.replace(/_/g, ' ')} · open {m.opening} {m.current}</div>
</div>
<LineMovementChart snapshots={m.snapshots} />
<span style={{ ...delta, color: m.delta > 0 ? '#00D4A0' : '#FF4D4D' }}>
{m.delta > 0 ? '+' : ''}{m.delta}
{m.sharpSignal ? <span style={sharp} title="Sharp money signal"> SHARP</span> : null}
</span>
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsell}>{hidden} more upgrade for the full board </a>
)}
</section>
);
}
const heading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6B6B7B)', margin: '0 0 10px',
};
const row: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 10px', borderRadius: 10,
background: 'var(--bg-2, #12121A)', border: '1px solid var(--border, #1A1A24)',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-0, #F0F0F5)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const propLine: React.CSSProperties = { fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', textTransform: 'capitalize' };
const delta: React.CSSProperties = { flex: '0 0 auto', fontSize: 13, fontWeight: 800, whiteSpace: 'nowrap' };
const sharp: React.CSSProperties = { fontSize: 9, color: '#FFB347', letterSpacing: '0.06em' };
const upsell: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--grade-a, #00D4A0)', textDecoration: 'none',
};
+9
View File
@@ -11,6 +11,10 @@ import { useAuth } from '@/contexts/AuthContext';
import StatFilterPills from '@/components/StatFilterPills';
import StreaksPanel from '@/components/StreaksPanel';
import HotListPanel from '@/components/HotListPanel';
// Session 28 — line-movement + book-comparison read-only panels. Both
// self-hide when empty; free users see a top-3 teaser.
import MoversPanel from '@/components/MoversPanel';
import BestLinesPanel from '@/components/BestLinesPanel';
/**
* The Slate (Session 13).
@@ -762,6 +766,11 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
an off-hours slate with no warm logs simply shows the games. */}
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
{/* Session 28 — market layers: how lines are MOVING and where the
BEST price sits. Both read cached data (zero credits) and
self-hide until there's something to show. */}
<MoversPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
<BestLinesPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
{/* Session 24 — removed the developer-facing "odds endpoint not
configured yet" footer note. A sport with no data simply doesn't