Session 24: Connect everything — Slate wired to all sources, copy fixed, nav fixed, startup prefetch, language button removed (1571 tests)

This commit is contained in:
Kev
2026-06-12 15:45:19 -04:00
parent 0538205fab
commit 433e827103
15 changed files with 586 additions and 99 deletions
+73 -1
View File
@@ -4,7 +4,79 @@
2026-06-12 2026-06-12
## Current Phase ## Current Phase
SHIP BUILD v23.0 — All-Day Intelligence Layer: schedule, game lines, streaks, hot lists, stat filtering (Session 23) SHIP BUILD v24.0 — Connect Everything: wired the all-day intelligence layer into the live UI + killed stale copy (Session 24)
## Session 24 (2026-06-12) — SHIPPED
Connected Session 23's backend to what users actually see. The Ferrari
engine got wheels. Frontend-heavy: the Slate now fetches every free/cheap
layer, the site shows content even with odds-api at 0 credits, and every
piece of stale copy is gone.
Backend 1567 → **1571 tests** (+4), 125 suites, zero regressions.
Web build clean.
### PHASE 1 — Slate wired to ALL sources
- `fetchSlate` now fetches odds + schedule (ESPN) + gamelines (Tank01)
per sport in parallel. `mergeSlate()` makes the SCHEDULE the foundation
(always shows), overlays odds props (matched by nickname token) and
Tank01 lines (matched by team abbreviation). Unmatched odds games are
appended so props are never dropped. Schedule empty → odds-only fallback.
- `GameCard` extended with optional `status`/`score` (LIVE/FINAL badge +
score) and `gameLines` (book-by-book ML / spread / total strip).
- Odds-down-but-schedule-up → soft inline notice, NOT a wall-of-error.
### PHASE 2 — Stat filter pills
- Pills hidden on the ALL tab (filtering by "points" across mixed sports
is meaningless). Sport-specific categories on a single-sport tab.
- Switching sport resets `activeStat` to 'all' (stale filter would blank
the panels).
### PHASE 3 — Copy
- Hero badge "NBA · MLB · WNBA" → "EVERY SPORT · EVERY PROP"; subhead
de-listed the three leagues. Features "Three sports, one engine" →
"Every sport, one engine". FAQ updated. LivePropsStrip "TONIGHT'S
GRADES LOAD AT 5 PM ET" → "LIVE GRADES APPEAR HERE AS BOOKS POST LINES".
Removed the developer-facing "odds endpoint not configured yet" footer.
No BetonBLK references existed.
### PHASE 4 — Nav for paid users
- Paid (analyst/desk) users see "Account" where free/anon see "Pricing".
- `/account` page created → redirects to `/profile` (canonical plan +
subscription-management surface; no duplicate UI).
### PHASE 5 — Cache population
- `src/startupPrefetch.js` — non-blocking, crash-safe Tank01 cache warm
scheduled 5s after boot (`server.js`). Skips when RAPID_API_KEY unset;
prefetch failure never crashes the server. Bounded by prefetch's budget.
### PHASE 6 — Language switcher
- Removed `<LocaleSwitcher />` from the Nav (no translations behind it).
i18n infrastructure (LocaleContext, useT, react-i18next, the
LocaleSwitcher component file) kept for when translations land.
### PHASE 7 — Empty states
- Dashboard falls back to the free ESPN schedule when the odds slate is
empty, so it shows today's matchups instead of "NO SLATE". "NO SLATE"
now appears only when BOTH odds and schedule are genuinely empty.
- "Tonight's slate is loaded. 0 games across 3 sports." → honest,
sport-aware count (or "Your ledger starts here." when zero).
### Files created
- `src/startupPrefetch.js`
- `web/src/app/account/page.tsx`
- `tests/unit/startupPrefetch.test.js`
### Files modified
- `web/src/components/Slate.tsx` (parallel fetch + merge, notice, pills)
- `web/src/components/GameCard.tsx` (status/score/game-lines layers)
- `web/src/components/Nav.tsx` (paid→Account, locale switcher removed)
- `web/src/components/Hero.tsx`, `Features.tsx`, `FAQ.tsx`,
`LivePropsStrip.tsx` (copy)
- `web/src/app/dashboard/page.tsx` (schedule fallback + copy)
- `src/server.js` (startup prefetch hook)
---
## Session 23 (2026-06-12) — SHIPPED ## Session 23 (2026-06-12) — SHIPPED
+21
View File
@@ -675,3 +675,24 @@
{"ts":"2026-06-12T14:57:50.503Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-12T14:57:50.503Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T14:57:50.614Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} {"ts":"2026-06-12T14:57:50.614Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T14:57:50.736Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-12T14:57:50.736Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:12:28.843Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:12:28.945Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T19:12:29.072Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:12:29.692Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T19:12:29.692Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T19:12:29.692Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T19:12:29.733Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T19:38:07.379Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:38:07.479Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T19:38:07.627Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T19:38:07.628Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T19:38:07.628Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T19:38:07.689Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:38:07.697Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T19:39:18.273Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:39:18.533Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T19:39:18.626Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T19:39:18.768Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T19:39:18.769Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T19:39:18.769Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T19:39:18.874Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+7
View File
@@ -4,6 +4,9 @@ const app = require('./app');
// otherwise only manifests when the gateway tries to fall over and // otherwise only manifests when the gateway tries to fall over and
// finds no chain. // finds no chain.
const { getConfiguredProviders, listProviderIds } = require('./config/providers'); const { getConfiguredProviders, listProviderIds } = require('./config/providers');
// Session 24 — warm the Tank01 cache after boot so streaks / hot lists /
// game lines have data on the first page load. Non-blocking; see module.
const { scheduleStartupPrefetch } = require('./startupPrefetch');
// Default 3001 — Next.js owns 3000 locally and in production. The poller, // Default 3001 — Next.js owns 3000 locally and in production. The poller,
// internal cron, and BASE_URL conventions all assume 3001 for the Express // internal cron, and BASE_URL conventions all assume 3001 for the Express
@@ -18,4 +21,8 @@ app.listen(PORT, () => {
if (missing.length) { if (missing.length) {
console.warn(`[VYNDR] providers missing keys: ${missing.join(', ')}`); console.warn(`[VYNDR] providers missing keys: ${missing.join(', ')}`);
} }
// Session 24 — fire-and-forget cache warm. 5s delay so Redis is ready.
// Skips itself when RAPID_API_KEY is unset; never blocks or crashes boot.
scheduleStartupPrefetch();
}); });
+64
View File
@@ -0,0 +1,64 @@
/**
* Startup prefetch (Session 24).
*
* Warms the Tank01 game-log + game-lines cache shortly after the server
* boots, so the streaks / hot-list panels and the game-lines strip have
* data on the FIRST page load instead of self-hiding until a user
* happens to trigger a fetch.
*
* Hard rules:
* - NON-BLOCKING. Server readiness must never wait on this. The caller
* schedules it and moves on.
* - NEVER crashes the process. Every failure is swallowed + logged.
* - Skips entirely when RAPID_API_KEY is absent (nothing to warm, and
* no point spinning the prefetch's no-op passes).
*
* The prefetch module owns its own quota budget (`--max`), so a runaway
* can't blow the RapidAPI monthly cap.
*/
const prefetch = require('../scripts/tank01-prefetch');
const DEFAULTS = Object.freeze({
sports: ['nba', 'mlb'],
maxRequests: 40, // conservative — bounded by the prefetch's own budget
});
/**
* Run the prefetch once. Awaitable for tests; callers in server.js do NOT
* await it. Returns the prefetch summary, or null when skipped/failed.
*/
async function runStartupPrefetch(opts = {}) {
const sports = opts.sports || DEFAULTS.sports;
const maxRequests = opts.maxRequests || DEFAULTS.maxRequests;
if (!process.env.RAPID_API_KEY) {
console.log('[VYNDR] startup prefetch skipped — RAPID_API_KEY not set');
return null;
}
try {
const argv = ['node', 'startup-prefetch', `--sports=${sports.join(',')}`, `--max=${maxRequests}`];
const summary = await prefetch.main(argv);
console.log('[VYNDR] startup prefetch complete');
return summary;
} catch (err) {
// Swallow — a flaky RapidAPI must never take the API server down.
console.warn('[VYNDR] startup prefetch failed:', err.message);
return null;
}
}
/**
* Schedule the prefetch to run after `delayMs` (default 5s so Redis is
* ready). Returns the timer handle (unref'd so it never keeps the event
* loop alive on its own). Fully fire-and-forget.
*/
function scheduleStartupPrefetch(opts = {}) {
const delayMs = opts.delayMs != null ? opts.delayMs : 5000;
const timer = setTimeout(() => { void runStartupPrefetch(opts); }, delayMs);
if (typeof timer.unref === 'function') timer.unref();
return timer;
}
module.exports = { runStartupPrefetch, scheduleStartupPrefetch, DEFAULTS };
+48
View File
@@ -0,0 +1,48 @@
// Unit: startup prefetch (Session 24). Must be non-blocking and crash-safe.
jest.mock('../../scripts/tank01-prefetch', () => ({
main: jest.fn(),
}));
const prefetch = require('../../scripts/tank01-prefetch');
const { runStartupPrefetch, scheduleStartupPrefetch } = require('../../src/startupPrefetch');
const savedKey = process.env.RAPID_API_KEY;
afterAll(() => {
if (savedKey === undefined) delete process.env.RAPID_API_KEY;
else process.env.RAPID_API_KEY = savedKey;
});
beforeEach(() => jest.clearAllMocks());
describe('runStartupPrefetch', () => {
test('skips (returns null) when RAPID_API_KEY is unset', async () => {
delete process.env.RAPID_API_KEY;
const result = await runStartupPrefetch();
expect(result).toBeNull();
expect(prefetch.main).not.toHaveBeenCalled();
});
test('runs the prefetch with sports + max budget when key is set', async () => {
process.env.RAPID_API_KEY = 'test-key';
prefetch.main.mockResolvedValue({ requestsSpent: 12 });
const result = await runStartupPrefetch({ sports: ['nba', 'mlb'], maxRequests: 40 });
expect(result).toEqual({ requestsSpent: 12 });
const argv = prefetch.main.mock.calls[0][0];
expect(argv).toEqual(expect.arrayContaining(['--sports=nba,mlb', '--max=40']));
});
test('prefetch failure resolves to null — never throws (server stays up)', async () => {
process.env.RAPID_API_KEY = 'test-key';
prefetch.main.mockRejectedValue(new Error('rapidapi 503'));
await expect(runStartupPrefetch()).resolves.toBeNull();
});
});
describe('scheduleStartupPrefetch', () => {
test('returns an unref-able timer and does not run synchronously', () => {
process.env.RAPID_API_KEY = 'test-key';
const timer = scheduleStartupPrefetch({ delayMs: 10_000 });
expect(prefetch.main).not.toHaveBeenCalled(); // deferred, not immediate
clearTimeout(timer);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
/**
* /account (Session 24).
*
* Paid users see "Account" in the nav instead of "Pricing". Rather than
* duplicate the subscription UI, this route forwards to /profile, which
* already renders the current plan, usage, founder pricing, and the
* cancel/manage-subscription controls. Keeping one canonical surface
* avoids two screens drifting out of sync.
*/
export default function AccountPage() {
const router = useRouter();
useEffect(() => {
router.replace('/profile');
}, [router]);
return (
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p
className="mono"
style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}
>
Opening your account
</p>
</section>
);
}
+37 -4
View File
@@ -24,6 +24,16 @@ interface Game {
injury_note?: string; injury_note?: string;
} }
// Session 24 — shape returned by the free ESPN schedule endpoint
// (/api/schedule/:sport), used as a fallback when the odds slate is empty.
interface ScheduleApiGame {
id: string;
homeTeam?: { name?: string | null; abbreviation?: string | null };
awayTeam?: { name?: string | null; abbreviation?: string | null };
gameTime?: string | null;
status?: 'pre' | 'in' | 'post' | null;
}
interface TopGrade { interface TopGrade {
player: string; player: string;
stat: string; stat: string;
@@ -88,9 +98,30 @@ export default function DashboardPage() {
Promise.all([ Promise.all([
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })), fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
fetch(`/api/props/top-graded?sport=${sport}`).then((r) => r.json()).catch(() => ({ props: [] })), fetch(`/api/props/top-graded?sport=${sport}`).then((r) => r.json()).catch(() => ({ props: [] })),
]).then(([gamesData, gradesData]) => { ]).then(async ([gamesData, gradesData]) => {
if (cancelled) return; if (cancelled) return;
setGames(Array.isArray(gamesData?.games) ? gamesData.games : []); let list: Game[] = Array.isArray(gamesData?.games) ? gamesData.games : [];
// Session 24 — when the odds-backed slate is empty (off-day or
// odds-api quota exhausted), fall back to the FREE ESPN schedule so
// the dashboard still shows today's matchups instead of "NO SLATE".
if (list.length === 0) {
try {
const sched = await fetch(`/api/schedule/${sport.toLowerCase()}`).then((r) => r.json());
const schedGames = Array.isArray(sched?.games) ? sched.games : [];
list = schedGames.map((sg: ScheduleApiGame) => ({
id: sg.id,
away: sg.awayTeam?.name || sg.awayTeam?.abbreviation || 'Away',
home: sg.homeTeam?.name || sg.homeTeam?.abbreviation || 'Home',
start_time: sg.gameTime || '',
sport,
status: sg.status === 'in' ? 'live' : sg.status === 'post' ? 'final' : 'scheduled',
}));
} catch { /* schedule unavailable too — leave list empty */ }
}
if (cancelled) return;
setGames(list);
setTopGrades(Array.isArray(gradesData?.props) ? gradesData.props.slice(0, 10) : []); setTopGrades(Array.isArray(gradesData?.props) ? gradesData.props.slice(0, 10) : []);
}); });
@@ -255,7 +286,7 @@ export default function DashboardPage() {
</Section> </Section>
{/* Tonight's games */} {/* Tonight's games */}
<Section title={`Tonight's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} games tipping`}> <Section title={`Today's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} game${games?.length === 1 ? '' : 's'} today`}>
{games === null ? ( {games === null ? (
<SkeletonRow stacked /> <SkeletonRow stacked />
) : games.length === 0 ? ( ) : games.length === 0 ? (
@@ -378,7 +409,9 @@ export default function DashboardPage() {
WELCOME TO THE LEDGER WELCOME TO THE LEDGER
</p> </p>
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}> <h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
Tonight&apos;s slate is loaded. {games?.length ?? 0} {games?.length === 1 ? 'game' : 'games'} across 3 sports. {(games?.length ?? 0) > 0
? `Today's slate is loaded. ${games?.length} ${games?.length === 1 ? 'game' : 'games'} on the ${sport} board.`
: 'Your ledger starts here.'}
</h3> </h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 20 }}> <p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 20 }}>
Pick a game and read your first prop it&apos;s on us. Pick a game and read your first prop it&apos;s on us.
+1 -1
View File
@@ -13,7 +13,7 @@ const FAQS = [
}, },
{ {
q: 'What sports do you cover?', q: 'What sports do you cover?',
a: 'NBA, MLB, and WNBA at launch. NFL is targeted for September 2026. Each sport has its own calibrated weights and sport-specific factor models.', a: 'NBA, MLB, WNBA, and soccer today. NFL is targeted for September 2026, with more sports rolling out through 2026. Each sport has its own calibrated weights and sport-specific factor models.',
}, },
{ {
q: 'Can I cancel anytime?', q: 'Can I cancel anytime?',
+2 -2
View File
@@ -31,8 +31,8 @@ const FEATURES = [
}, },
{ {
icon: '◯', icon: '◯',
title: 'Three sports, one engine', title: 'Every sport, one engine',
body: 'NBA. MLB. WNBA. Unified intelligence layer with sport-specific calibration. NFL coming September 2026.', body: 'NBA. MLB. WNBA. Soccer. NFL coming September 2026 — more rolling out through 2026. A unified intelligence layer with sport-specific calibration.',
}, },
{ {
icon: '⌦', icon: '⌦',
+101 -12
View File
@@ -34,6 +34,24 @@ const SPORT_ACCENT: Record<SlateSport, string> = {
soccer: '#00D4A0', soccer: '#00D4A0',
}; };
// Session 24 — game-level book-by-book lines from Tank01. One row per
// sportsbook (bet365 / betmgm / caesars …). All fields optional/null —
// books publish lines independently, so we render only what exists.
export interface GameLineBook {
homeML?: string | null;
awayML?: string | null;
total?: string | null;
homeSpread?: string | null;
awaySpread?: string | null;
}
export interface GameLines {
homeTeam?: string | null;
awayTeam?: string | null;
books: Record<string, GameLineBook>;
}
export type GameStatus = 'pre' | 'in' | 'post';
export interface GameCardProps { export interface GameCardProps {
sport: SlateSport; sport: SlateSport;
homeTeam: string; homeTeam: string;
@@ -49,6 +67,19 @@ export interface GameCardProps {
onGrade: (prop: PropRowProp) => void; onGrade: (prop: PropRowProp) => void;
onUpgrade?: () => void; onUpgrade?: () => void;
defaultVisible?: number; // how many props to show before "+ N more" defaultVisible?: number; // how many props to show before "+ N more"
// Session 24 — all-day intelligence layers, all optional. A game card
// shows whatever exists: schedule status/score (ESPN), game lines
// (Tank01), and props (odds-api) — nothing replaces anything else.
status?: GameStatus;
score?: { home: number; away: number } | null;
gameLines?: GameLines | null;
}
// Map ESPN status → a compact, human badge. 'in' is live; 'post' is final.
function statusBadge(status?: GameStatus, score?: { home: number; away: number } | null) {
if (status === 'in') return { label: 'LIVE', color: '#FF4D4D', score };
if (status === 'post') return { label: 'FINAL', color: '#6B6B7B', score };
return null; // 'pre' or unknown → no badge; the tip-off time carries it
} }
function formatTime(iso?: string) { function formatTime(iso?: string) {
@@ -142,8 +173,11 @@ export default function GameCard(props: GameCardProps) {
props: propList, gradedProps, loadingKey, errorByKey, props: propList, gradedProps, loadingKey, errorByKey,
tier = 'free', onGrade, onUpgrade, tier = 'free', onGrade, onUpgrade,
defaultVisible = 4, defaultVisible = 4,
status, score, gameLines,
} = props; } = props;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const badge = statusBadge(status, score);
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : [];
// Session 19 — visibility budget now applies to PLAYERS, not raw // Session 19 — visibility budget now applies to PLAYERS, not raw
// props. Showing the first 4 prop rows that all belonged to the // props. Showing the first 4 prop rows that all belonged to the
@@ -244,21 +278,76 @@ export default function GameCard(props: GameCardProps) {
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '} {[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
</div> </div>
</div> </div>
<div <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
className="mono" {/* Session 24 — live/final status + score (ESPN schedule). */}
style={{ {badge && (
fontSize: 10, <span
color: 'var(--text-tertiary, #6B6B7B)', className="mono"
background: 'rgba(255,255,255,0.04)', style={{
padding: '4px 8px', fontSize: 10,
borderRadius: 999, fontWeight: 800,
whiteSpace: 'nowrap', letterSpacing: '0.08em',
}} color: '#0A0A0F',
> background: badge.color,
{propList.length} prop{propList.length === 1 ? '' : 's'} padding: '3px 8px',
borderRadius: 4,
whiteSpace: 'nowrap',
}}
>
{badge.label}{badge.score ? ` · ${badge.score.away}${badge.score.home}` : ''}
</span>
)}
<div
className="mono"
style={{
fontSize: 10,
color: 'var(--text-tertiary, #6B6B7B)',
background: 'rgba(255,255,255,0.04)',
padding: '4px 8px',
borderRadius: 999,
whiteSpace: 'nowrap',
}}
>
{propList.length} prop{propList.length === 1 ? '' : 's'}
</div>
</div> </div>
</header> </header>
{/* Session 24 — game lines strip (Tank01). Book-by-book moneyline,
spread, total. Renders only when lines exist; never blocks the
card. The brand edge is props, but lines give immediate action
even when props haven't been published. */}
{bookRows.length > 0 && (
<div
style={{
padding: '10px 16px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 6,
background: 'rgba(255,255,255,0.015)',
}}
>
<div
className="mono"
style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
>
Game Lines
</div>
{bookRows.map(([book, line]) => (
<div
key={book}
className="mono"
style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--text-secondary, #8A8A9A)', flexWrap: 'wrap' }}
>
<span style={{ minWidth: 64, color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
{line.awayML && <span>{teamAbbr(awayTeam, sport)} {line.awayML}</span>}
{line.homeML && <span>{teamAbbr(homeTeam, sport)} {line.homeML}</span>}
{line.total && <span>O/U {line.total}</span>}
</div>
))}
</div>
)}
{propList.length === 0 ? ( {propList.length === 0 ? (
<p <p
style={{ style={{
+2 -2
View File
@@ -36,7 +36,7 @@ export default function Hero() {
textTransform: 'uppercase', textTransform: 'uppercase',
}} }}
> >
NBA · MLB · WNBA EVERY SPORT · EVERY PROP
</span> </span>
<h1 <h1
className="text-balance" className="text-balance"
@@ -61,7 +61,7 @@ export default function Hero() {
maxWidth: 600, maxWidth: 600,
}} }}
> >
Grade your NBA, MLB, and WNBA props with intelligence the books don&apos;t want you to have. Grade your props across every sport with intelligence the books don&apos;t want you to have.
Forty-plus factors. Kill conditions. Alt-line ladders. The honest ledger. Forty-plus factors. Kill conditions. Alt-line ladders. The honest ledger.
</p> </p>
<SportBadgeStrip /> <SportBadgeStrip />
+1 -1
View File
@@ -80,7 +80,7 @@ export default function LivePropsStrip() {
letterSpacing: '0.08em', letterSpacing: '0.08em',
}} }}
> >
TONIGHT&apos;S GRADES LOAD AT 5 PM ET LIVE GRADES APPEAR HERE AS BOOKS POST LINES
</p> </p>
</section> </section>
); );
+11 -4
View File
@@ -5,7 +5,10 @@ import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import Wordmark from '@/components/Wordmark'; import Wordmark from '@/components/Wordmark';
import NotificationBell from '@/components/NotificationBell'; import NotificationBell from '@/components/NotificationBell';
import LocaleSwitcher from '@/components/LocaleSwitcher'; // Session 24 — LocaleSwitcher removed from the nav. The i18n
// infrastructure (react-i18next, LocaleContext, useT) stays in place,
// but a visible language toggle with no translations behind it is
// worse than none. Re-add the switcher when translations land.
import { useT } from '@/contexts/LocaleContext'; import { useT } from '@/contexts/LocaleContext';
export default function Nav() { export default function Nav() {
@@ -27,10 +30,16 @@ export default function Nav() {
// /dashboard IS the scan surface (click [Read] on any prop). The // /dashboard IS the scan surface (click [Read] on any prop). The
// /scan page still exists as a fallback for custom props and is // /scan page still exists as a fallback for custom props and is
// reachable from the slate's "Scan manually" empty-state CTA. // reachable from the slate's "Scan manually" empty-state CTA.
// Session 24 — paid users (analyst / desk) get "Account" where free
// users and signed-out visitors see "Pricing". A subscriber shouldn't
// be pitched a plan they already pay for.
const isPaid = !!user && tier !== 'free';
const NAV_LINKS = [ const NAV_LINKS = [
{ label: t('nav.tracker'), href: '/tracker' }, { label: t('nav.tracker'), href: '/tracker' },
{ label: t('nav.ledger'), href: '/ledger' }, { label: t('nav.ledger'), href: '/ledger' },
{ label: t('nav.pricing'), href: '/pricing' }, isPaid
? { label: 'Account', href: '/account' }
: { label: t('nav.pricing'), href: '/pricing' },
{ label: 'Blog', href: '/blog' }, { label: 'Blog', href: '/blog' },
]; ];
@@ -124,7 +133,6 @@ export default function Nav() {
</span> </span>
)} )}
<NotificationBell /> <NotificationBell />
<LocaleSwitcher />
<button <button
onClick={() => setMenuOpen((o) => !o)} onClick={() => setMenuOpen((o) => !o)}
aria-haspopup="menu" aria-haspopup="menu"
@@ -198,7 +206,6 @@ export default function Nav() {
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<LocaleSwitcher />
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}> <a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
{t('nav.login')} {t('nav.login')}
</a> </a>
+186 -71
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import GameCard, { SlateSport } from '@/components/GameCard'; import GameCard, { SlateSport, GameLines } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow'; import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the // Session 23 — all-day intelligence layer. The stat filter is the
@@ -134,6 +134,98 @@ interface SlateGame {
venue?: string; venue?: string;
context?: string; context?: string;
props: PropRowProp[]; 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 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.
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).
*/
function mergeSlate(
sport: SlateSport,
scheduleGames: ScheduleGame[],
oddsGames: SlateGame[],
lines?: Record<string, GameLines>,
): SlateGame[] {
const base: SlateGame[] = scheduleGames.map((sg) => ({
sport,
homeTeam: sg.homeTeam?.name || '',
awayTeam: sg.awayTeam?.name || '',
gameTime: sg.gameTime || undefined,
venue: sg.venue || undefined,
status: sg.status || undefined,
score: sg.score || undefined,
props: [],
gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
}));
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[] { function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
@@ -194,7 +286,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const [games, setGames] = useState<SlateGame[]>([]); const [games, setGames] = useState<SlateGame[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const [unsupportedSports, setUnsupportedSports] = useState<SlateSport[]>([]); // 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);
// Grade state — Map keyed by propRowKey. // Grade state — Map keyed by propRowKey.
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map()); const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
@@ -204,19 +298,24 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// Search filter (Phase 3.4 — kept here so the Slate owns its own filtering). // Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
// Fetch + group. Promise.allSettled so one sport failing doesn't blank the slate. // 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.
const fetchSlate = useCallback(async (active: SlateTab) => { const fetchSlate = useCallback(async (active: SlateTab) => {
setLoading(true); setLoading(true);
setFetchError(null); setFetchError(null);
setOddsNotice(false);
const sportsToFetch: Array<{ sport: SlateSport; urls: string[] }> = []; // Sports that carry a schedule/streaks feed (ESPN-backed). Soccer
const unsupported: SlateSport[] = []; // 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'>) => { const consider = (s: Exclude<SlateTab, 'all'>) => {
const urls = FETCH_URLS[s]; if (FETCH_URLS[s] !== null) sportsToFetch.push(s as SlateSport);
if (urls === null) unsupported.push(s as SlateSport);
else sportsToFetch.push({ sport: s as SlateSport, urls });
}; };
if (active === 'all') { if (active === 'all') {
consider('nba'); consider('wnba'); consider('mlb'); consider('soccer'); consider('nba'); consider('wnba'); consider('mlb'); consider('soccer');
} else { } else {
@@ -225,64 +324,66 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
if (sportsToFetch.length === 0) { if (sportsToFetch.length === 0) {
setGames([]); setGames([]);
setUnsupportedSports(unsupported);
setLoading(false); setLoading(false);
return; return;
} }
const results = await Promise.allSettled( const getJson = async <T,>(url: string): Promise<T | null> => {
sportsToFetch.flatMap(({ sport, urls }) => try {
urls.map((url) => const r = await fetch(url, { cache: 'no-store' });
fetch(url, { cache: 'no-store' }) if (!r.ok) return null;
.then(async (r) => { return (await r.json()) as T;
const body = (await r.json().catch(() => ({}))) as OddsResponse; } catch {
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`); return null;
return { sport, body }; }
}) };
.catch((err) => {
// Re-throw so allSettled catches it, but attach the // Per sport: odds + schedule + gamelines, all settled independently.
// sport so the per-sport error-tracking below can const perSport = await Promise.all(
// surface "Soccer odds unavailable" without blanking sportsToFetch.map(async (sport) => {
// the rest of the slate. const oddsUrls = FETCH_URLS[sport] as string[];
const e = err instanceof Error ? err : new Error(String(err)); const [oddsResults, schedule, lines] = await Promise.all([
(e as Error & { _vyndrSport?: SlateSport })._vyndrSport = sport; Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
throw e; SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
}) SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : 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);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 };
}),
); );
const allGames: SlateGame[] = []; const allGames: SlateGame[] = [];
const failedSports: SlateSport[] = []; let anyOddsOk = false;
const sportsAttempted = new Set<SlateSport>(sportsToFetch.map((s) => s.sport)); let anyScheduleShown = false;
const sportsThatSucceeded = new Set<SlateSport>(); for (const s of perSport) {
for (const r of results) { allGames.push(...s.merged);
if (r.status === 'fulfilled') { if (s.oddsOk) anyOddsOk = true;
sportsThatSucceeded.add(r.value.sport); if (s.hadSchedule) anyScheduleShown = true;
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
allGames.push(...grouped);
} else {
const failed = (r.reason as Error & { _vyndrSport?: SlateSport })._vyndrSport;
if (failed && !failedSports.includes(failed)) failedSports.push(failed);
}
} }
setGames(allGames); setGames(allGames);
setUnsupportedSports([...unsupported, ...failedSports.filter((s) => !sportsThatSucceeded.has(s))]);
// Session 17 — only surface a top-level error when EVERY sport // Odds down but schedule carried the slate → soft notice, not a wall.
// attempted in this tab failed. Partial successes (NBA ok, if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
// soccer 503) silently drop the failed sport's row and surface // Genuine total failure (no odds, no schedule, anywhere) → error.
// it via the existing "endpoint not configured" footer note. if (!anyOddsOk && !anyScheduleShown && allGames.length === 0) {
if (sportsAttempted.size > 0 && sportsThatSucceeded.size === 0) { setFetchError('No games available right now. Check back soon.');
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
setFetchError(firstError ? (firstError.reason as Error).message : 'Odds fetch failed');
} }
setLoading(false); setLoading(false);
}, []); }, []);
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]); useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
// 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]);
// Grading call site. Single source of truth so we never have two // Grading call site. Single source of truth so we never have two
// PropRows in-flight from the same prop (the loadingKey enforces it). // PropRows in-flight from the same prop (the loadingKey enforces it).
const onGrade = useCallback(async (prop: PropRowProp) => { const onGrade = useCallback(async (prop: PropRowProp) => {
@@ -426,13 +527,17 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
); );
})} })}
</div> </div>
{/* Session 23 — stat filter pills, below the sport tabs and above {/* Session 23/24 — stat filter pills, below the sport tabs and
all content. Narrows the streaks + hot list panels. */} above all content. Sport-specific categories. Hidden on the
<StatFilterPills ALL tab: filtering by "points" makes no sense when the slate
sport={tab === 'all' ? 'nba' : tab} mixes NBA + MLB + soccer. Pills appear only on a single sport. */}
activeStat={activeStat} {tab !== 'all' && (
onChange={setActiveStat} <StatFilterPills
/> sport={tab}
activeStat={activeStat}
onChange={setActiveStat}
/>
)}
</div> </div>
{/* Body */} {/* Body */}
@@ -467,6 +572,24 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
</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 && ( {fetchError && !loading && (
<div <div
role="alert" role="alert"
@@ -532,6 +655,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
venue={g.venue} venue={g.venue}
context={g.context} context={g.context}
props={g.props} props={g.props}
status={g.status}
score={g.score}
gameLines={g.gameLines}
gradedProps={gradedProps} gradedProps={gradedProps}
loadingKey={gradingKey} loadingKey={gradingKey}
errorByKey={errorByKey} errorByKey={errorByKey}
@@ -548,20 +674,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} /> <StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} /> <HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
{unsupportedSports.length > 0 && !loading && ( {/* Session 24 — removed the developer-facing "odds endpoint not
<p configured yet" footer note. A sport with no data simply doesn't
className="mono" render a row; users never see internal wiring state. */}
style={{
fontSize: 11,
color: 'var(--text-tertiary, #6B6B7B)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{unsupportedSports.map((s) => s.toUpperCase()).join(', ')} odds endpoint not configured yet.
</p>
)}
</div> </div>
); );
} }