'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; // Session 45 — the live Slate now renders the pre-graded snapshot via the // VYNDR 2.0 card. Legacy GameCard is kept ONLY for its shared types. import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard'; import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; import { PropRowProp, Tier } from '@/components/PropRow'; import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter'; import { useAuth } from '@/contexts/AuthContext'; // Session 23 — all-day intelligence layer. The stat filter is the // navigation system; streaks + hot lists layer ON TOP of the odds the // Slate already shows, never replacing them. import StatFilterPills from '@/components/StatFilterPills'; import StreaksPanel from '@/components/StreaksPanel'; import HotListPanel from '@/components/HotListPanel'; // Session 28 — line-movement + book-comparison read-only panels. Both // self-hide when empty; free users see a top-3 teaser. import MoversPanel from '@/components/MoversPanel'; import BestLinesPanel from '@/components/BestLinesPanel'; /** * The Slate (Session 13). * * Browse-first dashboard surface. Fetches today's odds across the * selected sport(s), groups by game, hands off to GameCard. Owns the * graded-prop Map and the in-flight grading key so PropRow loading * states are accurate. * * Backend contract: * /api/odds/nba — NBA props (existing proxy) * /api/odds/soccer/:league — soccer per league (existing proxy) * /api/odds/mlb — MLB props (may not exist yet — * we surface a friendly "coming soon" * if the endpoint 404s) * /api/scan — submits a grade request (existing) * * State minimalism: one Map for graded props, one nullable loading * key, one error-by-key map. The Slate component is the only writer. */ // Session 14 — shimmer skeleton style. Width is a percentage string // so cards remain responsive at small viewports. The keyframe rule // lives in globals.css. function skeletonStyle({ widthPct, height }: { widthPct: number; height: number }): React.CSSProperties { return { width: `${widthPct}%`, height, borderRadius: 4, background: 'linear-gradient(90deg, #12121A 0%, #1A1A24 50%, #12121A 100%)', backgroundSize: '200% 100%', animation: 'vyndr-shimmer 1.5s ease-in-out infinite', }; } type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer'; const TABS: Array<{ id: SlateTab; label: string }> = [ { id: 'all', label: 'All' }, { id: 'nba', label: 'NBA' }, { id: 'wnba', label: 'WNBA' }, { id: 'mlb', label: 'MLB' }, { id: 'soccer', label: 'Soccer' }, ]; // Per-tab → list of fetch URLs. `null` indicates "no endpoint yet"; // the Slate renders a soft "coming soon" badge for that sport rather // than 404-spamming the backend. Session 14 brought WNBA + MLB online. const FETCH_URLS: Record, string[] | null> = { nba: ['/api/odds/nba'], wnba: ['/api/odds/wnba'], mlb: ['/api/odds/mlb'], soccer: ['/api/odds/soccer/wc'], }; // Session 17 — Express `/api/odds/{sport}` returns props in the // GROUPED shape produced by `src/routes/odds.js#groupProps`: // { player, stat_type, home_team, away_team, game_time, // lines: [{ book, line, over_odds, under_odds }] } // not a flat `line`/`direction`/`book` per prop. Pre-Session 17 the // Slate assumed flat — every prop got filtered out by the // `Number.isFinite(r.line)` check, which is why WNBA (the only // active sport at audit time) showed "No games published yet." // // RawProp now mirrors both shapes; the unwrapper below picks the // best available line out of the `lines[]` array when present. interface RawProp { player?: string; stat_type?: string; // Flat-shape fields (pre-Session 17 contract — still tolerated) line?: number; direction?: 'over' | 'under'; book?: string; // Grouped-shape fields (actual Express response since Session 7+) lines?: Array<{ book?: string; line?: number; over_odds?: number; under_odds?: number }>; game_time?: string; home_team?: string; away_team?: string; } interface OddsResponse { sport?: string; props?: RawProp[]; error?: string; } // Pick the most useful single line out of a grouped prop. Preference: // 1. A line marked `direction: over` (matches the default scan flow) // 2. The first numeric line in the array // 3. The flat-shape `line` field if present (legacy callers) function pickLine(r: RawProp): { line: number; direction: 'over' | 'under'; book: string } | null { // Flat shape wins when present — preserves the older test fixtures. if (Number.isFinite(r.line)) { return { line: r.line as number, direction: (r.direction as 'over' | 'under') || 'over', book: r.book || 'draftkings', }; } if (Array.isArray(r.lines)) { const first = r.lines.find((l) => Number.isFinite(l.line)); if (first && Number.isFinite(first.line)) { // The grouped response doesn't carry a per-line direction — // each line has both over/under odds. Default to `over` since // that's the default scan direction. return { line: first.line as number, direction: 'over', book: first.book || 'draftkings', }; } } return null; } interface SlateGame { sport: SlateSport; homeTeam: string; awayTeam: string; gameTime?: string; 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 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 45 — pre-graded snapshot response (snapshot:{sport}:latest). interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null } interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number } interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] } // Session 46 — MLB probable pitchers response. interface PitcherSide { team?: string | null; pitcher?: string | null; era?: number | null } interface PitcherGame { home?: PitcherSide; away?: PitcherSide } interface PitcherResponse { games?: PitcherGame[] } // Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the // VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas). type GradeIndex = ReturnType; type DeltaIndex = ReturnType; type PitcherMap = ReturnType; function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap): GameCardData { return { id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`, sport: g.sport, live: g.status === 'in', score: g.score ? { away: g.score.away, home: g.score.home } : undefined, away: { abbr: g.awayAbbr || g.awayTeam, name: g.awayTeam }, home: { abbr: g.homeAbbr || g.homeTeam, name: g.homeTeam }, time: formatGameTime(g.gameTime), venue: g.venue, lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [], playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex), // Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers). pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined, streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })), }; } // ---- 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 } // 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): 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). */ // 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, 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[] = []; 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[] { const games = new Map(); for (const r of rawProps) { if (!r.player || !r.stat_type) continue; // Session 17 — unwrap the grouped `lines[]` shape from Express. const lineInfo = pickLine(r); if (!lineInfo) continue; const home = r.home_team || '?'; const away = r.away_team || '?'; const time = r.game_time || ''; const key = `${away}__${home}__${time}`; if (!games.has(key)) { games.set(key, { sport, homeTeam: home, awayTeam: away, gameTime: time || undefined, props: [], }); } games.get(key)!.props.push({ player: r.player, stat_type: r.stat_type, line: lineInfo.line, direction: lineInfo.direction, book: lineInfo.book, }); } // Sort each game's props by player + stat for stable rendering. for (const g of games.values()) { g.props.sort((a, b) => { if (a.player !== b.player) return a.player.localeCompare(b.player); return a.stat_type.localeCompare(b.stat_type); }); } return Array.from(games.values()).sort((a, b) => { const ta = a.gameTime ? Date.parse(a.gameTime) : 0; const tb = b.gameTime ? Date.parse(b.gameTime) : 0; return ta - tb; }); } export interface SlateProps { initialTab?: SlateTab; tier?: Tier; } export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) { const router = useRouter(); const { session } = useAuth(); const [tab, setTab] = useState(initialTab); // Session 23 — active stat category for the intelligence panels. 'all' // shows everything; selecting one narrows streaks + hot list. Schedule // and game lines stay visible regardless (handled inside GameCard). const [activeStat, setActiveStat] = useState('all'); const [games, setGames] = useState([]); // Session 45 — merged pre-graded snapshot across the loaded sports. const [snapGrades, setSnapGrades] = useState([]); const [snapDeltas, setSnapDeltas] = useState([]); const [pitcherGames, setPitcherGames] = useState([]); const [loading, setLoading] = useState(false); const [fetchError, setFetchError] = useState(null); // Session 26 — per-sport schedule counts for the tab labels, fetched // ONCE on mount for every schedule-backed sport (free ESPN, cached 60s). // This makes "MLB (15)" / "WNBA (2)" show on their tabs even while the // user is viewing a different sport — the count was previously only // known for sports loaded by the active tab. const [scheduleCounts, setScheduleCounts] = useState>>({}); // 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); // Search filter (Phase 3.4 — kept here so the Slate owns its own filtering). const [searchQuery, setSearchQuery] = useState(''); // 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); // Sports that carry a schedule/streaks feed (ESPN-backed). Soccer // has no schedule endpoint, so it stays odds-only. const SCHEDULE_SPORTS = new Set(['nba', 'wnba', 'mlb']); const sportsToFetch: SlateSport[] = []; const consider = (s: Exclude) => { if (FETCH_URLS[s] !== null) sportsToFetch.push(s as SlateSport); }; if (active === 'all') { consider('nba'); consider('wnba'); consider('mlb'); consider('soccer'); } else { consider(active); } if (sportsToFetch.length === 0) { setGames([]); setLoading(false); return; } const getJson = async (url: string): Promise => { 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, streaksRes, snap, pitchersRes] = await Promise.all([ Promise.all(oddsUrls.map((u) => getJson(u))), SCHEDULE_SPORTS.has(sport) ? getJson(`/api/schedule/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson(`/api/gamelines/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson(`/api/streaks/${sport}`) : Promise.resolve(null), // Session 45 — pre-graded snapshot (locked grades + line deltas). getJson(`/api/snapshot/${sport}`), // Session 46 — MLB probable starting pitchers. sport === 'mlb' ? getJson(`/api/schedule/mlb/pitchers`) : 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, streaksRes?.streaks); return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [], pitcherGames: pitchersRes?.games || [] }; }), ); const allGames: SlateGame[] = []; const allSnapGrades: SnapshotGrade[] = []; const allSnapDeltas: SnapshotDelta[] = []; const allPitcherGames: PitcherGame[] = []; let anyOddsOk = false; let anyScheduleShown = false; for (const s of perSport) { allGames.push(...s.merged); allSnapGrades.push(...s.snapGrades); allSnapDeltas.push(...s.snapDeltas); allPitcherGames.push(...s.pitcherGames); if (s.oddsOk) anyOddsOk = true; if (s.hadSchedule) anyScheduleShown = true; } setGames(allGames); setSnapGrades(allSnapGrades); setSnapDeltas(allSnapDeltas); setPitcherGames(allPitcherGames); // 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]); // Session 26 — fetch schedule counts for every schedule-backed sport // once on mount, so all sport tabs show their game count regardless of // which tab is active. Free + cached; failures leave the count absent. useEffect(() => { let cancelled = false; const SPORTS: SlateSport[] = ['nba', 'wnba', 'mlb']; (async () => { const entries = await Promise.all( SPORTS.map(async (sport) => { try { const r = await fetch(`/api/schedule/${sport}`, { cache: 'no-store' }); if (!r.ok) return [sport, undefined] as const; const data = (await r.json()) as ScheduleResponse; return [sport, Array.isArray(data?.games) ? data.games.length : undefined] as const; } catch { return [sport, undefined] as const; } }), ); if (cancelled) return; const next: Partial> = {}; for (const [sport, count] of entries) if (count != null) next[sport] = count; setScheduleCounts(next); })(); return () => { cancelled = true; }; }, []); // Session 45 — the on-demand "Read" grade flow is RETIRED. Grades come from // the scheduled snapshot pipeline (snapshot:{sport}:latest), overlaid onto the // slate below. The legacy onGrade call site was removed with the legacy card. // Filter pipeline — searchQuery applied to games + props. // Session 45 — index the pre-graded snapshot once for the overlay. const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]); const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]); const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]); const filteredGames = useMemo(() => { // Session 44 — drop completed games >24h old so a 5-day-old FINAL never // lingers on the dashboard. Upcoming + live always show. const fresh = games.filter((g) => isRelevantGame(g)); if (!searchQuery.trim()) return fresh; const q = searchQuery.toLowerCase(); return fresh .map((g) => { const homeMatch = g.homeTeam.toLowerCase().includes(q); const awayMatch = g.awayTeam.toLowerCase().includes(q); if (homeMatch || awayMatch) return g; const matchedProps = g.props.filter( (p) => p.player.toLowerCase().includes(q) || p.stat_type.toLowerCase().includes(q), ); if (matchedProps.length === 0) return null; return { ...g, props: matchedProps }; }) .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> = {}; 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') { // Prefer the loaded total; fall back to the sum of schedule counts // so the ALL tab reflects every sport even before its games load. if (games.length > 0) return games.length; const sum = Object.values(scheduleCounts).reduce((a, b) => a + (b || 0), 0); return sum || null; } // Loaded games are the most accurate (they include odds-only games); // otherwise fall back to the mount-time schedule count. return countBySport[id as SlateSport] ?? scheduleCounts[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)}`; return (
{/* Sticky header — search + tabs */}
setSearchQuery(e.target.value)} placeholder="Search teams, players, stat types…" aria-label="Filter the slate" style={{ width: '100%', padding: '10px 14px', background: 'var(--bg-2, #12121A)', border: '1px solid var(--border, #1A1A24)', borderRadius: 6, color: 'var(--text-0, #F0F0F5)', fontSize: 14, marginBottom: 12, }} />
{TABS.map((t) => { const active = t.id === tab; return ( ); })}
{/* 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' && ( )}
{/* Body */} {loading && ( // Session 14 — shimmer skeletons replace the bare "Loading…" text. // Three placeholder cards approximating GameCard dimensions; the // shimmer animation lives in globals.css (`@keyframes // vyndr-shimmer`) so multiple loading surfaces stay in sync.
{[0, 1, 2].map((i) => (