c8790fde55
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>
854 lines
35 KiB
TypeScript
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'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 “{searchQuery}”.
|
|
</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>
|
|
);
|
|
}
|