Session 24: Connect everything — Slate wired to all sources, copy fixed, nav fixed, startup prefetch, language button removed (1571 tests)
This commit is contained in:
+186
-71
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import GameCard, { SlateSport } from '@/components/GameCard';
|
||||
import GameCard, { SlateSport, GameLines } 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
|
||||
@@ -134,6 +134,98 @@ interface SlateGame {
|
||||
venue?: string;
|
||||
context?: string;
|
||||
props: PropRowProp[];
|
||||
// Session 24 — schedule + game-lines layers overlaid onto each game.
|
||||
status?: 'pre' | 'in' | 'post';
|
||||
score?: { home: number; away: number } | null;
|
||||
gameLines?: GameLines | null;
|
||||
}
|
||||
|
||||
// ---- Session 24: schedule + game-lines response shapes ----
|
||||
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
|
||||
interface ScheduleGame {
|
||||
id?: string;
|
||||
homeTeam?: ScheduleTeam;
|
||||
awayTeam?: ScheduleTeam;
|
||||
gameTime?: string | null;
|
||||
status?: 'pre' | 'in' | 'post' | null;
|
||||
score?: { home: number; away: number } | null;
|
||||
venue?: string | null;
|
||||
broadcast?: string | null;
|
||||
}
|
||||
interface ScheduleResponse { games?: ScheduleGame[] }
|
||||
interface GameLinesResponse { games?: Record<string, GameLines> }
|
||||
|
||||
// Nickname token (last word) — the most stable cross-source identifier
|
||||
// between ESPN full names and odds-api full names ("San Antonio Spurs"
|
||||
// ↔ "spurs"). Falls back to the whole normalized string.
|
||||
function nickToken(name?: string | null): string {
|
||||
const w = String(name || '').trim().split(/\s+/);
|
||||
const last = w[w.length - 1] || '';
|
||||
return last.toLowerCase().replace(/[^a-z]/g, '');
|
||||
}
|
||||
|
||||
// Match an odds-derived game to a schedule game by both nicknames.
|
||||
function gamesMatch(scheduleHome: string, scheduleAway: string, oddsHome: string, oddsAway: string): boolean {
|
||||
const sh = nickToken(scheduleHome), sa = nickToken(scheduleAway);
|
||||
const oh = nickToken(oddsHome), oa = nickToken(oddsAway);
|
||||
if (!sh || !sa || !oh || !oa) return false;
|
||||
return (sh === oh && sa === oa) || (sh === oa && sa === oh);
|
||||
}
|
||||
|
||||
// Find the Tank01 game-lines entry for a schedule game by team
|
||||
// abbreviation (ESPN + Tank01 both use standard team abbreviations).
|
||||
function findGameLines(home?: ScheduleTeam, away?: ScheduleTeam, lines?: Record<string, GameLines>): GameLines | null {
|
||||
if (!lines) return null;
|
||||
const h = (home?.abbreviation || '').toUpperCase();
|
||||
const a = (away?.abbreviation || '').toUpperCase();
|
||||
if (!h && !a) return null;
|
||||
for (const entry of Object.values(lines)) {
|
||||
const eh = String(entry.homeTeam || '').toUpperCase();
|
||||
const ea = String(entry.awayTeam || '').toUpperCase();
|
||||
if ((eh === h && ea === a) || (eh === a && ea === h)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session 24 — merge the three free/cheap layers into one game list.
|
||||
* Schedule is the FOUNDATION (always shows from ESPN); odds props and
|
||||
* Tank01 lines overlay onto matching games. Unmatched odds games are
|
||||
* appended so we never drop props. When schedule is empty, the odds
|
||||
* games become the base (odds-only fallback).
|
||||
*/
|
||||
function mergeSlate(
|
||||
sport: SlateSport,
|
||||
scheduleGames: ScheduleGame[],
|
||||
oddsGames: SlateGame[],
|
||||
lines?: Record<string, GameLines>,
|
||||
): SlateGame[] {
|
||||
const base: SlateGame[] = scheduleGames.map((sg) => ({
|
||||
sport,
|
||||
homeTeam: sg.homeTeam?.name || '',
|
||||
awayTeam: sg.awayTeam?.name || '',
|
||||
gameTime: sg.gameTime || undefined,
|
||||
venue: sg.venue || undefined,
|
||||
status: sg.status || undefined,
|
||||
score: sg.score || undefined,
|
||||
props: [],
|
||||
gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
|
||||
}));
|
||||
|
||||
const unmatched: SlateGame[] = [];
|
||||
for (const og of oddsGames) {
|
||||
const target = base.find((b) => gamesMatch(b.homeTeam, b.awayTeam, og.homeTeam, og.awayTeam));
|
||||
if (target) target.props.push(...og.props);
|
||||
else unmatched.push(og);
|
||||
}
|
||||
|
||||
const merged = [...base, ...unmatched];
|
||||
// Stable order: scheduled tip-off time, unknowns last.
|
||||
return merged.sort((a, b) => {
|
||||
const ta = a.gameTime ? Date.parse(a.gameTime) : Number.MAX_SAFE_INTEGER;
|
||||
const tb = b.gameTime ? Date.parse(b.gameTime) : Number.MAX_SAFE_INTEGER;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||
@@ -194,7 +286,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
const [games, setGames] = useState<SlateGame[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [unsupportedSports, setUnsupportedSports] = useState<SlateSport[]>([]);
|
||||
// Session 24 — when odds are unavailable but the schedule still has
|
||||
// games, this becomes a soft inline notice instead of a wall-of-error.
|
||||
const [oddsNotice, setOddsNotice] = useState(false);
|
||||
|
||||
// Grade state — Map keyed by propRowKey.
|
||||
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
|
||||
@@ -204,19 +298,24 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
// Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Fetch + group. Promise.allSettled so one sport failing doesn't blank the slate.
|
||||
// Session 24 — fetch ALL free/cheap layers per sport in parallel:
|
||||
// odds (odds-api props) · schedule (ESPN) · gamelines (Tank01)
|
||||
// Schedule is the foundation — games render even when odds are
|
||||
// empty/503. Odds + lines overlay on top. The slate is never empty
|
||||
// just because one provider is down.
|
||||
const fetchSlate = useCallback(async (active: SlateTab) => {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
setOddsNotice(false);
|
||||
|
||||
const sportsToFetch: Array<{ sport: SlateSport; urls: string[] }> = [];
|
||||
const unsupported: SlateSport[] = [];
|
||||
// Sports that carry a schedule/streaks feed (ESPN-backed). Soccer
|
||||
// has no schedule endpoint, so it stays odds-only.
|
||||
const SCHEDULE_SPORTS = new Set<SlateSport>(['nba', 'wnba', 'mlb']);
|
||||
|
||||
const sportsToFetch: SlateSport[] = [];
|
||||
const consider = (s: Exclude<SlateTab, 'all'>) => {
|
||||
const urls = FETCH_URLS[s];
|
||||
if (urls === null) unsupported.push(s as SlateSport);
|
||||
else sportsToFetch.push({ sport: s as SlateSport, urls });
|
||||
if (FETCH_URLS[s] !== null) sportsToFetch.push(s as SlateSport);
|
||||
};
|
||||
|
||||
if (active === 'all') {
|
||||
consider('nba'); consider('wnba'); consider('mlb'); consider('soccer');
|
||||
} else {
|
||||
@@ -225,64 +324,66 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
|
||||
if (sportsToFetch.length === 0) {
|
||||
setGames([]);
|
||||
setUnsupportedSports(unsupported);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sportsToFetch.flatMap(({ sport, urls }) =>
|
||||
urls.map((url) =>
|
||||
fetch(url, { cache: 'no-store' })
|
||||
.then(async (r) => {
|
||||
const body = (await r.json().catch(() => ({}))) as OddsResponse;
|
||||
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
||||
return { sport, body };
|
||||
})
|
||||
.catch((err) => {
|
||||
// Re-throw so allSettled catches it, but attach the
|
||||
// sport so the per-sport error-tracking below can
|
||||
// surface "Soccer odds unavailable" without blanking
|
||||
// the rest of the slate.
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
(e as Error & { _vyndrSport?: SlateSport })._vyndrSport = sport;
|
||||
throw e;
|
||||
})
|
||||
),
|
||||
),
|
||||
const getJson = async <T,>(url: string): Promise<T | null> => {
|
||||
try {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Per sport: odds + schedule + gamelines, all settled independently.
|
||||
const perSport = await Promise.all(
|
||||
sportsToFetch.map(async (sport) => {
|
||||
const oddsUrls = FETCH_URLS[sport] as string[];
|
||||
const [oddsResults, schedule, lines] = 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),
|
||||
]);
|
||||
|
||||
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);
|
||||
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 };
|
||||
}),
|
||||
);
|
||||
|
||||
const allGames: SlateGame[] = [];
|
||||
const failedSports: SlateSport[] = [];
|
||||
const sportsAttempted = new Set<SlateSport>(sportsToFetch.map((s) => s.sport));
|
||||
const sportsThatSucceeded = new Set<SlateSport>();
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
sportsThatSucceeded.add(r.value.sport);
|
||||
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
|
||||
allGames.push(...grouped);
|
||||
} else {
|
||||
const failed = (r.reason as Error & { _vyndrSport?: SlateSport })._vyndrSport;
|
||||
if (failed && !failedSports.includes(failed)) failedSports.push(failed);
|
||||
}
|
||||
let anyOddsOk = false;
|
||||
let anyScheduleShown = false;
|
||||
for (const s of perSport) {
|
||||
allGames.push(...s.merged);
|
||||
if (s.oddsOk) anyOddsOk = true;
|
||||
if (s.hadSchedule) anyScheduleShown = true;
|
||||
}
|
||||
|
||||
setGames(allGames);
|
||||
setUnsupportedSports([...unsupported, ...failedSports.filter((s) => !sportsThatSucceeded.has(s))]);
|
||||
|
||||
// Session 17 — only surface a top-level error when EVERY sport
|
||||
// attempted in this tab failed. Partial successes (NBA ok,
|
||||
// soccer 503) silently drop the failed sport's row and surface
|
||||
// it via the existing "endpoint not configured" footer note.
|
||||
if (sportsAttempted.size > 0 && sportsThatSucceeded.size === 0) {
|
||||
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||
setFetchError(firstError ? (firstError.reason as Error).message : 'Odds fetch failed');
|
||||
// Odds down but schedule carried the slate → soft notice, not a wall.
|
||||
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
|
||||
// Genuine total failure (no odds, no schedule, anywhere) → error.
|
||||
if (!anyOddsOk && !anyScheduleShown && allGames.length === 0) {
|
||||
setFetchError('No games available right now. Check back soon.');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
|
||||
|
||||
// Session 24 — switching sport resets the stat filter. The categories
|
||||
// differ per sport (Points vs Hits), so a stale "points" filter would
|
||||
// silently blank the MLB panels. Always land back on 'all'.
|
||||
useEffect(() => { setActiveStat('all'); }, [tab]);
|
||||
|
||||
// Grading call site. Single source of truth so we never have two
|
||||
// PropRows in-flight from the same prop (the loadingKey enforces it).
|
||||
const onGrade = useCallback(async (prop: PropRowProp) => {
|
||||
@@ -426,13 +527,17 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Session 23 — stat filter pills, below the sport tabs and above
|
||||
all content. Narrows the streaks + hot list panels. */}
|
||||
<StatFilterPills
|
||||
sport={tab === 'all' ? 'nba' : tab}
|
||||
activeStat={activeStat}
|
||||
onChange={setActiveStat}
|
||||
/>
|
||||
{/* Session 23/24 — stat filter pills, below the sport tabs and
|
||||
above all content. Sport-specific categories. Hidden on the
|
||||
ALL tab: filtering by "points" makes no sense when the slate
|
||||
mixes NBA + MLB + soccer. Pills appear only on a single sport. */}
|
||||
{tab !== 'all' && (
|
||||
<StatFilterPills
|
||||
sport={tab}
|
||||
activeStat={activeStat}
|
||||
onChange={setActiveStat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -467,6 +572,24 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session 24 — soft notice when props are loading but the schedule
|
||||
(and lines) carry the slate. NOT a wall-of-error: the games are
|
||||
right below it. */}
|
||||
{oddsNotice && !loading && !fetchError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Player props are loading — today's schedule, game lines, and stats are shown below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchError && !loading && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -532,6 +655,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
venue={g.venue}
|
||||
context={g.context}
|
||||
props={g.props}
|
||||
status={g.status}
|
||||
score={g.score}
|
||||
gameLines={g.gameLines}
|
||||
gradedProps={gradedProps}
|
||||
loadingKey={gradingKey}
|
||||
errorByKey={errorByKey}
|
||||
@@ -548,20 +674,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
|
||||
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
|
||||
|
||||
{unsupportedSports.length > 0 && !loading && (
|
||||
<p
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{unsupportedSports.map((s) => s.toUpperCase()).join(', ')} odds endpoint not configured yet.
|
||||
</p>
|
||||
)}
|
||||
{/* Session 24 — removed the developer-facing "odds endpoint not
|
||||
configured yet" footer note. A sport with no data simply doesn't
|
||||
render a row; users never see internal wiring state. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user