Files
vyndr/web/src/components/Slate.tsx
T
builtbykev ff0d3b199e P1-6 fix: one freshness source — Slate 'UPDATED' reads pipeline refreshed_at
Phone audit: the same screen showed green 'SIGNAL LIVE · UPDATED 0s ago' AND
amber 'SYNC 46:03' — two components reading different fields. The Slate's
'UPDATED Xs ago' measured the CLIENT poll time (always ~0s, since it refetches
every 60s), while the app-bar clock honestly measured the pipeline's data age
(refreshed_at). '0s ago' claimed a freshness the data didn't have.

Now the Slate captures snap.refreshed_at from the snapshot it already fetches
(newest across sports) and 'UPDATED' shows THAT — the same source of truth as
the clock. The clock still owns the amber/STALE reaction; the strip just states
the honest data age. No more contradiction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:24:48 -04:00

1252 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<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; 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<string, number> }
interface LiveGame { id: string; home?: string | null; away?: string | null; progress?: { label?: string; fraction?: number } | null; players?: Record<string, LivePlayerEntry> }
interface LiveResponse { sport?: string; hasLive?: boolean; games?: LiveGame[] }
type LiveIndex = ReturnType<typeof buildLiveIndex>;
// Sports with a free live box feed wired (specs/LIVE-TRACKING.md).
const LIVE_TRACK_SPORTS = new Set<SlateSport>(['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<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
type PitcherMap = ReturnType<typeof buildPitcherMap>;
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters<typeof buildPlayerStripsFromProps>[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<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`;
}
/** 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<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,
// 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<Array<{ id: string; player_name: string; stat: string; line: number; side: string; grade: string; outcome?: string | null; actual_value?: number | null; clv_result?: string | null }>>([]);
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 (
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>THE SETTLE · {date}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{rows.map((r) => (
<div key={r.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{r.player_name}</span>
<span style={{ color: 'var(--text-1)' }}>{r.stat.replace(/_/g, ' ')} {String(r.side).toUpperCase() === 'UNDER' ? 'u' : 'o'}{r.line} · {r.grade}</span>
<span style={{ fontWeight: 800, color: r.outcome === 'hit' ? 'var(--g-a)' : r.outcome === 'miss' ? 'var(--miss)' : 'var(--text-1)' }}>
{r.outcome === 'hit' ? '✓ HIT' : r.outcome === 'miss' ? '✕ MISS' : ' PUSH'}{r.actual_value != null ? ` (${r.actual_value})` : ''}
</span>
{r.clv_result && <span style={{ fontSize: 10.5, color: r.clv_result === 'beat' ? 'var(--g-a)' : r.clv_result === 'faded' ? 'var(--miss)' : 'var(--text-2)' }}>CLV {r.clv_result.toUpperCase()}</span>}
</div>
))}
</div>
</section>
);
}
// 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<ModelReceiptRow[] | null>(null);
const [tomorrow, setTomorrow] = useState<ScheduleGame[] | null>(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 (
<section className="surface" style={{ border: '1px solid var(--border, #1A1A24)', borderRadius: 8, padding: 20, marginBottom: 16 }}>
<div style={{ marginBottom: receipts.length || preview.length ? 18 : 0 }}>
<p className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.16em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>OUTLOOK</p>
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 4 }}>{title}</p>
<p style={{ color: 'var(--text-secondary, #8A8A9A)', fontSize: 13 }}>{body}</p>
</div>
{receipts.length > 0 && (
<div style={{ marginBottom: preview.length ? 18 : 0 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
{receipts.map((r, i) => (
<div
key={`${r.player}-${r.stat}-${i}`}
className="mono"
style={{ minWidth: 178, padding: 12, border: '1px solid var(--g-a)', borderRadius: 10, background: 'var(--bg-surface, #101018)', fontVariantNumeric: 'tabular-nums' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)' }}>{String(r.sport || '').toUpperCase()}</span>
<span style={{ fontSize: 13, fontWeight: 800, color: 'var(--g-a)' }}>{r.grade}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', marginBottom: 3 }}>{r.player}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-1)' }}>
{String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
</div>
<div style={{ fontSize: 11.5, fontWeight: 800, color: 'var(--g-a)', marginTop: 6 }}>
HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''}
</div>
</div>
))}
</div>
</div>
)}
{preview.length > 0 && (
<div>
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 10 }}>
TOMORROW · SCHEDULE <span style={{ color: 'var(--text-2)', fontWeight: 600 }}> lines post on the day</span>
</div>
<div style={{ display: 'grid', gap: 7 }}>
{preview.map((g) => (
<div key={g.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
{g.sport && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', minWidth: 30 }}>{g.sport}</span>}
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{g.away} @ {g.home}</span>
{g.time && <span style={{ color: 'var(--text-2)' }}>{formatGameTime(g.time)}</span>}
</div>
))}
</div>
</div>
)}
</section>
);
}
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<SlateTab>(['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<SlateTab>(() => 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<string>('all');
const [games, setGames] = useState<SlateGame[]>([]);
// Session 45 — merged pre-graded snapshot across the loaded sports.
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
// 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<number | null>(null);
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
const [viability, setViability] = useState<{ lineups?: { byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] }; injuries?: Record<string, { status: string; detail?: string | null }> } | 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<Record<string, LiveResponse>>({});
// 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<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, 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<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[];
// 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<OddsResponse>(u))) : Promise.resolve([] as (OddsResponse | null)[]),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}${dateParam}`) : Promise.resolve(null),
isToday && SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
isToday && SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
// Session 45 — pre-graded snapshot (locked grades + line deltas).
isToday ? getJson<SnapshotResponse>(`/api/snapshot/${sport}`) : Promise.resolve(null),
// Session 46 — MLB probable starting pitchers.
isToday && sport === 'mlb' ? getJson<PitcherResponse>(`/api/schedule/mlb/pitchers`) : Promise.resolve(null),
// Session 64 (A1-S5) — lineup confirmation (MLB) + injury wire.
isToday && sport === 'mlb' ? getJson<{ byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] }>(`/api/schedule/mlb/lineups`) : Promise.resolve(null),
isToday && SCHEDULE_SPORTS.has(sport) ? getJson<{ byPlayer: Record<string, { status: string; detail?: string | null }> }>(`/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<string, { status: string; slot?: number }>; postedTeams: string[] } | null = null;
const mergedInjuries: Record<string, { status: string; detail?: string | null }> = {};
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<string>();
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<string, LiveResponse> = {};
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<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]);
// 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<SlateGame[]>(() => {
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<string, SnapshotGrade>)[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<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
className="slate-sticky-head"
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). P0-4:
// on mobile the header collapses to the 60px app bar (ticker +
// heartbeat hidden), so .slate-sticky-head is overridden to top:60px
// in globals.css — otherwise it stuck 62px below the bar.
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(--g-a)', fontWeight: 700 }}>{games.filter((g) => g.status === 'in').length} LIVE</span></>
)}
{/* 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 && (
<><span style={{ color: '#3A3A48' }}>·</span><span title="Data age — the pipeline's last refresh (one source of truth with the app-bar clock)">UPDATED {freshLabel(snapRefreshedAt, 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,
}}
/>
{/* Session 64 (A1-S5) — date navigation. Yesterday = results surface
(finals + settled reads); Tomorrow = schedule until lines post. */}
<div className="mono" style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
{([[-1, 'YESTERDAY'], [0, 'TODAY'], [1, 'TOMORROW']] as [number, string][]).map(([off, label]) => (
<button
key={off}
onClick={() => setDateOffset(off)}
className="mono"
style={{
cursor: 'pointer', padding: '5px 12px', borderRadius: 6, fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em',
background: dateOffset === off ? 'var(--g-a, #00D4A0)' : 'transparent',
color: dateOffset === off ? '#06060B' : 'var(--text-1)',
border: `1px solid ${dateOffset === off ? 'var(--g-a, #00D4A0)' : 'var(--border-hi)'}`,
}}
>
{label}
</button>
))}
{dateOffset !== 0 && (
<span className="mono" style={{ alignSelf: 'center', marginLeft: 8, fontSize: 10.5, color: 'var(--text-2)' }}>
{etDateWithOffset(dateOffset)} · {dateOffset < 0 ? 'results + settled reads' : 'schedule — lines post on the day'}
</span>
)}
</div>
<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>
)}
{/* A search miss keeps its own scan-it CTA. */}
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
<div
className="surface"
style={{
padding: 28,
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
<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>
</div>
)}
{/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA:
when there are no live games (and no search, no fetch failure) it shows
yesterday's proven receipts + tomorrow's real schedule. Yesterday/
Tomorrow date nav keep the plain honest copy (those are explicit date
surfaces; -1 already has THE SETTLE below). */}
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && (
<OutlookSurface tab={tab} />
)}
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && (
<div
className="surface"
style={{
padding: 28,
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
{(() => {
const { title, body } = emptyStateCopy(tab);
return (
<>
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
<p>{body}</p>
</>
);
})()}
</div>
)}
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
{/* Wave 4A (Step 4) — the CONSENSUS vs MODEL strip. Self-hides unless a
graded prop has a real ≥2-book median to compare the model against. */}
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
{(() => {
// Build each game's card once, then present two ways (Rev 3, screen 01):
// MOBILE = Design's FLAT edge-ranked board (all props, one list, sorted
// by edge); DESKTOP = the game-grouped cards. Same data, different IA —
// toggled by width in globals.css (.edge-board-mobile / -desktop).
const cards = orderedGames.map((g) => slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex));
const edgeRows = flattenToEdgeBoard(cards);
const breadth = edgeBoardBreadth(edgeRows, cards);
const hasBoard = edgeRows.length > 0;
return (
<>
{hasBoard && (
<div className="edge-board-mobile" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
<MobileEdgeBoard rows={edgeRows} breadth={breadth} />
</div>
)}
{/* Cards: on mobile they hide ONLY when the flat board is present
(an ungraded slate still shows the game cards on phones). */}
<div className={hasBoard ? 'edge-board-desktop' : ''} style={{ display: 'grid', gap: 16 }}>
{cards.map((card, i) => (
<VyndrGameCard
key={`${card.sport}-${card.away.abbr}-${card.home.abbr}-${i}`}
game={card}
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>
);
}