'use client'; import { useCallback, useEffect, useMemo, useRef, 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, gradeKey, flattenToEdgeBoard, edgeBoardBreadth } from '@/lib/slateAdapter'; import MobileEdgeBoard from '@/components/vyndr/MobileEdgeBoard'; // Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no // live games (and it's not a fetch failure) the grid shows REAL data — // yesterday's proven receipts + tomorrow's date-pinned schedule. import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook'; // Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model. import { collectBreadth } from '@/lib/marketBreadth'; import MarketBreadth from '@/components/vyndr/MarketBreadth'; // A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort. // Grades never change in-game; these marks are tracking, labeled as such. import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress'; import { emptyStateCopy } from '@/lib/emptyState'; 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; projection?: number | null; model_value?: number | 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[]; updated_at?: string | null; refreshed_at?: string | null } // 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[] } // A1 S11 — /api/live/:sport response (liveTrackingService envelope). interface LivePlayerEntry { name?: string; team?: string | null; values?: Record } interface LiveGame { id: string; home?: string | null; away?: string | null; progress?: { label?: string; fraction?: number } | null; players?: Record } interface LiveResponse { sport?: string; hasLive?: boolean; games?: LiveGame[] } type LiveIndex = ReturnType; // Sports with a free live box feed wired (specs/LIVE-TRACKING.md). const LIVE_TRACK_SPORTS = new Set(['mlb', 'wnba']); // 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, statFilter: string = 'all', viability: Parameters[5] = null, liveIndex: LiveIndex | null = null): GameCardData { // Session 60 (night2/C) — ONE stat selection filters every layer: props on // the cards narrow together with the streaks + hot-list panels below. const props = statFilter && statFilter !== 'all' ? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter) : g.props; // A1 S11 — overlay live box-line tracking onto the built strips, but ONLY // for games the schedule marks in-progress (a shared player name in another // game must not leak marks onto a pre-game card). const strips = buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability); const liveStrips = g.status === 'in' && liveIndex ? attachLiveProgress(strips, liveIndex) : strips; 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) : [], // Session 59 (work-order 1.6) — the join guard drops bad feed rows inside // buildPlayerStripsFromProps (game participants passed above). playerStrips: liveStrips, // 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. // Session 55 — relative freshness label ("updated 12s ago" → "3m ago"). function freshLabel(ts: number | null, now: number): string { if (!ts) return ''; const s = Math.max(0, Math.round((now - ts) / 1000)); if (s < 60) return `${s}s ago`; const m = Math.round(s / 60); if (m < 60) return `${m}m ago`; return `${Math.round(m / 60)}h ago`; } /** Session 64 — ET date string offset by n days (Yesterday/Tomorrow nav). */ function etDateWithOffset(offset: number): string { const d = new Date(Date.now() + offset * 86_400_000); return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d); } 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, // A1 S3 — keep the full per-book rows so the strip layer can mark // the best available price (pickLine alone discards the comparison). books: Array.isArray(r.lines) ? r.lines : undefined, }); } // 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; }); } /** Session 64 (A1-S5) — the Yesterday results panel: that date's settled * public reads (outcome + CLV), straight from the ledger. Self-hides empty. */ function YesterdaySettle({ date }: { date: string }) { const [rows, setRows] = useState>([]); useEffect(() => { let active = true; fetch(`/api/ledger/model?date=${date}&limit=60`) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (active && d) setRows((d.entries || []).filter((e: { outcome?: string | null }) => e.outcome)); }) .catch(() => { /* self-hide */ }); return () => { active = false; }; }, [date]); if (rows.length === 0) return null; return (
THE SETTLE · {date}
{rows.map((r) => (
{r.player_name} {r.stat.replace(/_/g, ' ')} {String(r.side).toUpperCase() === 'UNDER' ? 'u' : 'o'}{r.line} · {r.grade} {r.outcome === 'hit' ? '✓ HIT' : r.outcome === 'miss' ? '✕ MISS' : '– PUSH'}{r.actual_value != null ? ` (${r.actual_value})` : ''} {r.clv_result && CLV {r.clv_result.toUpperCase()}}
))}
); } // The /api/ledger/model settled-row shape (subset the receipts read). interface ModelReceiptRow { player_name?: string; player?: string; sport?: string; stat?: string; line?: number; side?: string; grade?: string; outcome?: string | null; actual_value?: number | null; clv_result?: string | null; } // buildHeroReceipts output (proven yesterday hit). interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null } // mapTomorrowPreview output. interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null } /** * Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in * place of the old dead-end CTA: yesterday's PROVEN A-tier receipts + * tomorrow's date-pinned schedule preview (both REAL, always-available data — * never an invented line). The month-aware header ALWAYS shows, so the grid is * never blank. Distinct from the network `fetchError` state. */ function OutlookSurface({ tab }: { tab: SlateTab }) { const [settled, setSettled] = useState(null); const [tomorrow, setTomorrow] = useState(null); useEffect(() => { let active = true; const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : ''; fetch(`/api/ledger/model?limit=80${sportQ}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); }) .catch(() => { if (active) setSettled([]); }); return () => { active = false; }; }, [tab]); useEffect(() => { let active = true; const date = etDateWithOffset(1); // tomorrow, ET — the schedule route is date-pinned const SPORTS: SlateSport[] = tab === 'all' ? ['mlb', 'nba', 'wnba'] : (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : []; if (SPORTS.length === 0) { setTomorrow([]); return; } Promise.all(SPORTS.map(async (sport) => { try { const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' }); if (!r.ok) return [] as ScheduleGame[]; const d = (await r.json()) as ScheduleResponse; return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport })); } catch { return [] as ScheduleGame[]; } })).then((lists) => { if (active) setTomorrow(lists.flat()); }); return () => { active = false; }; }, [tab]); const outlook = useMemo( () => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] }, [settled, tomorrow], ); const receipts: OutlookReceipt[] = outlook.receipts ?? []; const preview: OutlookGame[] = outlook.tomorrow ?? []; const { title, body } = emptyStateCopy(tab); return (

OUTLOOK

{title}

{body}

{receipts.length > 0 && (
YESTERDAY · PROVEN
{receipts.map((r, i) => (
{String(r.sport || '').toUpperCase()} {r.grade}
{r.player}
{String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
✓ HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''}
))}
)} {preview.length > 0 && (
TOMORROW · SCHEDULE — lines post on the day
{preview.map((g) => (
{g.sport && {g.sport}} {g.away} @ {g.home} {g.time && {formatGameTime(g.time)}}
))}
)}
); } export interface SlateProps { initialTab?: SlateTab; tier?: Tier; /** Session 49 — user's preferred books (highlighted in each card's lines). */ preferredBooks?: string[]; /** Session 59 (2.1) — the sport tabs are THE filter; parents (dashboard * legacy sections) subscribe instead of running their own tab row. */ onTabChange?: (tab: SlateTab) => void; } const VALID_TABS = new Set(['all', 'nba', 'wnba', 'mlb', 'soccer']); /** ?sport= from the URL (deep-linkable tabs, spec §6 SportTabs). */ function tabFromUrl(): SlateTab | null { if (typeof window === 'undefined') return null; const q = new URLSearchParams(window.location.search).get('sport'); const t = String(q || '').toLowerCase() as SlateTab; return VALID_TABS.has(t) ? t : null; } export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks, onTabChange }: SlateProps) { const router = useRouter(); const { session } = useAuth(); // Session 59 (2.1) — the URL is the source of truth on load (?sport=mlb // deep-links a filtered slate); user prefs are the fallback default. const [tab, setTabState] = useState(() => tabFromUrl() || initialTab); const setTab = (t: SlateTab) => { setTabState(t); if (typeof window !== 'undefined') { const url = new URL(window.location.href); if (t === 'all') url.searchParams.delete('sport'); else url.searchParams.set('sport', t); window.history.replaceState(null, '', url.toString()); } if (onTabChange) onTabChange(t); }; // 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([]); // P1-6 — the PIPELINE's freshness (refreshed_at, the same field the app-bar // clock reads). "UPDATED Xs ago" must show the DATA age, not the client's poll // time — else the strip claims "0s ago" while the clock (honestly) says 46m. const [snapRefreshedAt, setSnapRefreshedAt] = useState(null); const [snapDeltas, setSnapDeltas] = useState([]); const [pitcherGames, setPitcherGames] = useState([]); // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). const [viability, setViability] = useState<{ lineups?: { byPlayer: Record; postedTeams: string[] }; injuries?: Record } | null>(null); // A1 S11 — live tracking responses per sport (polled only while live games // are on screen; the backend cache makes this ~1 upstream call per game/90s). const [liveBySport, setLiveBySport] = useState>({}); // Session 64 (A1-S5) — date navigation: -1 = Yesterday (results surface), // 0 = Today, +1 = Tomorrow (schedule until lines post). const [dateOffset, setDateOffset] = useState(0); const dateOffsetRef = useRef(0); useEffect(() => { dateOffsetRef.current = dateOffset; }, [dateOffset]); const [loading, setLoading] = useState(false); const [fetchError, setFetchError] = useState(null); // Session 55 — real-time freshness: when the slate last pulled fresh data, // and a ticking clock so "updated Xs ago" advances between polls. const [lastRefreshed, setLastRefreshed] = useState(null); const [nowTick, setNowTick] = useState(() => Date.now()); // 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. // Session 55 — real-time layer. `silent` background refreshes keep the slate // alive (polling) without the skeleton flash or clearing the current view. const fetchSlate = useCallback(async (active: SlateTab, silent = false, offset = dateOffsetRef.current) => { const dateParam = offset === 0 ? '' : `?date=${etDateWithOffset(offset)}`; const isToday = offset === 0; if (!silent) { 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[]; // Session 64 — Yesterday/Tomorrow are schedule+results surfaces: the // odds/grades/pitcher layers are TODAY's and never fake other dates. const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes, lineupsRes, injuriesRes] = await Promise.all([ isToday ? Promise.all(oddsUrls.map((u) => getJson(u))) : Promise.resolve([] as (OddsResponse | null)[]), SCHEDULE_SPORTS.has(sport) ? getJson(`/api/schedule/${sport}${dateParam}`) : Promise.resolve(null), isToday && SCHEDULE_SPORTS.has(sport) ? getJson(`/api/gamelines/${sport}`) : Promise.resolve(null), isToday && SCHEDULE_SPORTS.has(sport) ? getJson(`/api/streaks/${sport}`) : Promise.resolve(null), // Session 45 — pre-graded snapshot (locked grades + line deltas). isToday ? getJson(`/api/snapshot/${sport}`) : Promise.resolve(null), // Session 46 — MLB probable starting pitchers. isToday && sport === 'mlb' ? getJson(`/api/schedule/mlb/pitchers`) : Promise.resolve(null), // Session 64 (A1-S5) — lineup confirmation (MLB) + injury wire. isToday && sport === 'mlb' ? getJson<{ byPlayer: Record; postedTeams: string[] }>(`/api/schedule/mlb/lineups`) : Promise.resolve(null), isToday && SCHEDULE_SPORTS.has(sport) ? getJson<{ byPlayer: Record }>(`/api/schedule/${sport}/injuries`) : 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 || [], snapRefreshed: snap?.refreshed_at || snap?.updated_at || null, pitcherGames: pitchersRes?.games || [], lineups: lineupsRes || null, injuries: injuriesRes?.byPlayer || null }; }), ); const allGames: SlateGame[] = []; const allSnapGrades: SnapshotGrade[] = []; const allSnapDeltas: SnapshotDelta[] = []; const allPitcherGames: PitcherGame[] = []; let mergedLineups: { byPlayer: Record; postedTeams: string[] } | null = null; const mergedInjuries: Record = {}; 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.lineups && s.lineups.byPlayer) mergedLineups = s.lineups; if (s.injuries) Object.assign(mergedInjuries, s.injuries); if (s.oddsOk) anyOddsOk = true; if (s.hadSchedule) anyScheduleShown = true; } // A silent background poll that came back empty (transient blip) must NOT // wipe the current view or flash an error — keep what the user is seeing. if (silent && allGames.length === 0) { setLoading(false); return; } setGames(allGames); setSnapGrades(allSnapGrades); // P1-6 — newest pipeline refreshed_at across the loaded sports (the real // data age, shared with the app-bar clock — one source of truth). { const stamps = perSport.map((s) => (s.snapRefreshed ? Date.parse(s.snapRefreshed) : NaN)).filter((t) => Number.isFinite(t)); setSnapRefreshedAt(stamps.length ? Math.max(...stamps) : null); } setSnapDeltas(allSnapDeltas); setPitcherGames(allPitcherGames); setViability({ lineups: mergedLineups || undefined, injuries: Object.keys(mergedInjuries).length ? mergedInjuries : undefined }); setLastRefreshed(Date.now()); // Odds down but schedule carried the slate → soft notice, not a wall. // (Only meaningful for today — other dates are schedule surfaces.) if (!silent && isToday && !anyOddsOk && anyScheduleShown) setOddsNotice(true); // Genuine total failure (no odds, no schedule, anywhere) → error. if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) { setFetchError('No games available right now. Check back soon.'); } setLoading(false); }, []); useEffect(() => { fetchSlate(tab, false, dateOffset); }, [tab, fetchSlate, dateOffset]); // Session 55 — auto-refresh: poll the slate every 60s so fresh snapshot grades // + schedule/score updates appear without a page reload. Silent (no skeleton). useEffect(() => { const id = setInterval(() => { if (dateOffsetRef.current === 0) fetchSlate(tab, true, 0); }, 60_000); return () => clearInterval(id); }, [tab, fetchSlate]); // A 15s ticking clock so the "updated Xs ago" freshness label stays honest. useEffect(() => { const id = setInterval(() => setNowTick(Date.now()), 15_000); return () => clearInterval(id); }, []); // A1 S11 — LIVE SLATE MODE poll. Fetch /api/live/{sport} every 60s ONLY // while live games for a track-able sport are on screen (today's slate). // Nothing live → no polling at all; the shared backend cache (90s) means // the upstream cost is ~1 boxscore call per live game per window TOTAL. const liveSportsKey = useMemo(() => { if (dateOffset !== 0) return ''; const sports = new Set(); for (const g of games) { if (g.status === 'in' && LIVE_TRACK_SPORTS.has(g.sport)) sports.add(g.sport); } return [...sports].sort().join(','); }, [games, dateOffset]); useEffect(() => { if (!liveSportsKey) { setLiveBySport({}); return; } const sports = liveSportsKey.split(','); let cancelled = false; const poll = async () => { const entries = await Promise.all(sports.map(async (sport) => { try { const r = await fetch(`/api/live/${sport}`, { cache: 'no-store' }); if (!r.ok) return [sport, null] as const; return [sport, (await r.json()) as LiveResponse] as const; } catch { return [sport, null] as const; } })); if (cancelled) return; setLiveBySport((prev) => { const next: Record = {}; for (const [sport, resp] of entries) { // A transient fetch failure keeps the previous live view (never blank // a good in-progress mark on a blip). if (resp) next[sport] = resp; else if (prev[sport]) next[sport] = prev[sport]; } return next; }); }; poll(); const id = setInterval(poll, 60_000); return () => { cancelled = true; clearInterval(id); }; }, [liveSportsKey]); // 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]); // A1 S11 — one merged live index across the polled sports. const liveIndex = useMemo(() => buildLiveIndex(Object.values(liveBySport)), [liveBySport]); 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]); // A1 S11 — live games with TRACKED props float to the top, ordered by // proximity-to-hit (pure sortLiveFirst; everything else keeps tip-off order). const orderedGames = useMemo(() => { if (!liveIndex || liveIndex.count === 0) return filteredGames; return sortLiveFirst(filteredGames, (g: SlateGame) => ( g.status === 'in' ? gameLiveProximity(g.props, gradeIndex, liveIndex) : { tracked: false, proximity: 0 } )) as SlateGame[]; }, [filteredGames, gradeIndex, liveIndex]); // Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that // carries ≥2 book lines, compare the market's median line to the model's // projection (signed by the graded side). collectBreadth drops <2-book props // and ranks by |edge| — an empty result self-hides the strip. This is the // REAL data behind the DeskShowcase "consensus vs model" claim. const breadthItems = useMemo(() => { const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = []; for (const g of filteredGames) { for (const p of g.props) { if (!Array.isArray(p.books) || p.books.length < 2) continue; const grade = (gradeIndex as Record)[gradeKey(p.player, p.stat_type)]; const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null; items.push({ player: p.player, stat: p.stat_type, side: (grade && grade.direction) || p.direction || 'over', line: p.line, books: p.books, modelValue: mv == null ? null : Number(mv), }); } } return collectBreadth(items, 6); }, [filteredGames, gradeIndex]); // 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 */}
{/* Session 55 — the live signal strip: proves the data is alive. A pulsing dot, the graded-prop count, any in-progress games, and a ticking "updated Xs ago" freshness stamp fed by the 60s poll. */}
SIGNAL LIVE {snapGrades.length > 0 && ( <>·{snapGrades.length} PROPS GRADED )} {games.some((g) => g.status === 'in') && ( <>·{games.filter((g) => g.status === 'in').length} LIVE )} {/* P1-6 — DATA age from the pipeline's refreshed_at (the same field the app-bar clock reads), NOT the client poll time. "UPDATED 0s ago" while the clock said "SYNC 46:03" was the contradiction; both now read refreshed_at. The clock owns the amber/STALE reaction. */} {snapRefreshedAt && ( <>·UPDATED {freshLabel(snapRefreshedAt, nowTick)} )}
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, }} /> {/* Session 64 (A1-S5) — date navigation. Yesterday = results surface (finals + settled reads); Tomorrow = schedule until lines post. */}
{([[-1, 'YESTERDAY'], [0, 'TODAY'], [1, 'TOMORROW']] as [number, string][]).map(([off, label]) => ( ))} {dateOffset !== 0 && ( {etDateWithOffset(dateOffset)} · {dateOffset < 0 ? 'results + settled reads' : 'schedule — lines post on the day'} )}
{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) => (