Files
vyndr/web/src/components/Slate.tsx
T
builtbykev c8790fde55 Session 57: Phase 0 — Kill the Lies (2309 tests)
Work-order Phase 0 (Jul 10 live audit): every fabricated UI element deleted
or rewired to real data. Deletion sprint — no new product features.

0.1 Fake NBA game: root cause was scheduleService fetching the ESPN
    scoreboard with no ?dates= param or date filter — off-season ESPN
    returns the NEAREST slate (Jun 13 NYK@SA Finals rendered as tonight).
    Now pinned to the requested ET date + defensive filter; undated events
    dropped. Honest month-aware per-sport empty states (lib/emptyState.js).
0.2 Fake header counters: liveTick stripped to a bare 1s pulse (the
    auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades are
    dead). New GET /api/snapshot/summary (cache-only, before /:sport) +
    Next proxy; HeartbeatBar shows the real graded count and SYNC =
    elapsed since the last pipeline run (amber past 5 min).
0.3 Ticker: hardcoded fallback items deleted (real snapshot exhaust only);
    MOVE kept — computeLineDeltas is real movement. <4 real items → no
    ticker; bar publishes --ticker-h so the fixed header collapses cleanly.
0.4 /terminal retired: route redirects to /dashboard; VVI/injury-wire/
    leaders layouts preserved unrouted as §12 content-engine templates.
    Nav PRIMARY = Slate/Scan/Ledger; BottomTabBar Terminal→Explore; PWA
    shortcut Terminal→Ledger; #terminal alias → /dashboard.
0.5 ›Query nav pill deleted (duplicate /scan link).

Backend 2289 → 2309 tests (199 suites), web build exit 0.
Spec: specs/phase-0-kill-the-lies.md. Next: work-order Phase 1 (ledger
persistence + settlement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:17:49 -04:00

854 lines
35 KiB
TypeScript

'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 { 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<Exclude<SlateTab, 'all'>, 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<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
type PitcherMap = ReturnType<typeof buildPitcherMap>;
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<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.
// 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`;
}
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).
*/
// 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[] = [];
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<string, SlateGame>();
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;
/** Session 49 — user's preferred books (highlighted in each card's lines). */
preferredBooks?: string[];
}
export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks }: SlateProps) {
const router = useRouter();
const { session } = useAuth();
const [tab, setTab] = useState<SlateTab>(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<string>('all');
const [games, setGames] = useState<SlateGame[]>([]);
// Session 45 — merged pre-graded snapshot across the loaded sports.
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(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<number | null>(null);
const [nowTick, setNowTick] = useState<number>(() => 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<Partial<Record<SlateSport, number>>>({});
// 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) => {
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<SlateSport>(['nba', 'wnba', 'mlb']);
const sportsToFetch: SlateSport[] = [];
const consider = (s: Exclude<SlateTab, 'all'>) => {
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 <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, streaksRes, snap, pitchersRes] = 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),
// Session 45 — pre-graded snapshot (locked grades + line deltas).
getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
// Session 46 — MLB probable starting pitchers.
sport === 'mlb' ? getJson<PitcherResponse>(`/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;
}
// 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);
setSnapDeltas(allSnapDeltas);
setPitcherGames(allPitcherGames);
setLastRefreshed(Date.now());
// Odds down but schedule carried the slate → soft notice, not a wall.
if (!silent && !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); }, [tab, fetchSlate]);
// 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(() => { fetchSlate(tab, true); }, 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);
}, []);
// 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<Record<SlateSport, number>> = {};
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<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') {
// 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 (
<div style={{ display: 'grid', gap: 24, paddingBottom: 24 }}>
{/* Sticky header — search + tabs */}
<div
style={{
position: 'sticky',
// Clears nav (60) + heartbeat (30) + the dynamic ticker (Session 57:
// --ticker-h is 32px only when the ticker has ≥4 real items).
top: 'calc(90px + var(--ticker-h, 0px))',
zIndex: 5,
background: 'var(--bg-0, #0A0A0F)',
paddingTop: 12,
paddingBottom: 12,
}}
>
{/* 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. */}
<div
className="mono"
style={{
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-secondary, #8A8A9A)',
marginBottom: 10,
}}
>
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>SIGNAL LIVE</span>
{snapGrades.length > 0 && (
<><span style={{ color: '#3A3A48' }}>·</span><span>{snapGrades.length} PROPS GRADED</span></>
)}
{games.some((g) => g.status === 'in') && (
<><span style={{ color: '#3A3A48' }}>·</span><span style={{ color: 'var(--live, #FF4757)', fontWeight: 700 }}>{games.filter((g) => g.status === 'in').length} LIVE</span></>
)}
{lastRefreshed && (
<><span style={{ color: '#3A3A48' }}>·</span><span title="The slate auto-refreshes every 60 seconds">UPDATED {freshLabel(lastRefreshed, nowTick)}</span></>
)}
</div>
<input
type="search"
value={searchQuery}
onChange={(e) => 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,
}}
/>
<div
role="tablist"
aria-label="Sport"
style={{
display: 'flex',
gap: 6,
overflowX: 'auto',
paddingBottom: 2,
WebkitOverflowScrolling: 'touch',
}}
>
{TABS.map((t) => {
const active = t.id === tab;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setTab(t.id)}
className="mono"
style={{
flexShrink: 0,
padding: '6px 14px',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
border: active ? '1px solid var(--grade-a, #00D4A0)' : '1px solid var(--border, #1A1A24)',
background: active ? 'var(--grade-a, #00D4A0)' : 'transparent',
color: active ? 'var(--bg-0, #0A0A0F)' : 'var(--text-secondary, #8A8A9A)',
borderRadius: 4,
cursor: 'pointer',
}}
>
{t.label}{tabCount(t.id) != null ? ` (${tabCount(t.id)})` : ''}
</button>
);
})}
</div>
{/* 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 */}
{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.
<div style={{ display: 'grid', gap: 16 }} role="status" aria-label="Loading the slate">
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
background: 'var(--bg-2, #12121A)',
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
padding: 16,
display: 'grid',
gap: 10,
}}
aria-hidden="true"
>
<div style={skeletonStyle({ widthPct: 60, height: 18 })} />
<div style={skeletonStyle({ widthPct: 40, height: 10 })} />
<div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
<div style={skeletonStyle({ widthPct: 88, height: 14 })} />
<div style={skeletonStyle({ widthPct: 70, height: 14 })} />
<div style={skeletonStyle({ widthPct: 80, height: 14 })} />
</div>
</div>
))}
</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&apos;s schedule, game lines, and stats are shown below.
</div>
)}
{fetchError && !loading && (
<div
role="alert"
style={{
padding: 14,
border: '1px solid var(--grade-d, #FF6B6B)',
color: 'var(--grade-d, #FF6B6B)',
borderRadius: 6,
fontSize: 13,
}}
>
{fetchError}
</div>
)}
{!loading && !fetchError && filteredGames.length === 0 && (
<div
className="surface"
style={{
padding: 28,
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
{searchQuery ? (
<>
<p style={{ marginBottom: 12 }}>
No props found for &ldquo;{searchQuery}&rdquo;.
</p>
<a
href={manualScanHref}
className="btn-primary"
style={{
display: 'inline-block',
padding: '8px 16px',
background: 'var(--grade-a, #00D4A0)',
color: 'var(--bg-0, #0A0A0F)',
borderRadius: 4,
textDecoration: 'none',
fontSize: 13,
fontWeight: 700,
}}
>
Scan it manually
</a>
</>
) : (
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
// off-season sports name their return window; in-season = off-day.
(() => {
const { title, body } = emptyStateCopy(tab);
return (
<>
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
<p>{body}</p>
</>
);
})()
)}
</div>
)}
<div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => (
<VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')}
/>
))}
</div>
{/* Session 23 — intelligence layer. These coexist WITH the odds
above; they never replace games. Both self-hide when empty, so
an off-hours slate with no warm logs simply shows the games. */}
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
{/* Session 28 — market layers: how lines are MOVING and where the
BEST price sits. Both read cached data (zero credits) and
self-hide until there's something to show. */}
<MoversPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
<BestLinesPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
{/* 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>
);
}