170 lines
5.6 KiB
TypeScript
170 lines
5.6 KiB
TypeScript
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' } },
|
|
);
|
|
}
|