Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user