bc8633466c
Step 3 — OUTLOOK MODE. The game grid no longer dead-ends in a "NO SLATE" CTA. When there are no live games (and it's not a network failure) it shows REAL, always-available data: yesterday's PROVEN A-tier receipts (/api/ledger/model) + tomorrow's date-pinned ESPN schedule preview (free/cached). A network fetchError stays a distinct ERROR state — never a fabricated outlook. - lib/outlook.js (new, CommonJS, unit-tested): buildOutlook selection + mapTomorrowPreview (upcoming-only, drops incomplete matchups, never invents). - Slate.tsx: OutlookSurface replaces the empty-grid CTA (dateOffset 0 only). - dashboard/page.tsx: DashboardOutlook replaces the "Today's games" NO-SLATE CTA. Step 4 — MARKET-BREADTH / CONSENSUS vs MODEL. Makes the DeskShowcase "consensus vs model" claim REAL. Consensus = median book line across a prop's per-book rows; the model's position is model_value vs consensus, signed by the graded side. <2 distinct books → null (never fabricate a consensus); a non-numeric line is ignored, never coerced to 0. - lib/marketBreadth.js (new, CommonJS, unit-tested): median/computeBreadth/ collectBreadth (strict null guards). - components/vyndr/MarketBreadth.tsx (new): mono/tabular strip, colored by sign via colorContract.edgeColor, self-hides when nothing has >=2 books. - Slate.tsx renders it above the grid (joins books + snapshot model_value). - slateAdapter.js exports gradeKey for the join. - DeskShowcase.tsx: the consensus claim is now backed by the shipped feature. Tests: tests/unit/outlook.test.js + tests/unit/marketBreadth.test.js (23 cases). Full suite 2984 passing (245 suites); next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1212 lines
55 KiB
TypeScript
1212 lines
55 KiB
TypeScript
'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 } from '@/lib/slateAdapter';
|
||
// 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[] }
|
||
|
||
// 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[]>([]);
|
||
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 || [], 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);
|
||
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
|
||
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,
|
||
}}
|
||
/>
|
||
{/* 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'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 “{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>
|
||
</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} />}
|
||
|
||
<div style={{ display: 'grid', gap: 16 }}>
|
||
{orderedGames.map((g, i) => (
|
||
<VyndrGameCard
|
||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex)}
|
||
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>
|
||
);
|
||
}
|