Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)

This commit is contained in:
Kev
2026-06-11 18:15:25 -04:00
parent 167996d99a
commit 73b65a0248
11 changed files with 1010 additions and 101 deletions
+169
View File
@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
// Cache the response for 15 minutes (server-side) so cold visitors
// don't trigger a fresh grade on every page load. The cache header
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
export const revalidate = 900;
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const HERO_FETCH_TIMEOUT_MS = 6000;
interface OddsResponseProp {
player: string;
stat_type: string;
line: number;
direction?: 'over' | 'under';
book?: string;
home_team?: string;
away_team?: string;
}
interface OddsResponse {
sport?: string;
source?: string;
props?: OddsResponseProp[];
error?: string;
}
interface GradeResponse {
grade?: string;
confidence?: number;
edge_pct?: number;
projection?: number;
reasoning?: { summary?: string; steps?: unknown };
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
}
/**
* Live hero prop endpoint (Session 16).
*
* Picks one fresh, real prop from the day's odds and grades it. The
* landing page hero renders this in place of the static Jokic mockup
* — cold visitors see proof of live intelligence on first paint
* instead of a hypothetical example.
*
* Sport cascade: NBA → WNBA → MLB. Whichever sport produces a non-
* empty `props` list first wins. When every sport is empty (off-
* hours, holiday slate, upstream odds quota burned), responds with
* `{ isStatic: true }` and the client falls back to the existing
* static card. Never throws — odds outages must not blank the
* landing page.
*
* Two-stage flow:
* 1. GET ${BACKEND}/api/odds/{sport} → pick random prop
* 2. POST ${BACKEND}/api/analyze/prop → grade it
*
* Both calls share a 6s timeout (AbortController). The overall
* route is wrapped in try/catch and always 200s (with `isStatic:true`
* on failure) so the client renders gracefully.
*/
async function fetchWithTimeout(url: string, init?: RequestInit, ms = HERO_FETCH_TIMEOUT_MS): Promise<Response | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function pickPropFromSport(sport: string): Promise<{ prop: OddsResponseProp; sport: string } | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/odds/${sport}`, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!res || !res.ok) return null;
const body = (await res.json().catch(() => null)) as OddsResponse | null;
if (!body || !Array.isArray(body.props) || body.props.length === 0) return null;
// Bias toward A-list player names — props with longer player names
// tend to be top-of-rotation stars (better hero material). Cheap
// heuristic, not a hard filter; we still random-pick among the top
// half of the sorted list so multiple page loads vary.
const sorted = body.props
.filter((p) => p.player && p.stat_type && Number.isFinite(p.line))
.sort((a, b) => (b.player.length - a.player.length));
if (sorted.length === 0) return null;
const topHalf = sorted.slice(0, Math.max(3, Math.ceil(sorted.length / 2)));
const pick = topHalf[Math.floor(Math.random() * topHalf.length)];
return { prop: pick, sport };
}
async function gradeProp(sport: string, prop: OddsResponseProp): Promise<GradeResponse | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/analyze/prop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
sport,
player: prop.player,
stat_type: prop.stat_type,
line: prop.line,
direction: prop.direction || 'over',
book: prop.book || 'draftkings',
}),
cache: 'no-store',
});
if (!res || !res.ok) return null;
return (await res.json().catch(() => null)) as GradeResponse | null;
}
export async function GET() {
// The order matters: NBA props lead because mid-summer the cascade
// would otherwise constantly land on the same sport. After NBA
// off-season concludes, swap to a season-aware ordering (winter:
// NBA, summer: MLB + WNBA, fall: NFL — when supported).
const sportsToTry = ['nba', 'wnba', 'mlb'];
try {
for (const sport of sportsToTry) {
const picked = await pickPropFromSport(sport);
if (!picked) continue;
const grade = await gradeProp(picked.sport, picked.prop);
if (!grade) continue;
return NextResponse.json(
{
isStatic: false,
sport: picked.sport,
prop: picked.prop,
grade,
},
{ headers: { 'Cache-Control': 'public, s-maxage=900, stale-while-revalidate=60' } },
);
}
} catch {
// Falls through to static fallback below.
}
// Static fallback — keeps the hero alive when every sport is empty.
return NextResponse.json(
{
isStatic: true,
sport: 'nba',
prop: {
player: 'Nikola Jokic',
stat_type: 'points',
line: 26.5,
direction: 'over',
book: 'draftkings',
home_team: 'DEN',
away_team: 'LAL',
},
grade: {
grade: 'A-',
confidence: 73,
edge_pct: 6.2,
projection: 29.4,
reasoning: {
summary: 'L5 form is 28.6 over 5 games, +2.1 above the line. Lakers are bottom-five vs Cs.',
},
kill_conditions_triggered: [],
},
},
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' } },
);
}