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:
+73
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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's on us.
|
Pick a game and read your first prop — it's on us.
|
||||||
|
|||||||
@@ -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?',
|
||||||
|
|||||||
@@ -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
@@ -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={{
|
||||||
|
|||||||
@@ -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't want you to have.
|
Grade your props across every sport with intelligence the books don'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 />
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function LivePropsStrip() {
|
|||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
TONIGHT'S GRADES LOAD AT 5 PM ET
|
LIVE GRADES APPEAR HERE AS BOOKS POST LINES
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user