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
+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}