Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)

This commit is contained in:
Kev
2026-06-12 17:58:55 -04:00
parent 433e827103
commit 956cdb863a
15 changed files with 602 additions and 39 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Game-lines proxy (Session 25). Thin forwarder to Express
* `/api/gamelines/:sport` (Tank01 book-by-book moneylines / spreads /
* totals). See the schedule proxy for why these were missing.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/gamelines/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, games: {}, source: 'tank01' });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Hot-list proxy (Session 25). Forwards to Express `/api/hotlist/:sport`,
* preserving the `?stat=` filter. Computed from cached game logs — no
* odds-api credits. See the schedule proxy for why these were missing.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/hotlist/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, players: [], source: 'computed' });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Schedule proxy (Session 25).
*
* The all-day intelligence endpoints (schedule / gamelines / streaks /
* hotlist) were built on the Express backend in Sessions 23-24 but had
* NO Next.js proxy route — so the browser's `fetch('/api/schedule/mlb')`
* 404'd on the Next origin and the slate showed zero games even though
* the backend was serving 8+. This thin forwarder fixes that, mirroring
* the existing `/api/odds/*` proxies.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
// Schedule is the foundation layer — never blow up the page. Return an
// empty-but-valid slate so the UI degrades to "no games" gracefully.
return NextResponse.json({ sport: sportLc, games: [], source: 'espn' });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Streaks proxy (Session 25). Forwards to Express `/api/streaks/:sport`,
* preserving the `?stat=` filter. Computed from cached game logs — no
* odds-api credits. See the schedule proxy for why these were missing.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/streaks/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, streaks: [], source: 'computed' });
}
}
+48 -1
View File
@@ -52,6 +52,14 @@ export interface GameLines {
export type GameStatus = 'pre' | 'in' | 'post';
// Session 25 — a computed streak for a player whose team is in this game.
export interface GameStreak {
player: string;
team?: string | null;
description: string;
currentStreak: number;
}
export interface GameCardProps {
sport: SlateSport;
homeTeam: string;
@@ -73,6 +81,10 @@ export interface GameCardProps {
status?: GameStatus;
score?: { home: number; away: number } | null;
gameLines?: GameLines | null;
// Session 25 — streaks for players in THIS game, matched by team in the
// Slate. Renders inline below the props/lines so the streak context
// lives with the game it belongs to.
streaks?: GameStreak[];
}
// Map ESPN status → a compact, human badge. 'in' is live; 'post' is final.
@@ -173,11 +185,12 @@ export default function GameCard(props: GameCardProps) {
props: propList, gradedProps, loadingKey, errorByKey,
tier = 'free', onGrade, onUpgrade,
defaultVisible = 4,
status, score, gameLines,
status, score, gameLines, streaks,
} = props;
const [expanded, setExpanded] = useState(false);
const badge = statusBadge(status, score);
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : [];
const streakRows = (streaks || []).filter((s) => s && s.player && s.description);
// Session 19 — visibility budget now applies to PLAYERS, not raw
// props. Showing the first 4 prop rows that all belonged to the
@@ -406,6 +419,40 @@ export default function GameCard(props: GameCardProps) {
)}
</div>
)}
{/* Session 25 — streaks for players in THIS game, inline. The Slate
matches streaks to games by team, so a card shows the streak
context for the players actually on the floor/field. Renders
only when there's at least one — no empty section. */}
{streakRows.length > 0 && (
<div
style={{
padding: '10px 16px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 6,
}}
>
<div
className="mono"
style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
>
🔥 Streaks
</div>
{streakRows.map((s) => (
<div
key={`${s.player}-${s.description}`}
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12, alignItems: 'baseline' }}
>
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 600 }}>
{s.player}
{s.team ? <span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400 }}> · {s.team}</span> : null}
</span>
<span style={{ color: 'var(--text-secondary, #8A8A9A)', textAlign: 'right' }}>{s.description}</span>
</div>
))}
</div>
)}
</article>
);
}
+14
View File
@@ -145,6 +145,20 @@ export default function LiveHeroProp() {
marginInline: 'auto',
}}
>
{/* Session 25 — the static fallback is an ILLUSTRATIVE example, not
a live pick. Labelling it prevents the fixed stats from reading
as stale real data when no live hero-prop is flowing. */}
<span
className="mono"
aria-label="Example grade"
style={{
position: 'absolute', top: 12, right: 12, fontSize: 9, fontWeight: 800,
letterSpacing: '0.12em', padding: '3px 7px', borderRadius: 4,
background: 'rgba(255,255,255,0.06)', color: 'var(--text-tertiary)', textTransform: 'uppercase',
}}
>
Example
</span>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<span
+52 -4
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GameCard, { SlateSport, GameLines } from '@/components/GameCard';
import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
@@ -138,8 +138,20 @@ interface SlateGame {
status?: 'pre' | 'in' | 'post';
score?: { home: number; away: number } | null;
gameLines?: GameLines | null;
// Session 25 — team abbreviations (for streak matching) + matched streaks.
homeAbbr?: string | null;
awayAbbr?: string | null;
streaks?: GameStreak[];
}
interface StreakApiRow {
player: string;
team?: string | null;
description: string;
currentStreak: number;
}
interface StreaksResponse { streaks?: StreakApiRow[] }
// ---- Session 24: schedule + game-lines response shapes ----
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
interface ScheduleGame {
@@ -194,22 +206,42 @@ function findGameLines(home?: ScheduleTeam, away?: ScheduleTeam, lines?: Record<
* appended so we never drop props. When schedule is empty, the odds
* games become the base (odds-only fallback).
*/
// Match streaks to a game by team abbreviation. A streak's `team` is the
// player's team abbrev (ESPN/Tank01 standard), which lines up with the
// schedule's home/away abbreviations.
function streaksForGame(home?: string | null, away?: string | null, streaks?: StreakApiRow[]): GameStreak[] {
if (!streaks || streaks.length === 0) return [];
const h = (home || '').toUpperCase();
const a = (away || '').toUpperCase();
if (!h && !a) return [];
return streaks
.filter((s) => {
const t = (s.team || '').toUpperCase();
return t && (t === h || t === a);
})
.map((s) => ({ player: s.player, team: s.team, description: s.description, currentStreak: s.currentStreak }));
}
function mergeSlate(
sport: SlateSport,
scheduleGames: ScheduleGame[],
oddsGames: SlateGame[],
lines?: Record<string, GameLines>,
streaks?: StreakApiRow[],
): SlateGame[] {
const base: SlateGame[] = scheduleGames.map((sg) => ({
sport,
homeTeam: sg.homeTeam?.name || '',
awayTeam: sg.awayTeam?.name || '',
homeAbbr: sg.homeTeam?.abbreviation || null,
awayAbbr: sg.awayTeam?.abbreviation || null,
gameTime: sg.gameTime || undefined,
venue: sg.venue || undefined,
status: sg.status || undefined,
score: sg.score || undefined,
props: [],
gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
streaks: streaksForGame(sg.homeTeam?.abbreviation, sg.awayTeam?.abbreviation, streaks),
}));
const unmatched: SlateGame[] = [];
@@ -342,17 +374,18 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const perSport = await Promise.all(
sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines] = await Promise.all([
const [oddsResults, schedule, lines, streaksRes] = await Promise.all([
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
]);
const oddsOk = oddsResults.some((o) => o !== null);
const oddsProps = oddsResults.flatMap((o) => o?.props || []);
const oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games);
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 };
}),
);
@@ -453,6 +486,20 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
.filter((g): g is SlateGame => g !== null);
}, [games, searchQuery]);
// Session 25 — per-sport game counts for the tab labels, derived from
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
// free ESPN schedule even when odds are empty. Counts only appear for
// sports currently loaded (the active tab fetches its own sports).
const countBySport = useMemo(() => {
const m: Partial<Record<SlateSport, number>> = {};
for (const g of games) m[g.sport] = (m[g.sport] || 0) + 1;
return m;
}, [games]);
const tabCount = (id: SlateTab): number | null => {
if (id === 'all') return games.length || null;
return countBySport[id as SlateSport] ?? null;
};
// Manual scan fallback URL — pre-fills /scan with the search query
// so the user lands on a partially-filled form instead of empty.
const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`;
@@ -522,7 +569,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
cursor: 'pointer',
}}
>
{t.label}
{t.label}{tabCount(t.id) != null ? ` (${tabCount(t.id)})` : ''}
</button>
);
})}
@@ -658,6 +705,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
status={g.status}
score={g.score}
gameLines={g.gameLines}
streaks={g.streaks}
gradedProps={gradedProps}
loadingKey={gradingKey}
errorByKey={errorByKey}