Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)

This commit is contained in:
Kev
2026-06-12 17:58:55 -04:00
parent 433e827103
commit 956cdb863a
15 changed files with 602 additions and 39 deletions
+71
View File
@@ -4,6 +4,77 @@
2026-06-12 2026-06-12
## Current Phase ## Current Phase
SHIP BUILD v25.0 — Fix every data-rendering bug: the frontend now actually SHOWS the backend's data (Session 25)
## Session 25 (2026-06-12) — SHIPPED
Traced data from API response → normalizer → cache → frontend fetch →
render and fixed every break. The backend was serving real data; the
frontend showed "NO SLATE." Root causes found and fixed.
Backend 1571 → **1579 tests** (+8), 125 suites, zero regressions.
Web build clean.
### PHASE 1 — Tank01 game-lines normalizer (traced + fixed)
- TRACE: the real Tank01 betting-odds shape puts each sportsbook as a
TOP-LEVEL key on the game object (`{ awayTeam, homeTeam, bet365:{...},
betmgm:{...} }`), NOT inside a `sportsBooks` array. The old normalizer
looked for the array → `books: {}` every time.
- FIX: `extractBooks()` filters out NON_BOOK_KEYS and treats remaining
object values as books (counted only if they yield a real odds field).
`normalizeBook` now reads `homeTeamML`/`totalOver`/`homeTeamRunLine`
(MLB) alongside the older spellings. Legacy array shape still handled.
### PHASE 2 — Slate schedule rendering (THE root cause)
- TRACE: the all-day endpoints (`/api/schedule`, `/api/gamelines`,
`/api/streaks`, `/api/hotlist`) existed on Express but had NO Next.js
proxy route — so the browser's `fetch('/api/schedule/mlb')` 404'd on the
Next origin and the slate was always empty.
- FIX: created 4 Next.js proxy route handlers (mirroring `/api/odds/*`).
- Sport tabs now show merged counts ("MLB (8)") from schedule+odds.
- Games already rendered with 0 props (Session 24 merge); now they get data.
### PHASE 3 — Dashboard
- The Session 24 schedule fallback was 404ing for the same proxy reason;
the Phase 2 proxy unblocks it. Dashboard now shows ESPN schedule games.
### PHASE 4 — Hero prop
- The static Jokic fallback card is now labelled "EXAMPLE" so its fixed
stats don't read as stale live data when no live hero-prop is flowing.
### PHASE 5 — Per-game inline streaks
- `GameCard` renders a 🔥 STREAKS section inline (below props/lines),
matched to the game by team abbreviation in the Slate. Renders only when
streaks exist for that game's teams. Sport-wide panels kept as the board.
### PHASE 6 — Game-log cache key alignment (traced + bridged)
- TRACE: prefetch writes `tank01:{sport}:boxscore:{gameId}`; rosterLogs
read `gamelogs:{sport}:*` / `rosterlogs:{sport}`. NBA/WNBA are fed by
gameLogService (Python) during grading — ALIGNED. MLB had NO writer for
the keys rosterLogs read — MISALIGNED, so MLB streaks were always empty.
- FIX: `rosterLogs` now falls back to aggregating the cached Tank01 box
scores (`tank01:{sport}:boxscore:*`) into per-player multi-game logs,
flattening MLB `_raw` and ordering games most-recent-first by the date
in the gameID. Honest limitation: streaks need 2+ cached games to
surface, so coverage grows as box scores accumulate across prefetch runs.
### Files created
- `web/src/app/api/schedule/[sport]/route.ts`
- `web/src/app/api/gamelines/[sport]/route.ts`
- `web/src/app/api/streaks/[sport]/route.ts`
- `web/src/app/api/hotlist/[sport]/route.ts`
### Files modified
- `src/routes/gameLines.js` (normalizer rewrite + extractBooks)
- `src/services/rosterLogs.js` (box-score aggregation bridge)
- `web/src/components/Slate.tsx` (streaks fetch+match, tab counts)
- `web/src/components/GameCard.tsx` (inline streaks section)
- `web/src/components/LiveHeroProp.tsx` (EXAMPLE label)
- `tests/integration/gameLinesRoute.test.js`, `tests/unit/rosterLogs.test.js`
---
## Previous Phase
SHIP BUILD v24.0 — Connect Everything: wired the all-day intelligence layer into the live UI + killed stale copy (Session 24) 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 ## Session 24 (2026-06-12) — SHIPPED
+13
View File
@@ -73,6 +73,19 @@ empty. NONE of these spend odds-api credits:
- Dead providers: set `status: 'dead'` in `config/providers.js` to drop a - Dead providers: set `status: 'dead'` in `config/providers.js` to drop a
provider from fallback chains + configured list (ParlayAPI host is dead). provider from fallback chains + configured list (ParlayAPI host is dead).
## Frontend ↔ Backend Wiring (Session 25 — non-obvious)
A new Express route under `/api/*` is NOT reachable from the browser until
a matching **Next.js proxy route** exists at `web/src/app/api/.../route.ts`
that forwards to `${BACKEND_URL}/api/...`. The browser hits the Next origin,
not Express directly. This bit us: schedule/gamelines/streaks/hotlist
endpoints worked on Express but 404'd in the UI for two sessions. When
adding a backend endpoint the frontend calls, ALWAYS add the proxy too
(pattern: `web/src/app/api/odds/nba/route.ts`).
Tank01 betting-odds real shape: sportsbooks are TOP-LEVEL keys on each
game object (`{ awayTeam, homeTeam, bet365:{...} }`), not a `sportsBooks`
array. Filter `NON_BOOK_KEYS` to extract books (see `gameLines.js`).
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+21
View File
@@ -696,3 +696,24 @@
{"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":"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.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"} {"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"}
{"ts":"2026-06-12T21:15:48.892Z","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-12T21:15:48.896Z","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-12T21:15:48.896Z","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-12T21:15:48.958Z","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-12T21:15:49.248Z","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-12T21:15:49.337Z","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-12T21:15:50.038Z","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-12T21:41:40.018Z","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-12T21:41:40.065Z","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-12T21:41:40.200Z","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-12T21:41:40.582Z","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-12T21:41:40.585Z","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-12T21:41:40.585Z","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-12T21:41:40.654Z","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-12T21:43:24.893Z","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-12T21:43:24.930Z","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-12T21:43:24.930Z","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-12T21:43:24.930Z","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-12T21:43:25.003Z","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-12T21:43:25.020Z","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-12T21:43:25.037Z","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"}
+62 -20
View File
@@ -57,12 +57,28 @@ function teamsFromGameId(gameId) {
return { awayTeam: m[1], homeTeam: m[2] }; return { awayTeam: m[1], homeTeam: m[2] };
} }
// Session 25 — Tank01's real shape (traced 2026-06-12) puts each
// sportsbook as a TOP-LEVEL key inside the game object, NOT inside a
// `sportsBooks` array:
// { "20260612_ARI@CIN": {
// awayTeam: "ARI", homeTeam: "CIN", gameID: "...",
// bet365: { homeTeamML: "-110", totalOver: "9.5", ... },
// betmgm: { ... }, caesars: { ... } } }
// These non-book keys must be excluded so they don't get treated as books.
const NON_BOOK_KEYS = new Set([
'awayTeam', 'homeTeam', 'gameID', 'gameId', 'gameDate', 'gameTime',
'gameStatus', 'gameStatusCode', 'teamIDAway', 'teamIDHome',
'season', 'seasonType', 'last_updated_e_time', 'espnID', 'espnLink',
'cbsLink', 'sportsBooks', 'books',
]);
/** /**
* Normalize one sportsbook's raw odds object into a flat, UI-ready row. * Normalize one sportsbook's raw odds object into a flat, UI-ready row.
* Tank01 field names are verbose and occasionally vary; pull defensively. * Tank01 field names are verbose and vary across feeds — pull defensively,
* tolerating both the MLB run-line and NBA spread spellings.
*/ */
function normalizeBook(odds) { function normalizeBook(odds) {
if (!odds || typeof odds !== 'object') return null; if (!odds || typeof odds !== 'object' || Array.isArray(odds)) return null;
const pick = (...keys) => { const pick = (...keys) => {
for (const k of keys) { for (const k of keys) {
if (odds[k] !== undefined && odds[k] !== null && odds[k] !== '') return odds[k]; if (odds[k] !== undefined && odds[k] !== null && odds[k] !== '') return odds[k];
@@ -70,18 +86,53 @@ function normalizeBook(odds) {
return null; return null;
}; };
return { return {
homeML: pick('homeTeamMLOdds', 'homeML', 'moneyLineHome'), homeML: pick('homeTeamML', 'homeTeamMLOdds', 'homeML', 'moneyLineHome'),
awayML: pick('awayTeamMLOdds', 'awayML', 'moneyLineAway'), awayML: pick('awayTeamML', 'awayTeamMLOdds', 'awayML', 'moneyLineAway'),
total: pick('totalOver', 'total', 'overUnder'), total: pick('totalOver', 'totalUnder', 'total', 'overUnder'),
overOdds: pick('totalOverOdds', 'overOdds'), overOdds: pick('totalOverOdds', 'overOdds'),
underOdds: pick('totalUnderOdds', 'underOdds'), underOdds: pick('totalUnderOdds', 'underOdds'),
homeSpread: pick('homeTeamSpread', 'homeSpread'), homeSpread: pick('homeTeamRunLine', 'homeTeamSpread', 'homeSpread'),
awaySpread: pick('awayTeamSpread', 'awaySpread'), awaySpread: pick('awayTeamRunLine', 'awayTeamSpread', 'awaySpread'),
homeSpreadOdds: pick('homeTeamSpreadOdds'), homeSpreadOdds: pick('homeTeamSpreadOdds', 'homeTeamRunLineOdds'),
awaySpreadOdds: pick('awayTeamSpreadOdds'), awaySpreadOdds: pick('awayTeamSpreadOdds', 'awayTeamRunLineOdds'),
}; };
} }
/**
* Extract the books map from a single game object. Handles BOTH shapes:
* 1. (current) sportsbooks as top-level keys on the game object
* 2. (legacy) a `sportsBooks` array of { sportsBook, odds } entries
* A book is only counted if it yields at least one real odds field, so a
* stray non-book object key can't pollute the result.
*/
function extractBooks(game) {
const books = {};
// Shape 1 — top-level book keys.
for (const [key, val] of Object.entries(game)) {
if (NON_BOOK_KEYS.has(key)) continue;
if (!val || typeof val !== 'object' || Array.isArray(val)) continue;
const row = normalizeBook(val);
if (row && Object.values(row).some((v) => v !== null)) {
books[String(key).toLowerCase()] = row;
}
}
// Shape 2 — legacy sportsBooks array (kept for backward compatibility).
const sbList = game.sportsBooks || game.books;
if (Array.isArray(sbList)) {
for (const sb of sbList) {
const name = sb?.sportsBook || sb?.book || sb?.name;
const row = normalizeBook(sb?.odds || sb);
if (name && row && Object.values(row).some((v) => v !== null)) {
books[String(name).toLowerCase()] = row;
}
}
}
return books;
}
/** /**
* Normalize the Tank01 betting-odds body (a map keyed by gameID) into the * Normalize the Tank01 betting-odds body (a map keyed by gameID) into the
* route's `games` shape. Defensive against both the documented map form * route's `games` shape. Defensive against both the documented map form
@@ -98,19 +149,10 @@ function normalizeGameLines(body) {
for (const [gameId, game] of entries) { for (const [gameId, game] of entries) {
if (!gameId || !game || typeof game !== 'object') continue; if (!gameId || !game || typeof game !== 'object') continue;
const { awayTeam, homeTeam } = teamsFromGameId(gameId); const { awayTeam, homeTeam } = teamsFromGameId(gameId);
const books = {};
const sbList = game.sportsBooks || game.books || [];
if (Array.isArray(sbList)) {
for (const sb of sbList) {
const name = sb?.sportsBook || sb?.book || sb?.name;
const row = normalizeBook(sb?.odds || sb);
if (name && row) books[String(name).toLowerCase()] = row;
}
}
games[gameId] = { games[gameId] = {
homeTeam: game.homeTeam || homeTeam, homeTeam: game.homeTeam || homeTeam,
awayTeam: game.awayTeam || awayTeam, awayTeam: game.awayTeam || awayTeam,
books, books: extractBooks(game),
}; };
} }
return games; return games;
@@ -141,4 +183,4 @@ router.get('/:sport', async (req, res) => {
}); });
module.exports = router; module.exports = router;
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines }; module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines, extractBooks };
+83 -9
View File
@@ -6,11 +6,20 @@
* as the grading flow touches them. There's no roster-wide pull, and we * as the grading flow touches them. There's no roster-wide pull, and we
* will NOT add API calls to build one (free/cheap-only session). * will NOT add API calls to build one (free/cheap-only session).
* *
* So we read what's ALREADY cached: * So we read what's ALREADY cached, in priority order:
* 1. A precomputed roster blob `rosterlogs:{sport}` if a prefetch wrote * 1. A precomputed roster blob `rosterlogs:{sport}` if a prefetch wrote
* one (fast path — a single read). * one (fast path — a single read).
* 2. Otherwise SCAN the per-player `gamelogs:{sport}:*` keys and assemble * 2. The per-player `gamelogs:{sport}:*` keys (NBA/WNBA: written by
* a roster from whatever's warm. * gameLogService during grading, with real multi-game logs).
* 3. (Session 25) The Tank01 box-score cache `tank01:{sport}:boxscore:*`
* that the prefetch writes. Each key is ONE game; we aggregate by
* player across games into the same roster shape. This closes the
* key-alignment gap Session 25 traced: the prefetch wrote box scores
* under a key rosterLogs never read, so MLB streaks were always empty.
*
* Streaks need 2+ games to surface (MIN_STREAK), so a single cached game
* day yields no streak — correct, not a bug. Coverage grows as box scores
* accumulate across prefetch runs.
* *
* Everything here is Redis-only (free) and defensive — any failure yields * Everything here is Redis-only (free) and defensive — any failure yields
* an empty roster, never a throw. An empty roster is a valid state: the * an empty roster, never a throw. An empty roster is a valid state: the
@@ -37,11 +46,10 @@ function playerFromKey(key, sport) {
return rest.slice(0, lastColon); return rest.slice(0, lastColon);
} }
async function scanGameLogKeys(sport) { async function scanKeys(match) {
if (isDegraded && isDegraded()) return []; if (isDegraded && isDegraded()) return [];
const redis = getRedisClient(); const redis = getRedisClient();
if (!redis || typeof redis.scan !== 'function') return []; if (!redis || typeof redis.scan !== 'function') return [];
const match = `gamelogs:${sport}:*`;
const keys = []; const keys = [];
let cursor = '0'; let cursor = '0';
try { try {
@@ -60,6 +68,63 @@ async function scanGameLogKeys(sport) {
return keys; return keys;
} }
function scanGameLogKeys(sport) {
return scanKeys(`gamelogs:${sport}:*`);
}
// Session 25 — box-score-cache aggregation (the prefetch-alignment bridge).
// Extract the YYYYMMDD date from a `tank01:{sport}:boxscore:{gameId}` key,
// where gameId is `YYYYMMDD_AWAY@HOME`. Used to order games most-recent-first
// (streaksService counts the streak from games[0] backward).
function boxScoreKeyDate(key) {
const m = String(key || '').match(/boxscore:(\d{8})/);
return m ? m[1] : '0';
}
// Project one cached box-score row into a stat row the streaks/hot-list
// engines can read. NBA rows already carry pts/reb/ast/etc. at the top
// level; MLB rows keep stats under `_raw`, so we flatten that up.
function projectBoxRow(sport, row) {
if (!row || typeof row !== 'object') return null;
if (sport === 'mlb') {
const raw = row._raw && typeof row._raw === 'object' ? row._raw : {};
return { ...raw, team: row.team, playerId: row.playerId, _final: row._final };
}
// nba / wnba — already flat.
return row;
}
/**
* Aggregate cached Tank01 box scores into [{ name, playerId, team, games }].
* One box-score key = one game; a player appearing across N cached games
* accumulates an N-length log. Games are ordered most-recent-first by the
* date embedded in the key.
*/
async function aggregateBoxScores(sport) {
const keys = await scanKeys(`tank01:${sport}:boxscore:*`);
if (keys.length === 0) return [];
// Most-recent game first.
keys.sort((a, b) => boxScoreKeyDate(b).localeCompare(boxScoreKeyDate(a)));
const byPlayer = new Map();
for (const k of keys) {
const box = await cacheGet(k);
if (!Array.isArray(box)) continue;
for (const row of box) {
const name = row?.name;
if (!name) continue;
const stat = projectBoxRow(sport, row);
if (!stat) continue;
if (!byPlayer.has(name)) {
byPlayer.set(name, { name, playerId: row.playerId ?? null, team: row.team ?? null, games: [] });
}
byPlayer.get(name).games.push(stat);
}
}
return Array.from(byPlayer.values());
}
/** /**
* Returns [{ name, playerId, team, games }] for a sport. Dedupes players * Returns [{ name, playerId, team, games }] for a sport. Dedupes players
* (the highest game-count key wins) so one player isn't double-counted * (the highest game-count key wins) so one player isn't double-counted
@@ -73,9 +138,8 @@ async function loadRosterLogs(sport) {
const blob = await cacheGet(`rosterlogs:${key}`); const blob = await cacheGet(`rosterlogs:${key}`);
if (Array.isArray(blob) && blob.length > 0) return blob; if (Array.isArray(blob) && blob.length > 0) return blob;
// Per-player game-log keys (NBA/WNBA grading flow writes these).
const keys = await scanGameLogKeys(key); const keys = await scanGameLogKeys(key);
if (keys.length === 0) return [];
const byPlayer = new Map(); const byPlayer = new Map();
for (const k of keys) { for (const k of keys) {
const name = playerFromKey(k, key); const name = playerFromKey(k, key);
@@ -89,7 +153,17 @@ async function loadRosterLogs(sport) {
byPlayer.set(name, { name, playerId, team, games }); byPlayer.set(name, { name, playerId, team, games });
} }
} }
return Array.from(byPlayer.values()); if (byPlayer.size > 0) return Array.from(byPlayer.values());
// Session 25 — fall back to the Tank01 box-score cache the prefetch
// writes. Closes the key-alignment gap that left MLB streaks empty.
return aggregateBoxScores(key);
} }
module.exports = { loadRosterLogs, __internals: { playerFromKey, scanGameLogKeys } }; module.exports = {
loadRosterLogs,
__internals: {
playerFromKey, scanGameLogKeys, scanKeys,
boxScoreKeyDate, projectBoxRow, aggregateBoxScores,
},
};
+47 -4
View File
@@ -30,12 +30,26 @@ function mountApp() {
return app; return app;
} }
// Session 25 — the REAL Tank01 shape (traced): sportsbooks are top-level
// keys on the game object, alongside non-book keys (awayTeam, gameID…).
const MLB_BODY = { const MLB_BODY = {
'20260612_ARI@CIN': {
gameID: '20260612_ARI@CIN',
awayTeam: 'ARI',
homeTeam: 'CIN',
last_updated_e_time: '1718200000',
bet365: { homeTeamML: '-110', awayTeamML: '+100', totalOver: '9.5', totalOverOdds: '-105', totalUnderOdds: '-115', homeTeamRunLine: '-1.5' },
betmgm: { homeTeamML: '-115', awayTeamML: '-105', totalOver: '9' },
caesars: { homeTeamML: '-112', awayTeamML: '-102', totalUnder: '9.5' },
},
};
// Legacy array shape — still supported for backward compatibility.
const MLB_BODY_LEGACY = {
'20260612_ARI@CIN': { '20260612_ARI@CIN': {
gameID: '20260612_ARI@CIN', gameID: '20260612_ARI@CIN',
sportsBooks: [ sportsBooks: [
{ sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5', totalOverOdds: '-105', totalUnderOdds: '-115' } }, { sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5' } },
{ sportsBook: 'betmgm', odds: { homeTeamMLOdds: '-115', awayTeamMLOdds: '-105', totalOver: '9' } },
], ],
}, },
}; };
@@ -49,7 +63,7 @@ beforeEach(() => {
}); });
describe('GET /api/gamelines/:sport', () => { describe('GET /api/gamelines/:sport', () => {
test('mlb returns book-by-book odds with teams parsed from gameID', async () => { test('mlb returns book-by-book odds from top-level book keys (real shape)', async () => {
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY); mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
const res = await request(mountApp()).get('/api/gamelines/mlb'); const res = await request(mountApp()).get('/api/gamelines/mlb');
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -57,9 +71,38 @@ describe('GET /api/gamelines/:sport', () => {
const game = res.body.games['20260612_ARI@CIN']; const game = res.body.games['20260612_ARI@CIN'];
expect(game.homeTeam).toBe('CIN'); expect(game.homeTeam).toBe('CIN');
expect(game.awayTeam).toBe('ARI'); expect(game.awayTeam).toBe('ARI');
// Books must be POPULATED (the Session 25 bug: this was {}).
expect(Object.keys(game.books).sort()).toEqual(['bet365', 'betmgm', 'caesars']);
expect(game.books.bet365.homeML).toBe('-110'); expect(game.books.bet365.homeML).toBe('-110');
expect(game.books.bet365.awayML).toBe('+100');
expect(game.books.bet365.total).toBe('9.5'); expect(game.books.bet365.total).toBe('9.5');
expect(game.books.bet365.homeSpread).toBe('-1.5');
expect(game.books.betmgm.homeML).toBe('-115'); expect(game.books.betmgm.homeML).toBe('-115');
expect(game.books.caesars.total).toBe('9.5'); // totalUnder fallback
});
test('non-book keys (awayTeam, homeTeam, gameID) are excluded from books', async () => {
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
const res = await request(mountApp()).get('/api/gamelines/mlb');
const books = res.body.games['20260612_ARI@CIN'].books;
expect(books.awayteam).toBeUndefined();
expect(books.hometeam).toBeUndefined();
expect(books.gameid).toBeUndefined();
});
test('missing fields normalize to null, never undefined/crash', async () => {
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
const res = await request(mountApp()).get('/api/gamelines/mlb');
const betmgm = res.body.games['20260612_ARI@CIN'].books.betmgm;
expect(betmgm.homeSpread).toBeNull();
expect(betmgm.overOdds).toBeNull();
expect(Object.prototype.hasOwnProperty.call(betmgm, 'homeSpread')).toBe(true);
});
test('legacy sportsBooks array shape still normalizes', async () => {
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY_LEGACY);
const res = await request(mountApp()).get('/api/gamelines/mlb');
expect(res.body.games['20260612_ARI@CIN'].books.bet365.homeML).toBe('-110');
}); });
test('nba returns empty games object off-season (not an error)', async () => { test('nba returns empty games object off-season (not an error)', async () => {
@@ -89,7 +132,7 @@ describe('GET /api/gamelines/:sport', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test('cache works — adapter called once per request (adapter owns TTL)', async () => { test('adapter called once per request (adapter owns TTL)', async () => {
mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY); mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY);
const app = mountApp(); const app = mountApp();
await request(app).get('/api/gamelines/mlb'); await request(app).get('/api/gamelines/mlb');
+75
View File
@@ -58,3 +58,78 @@ describe('rosterLogs', () => {
expect(__internals.playerFromKey('gamelogs:nba:LeBron James:20', 'nba')).toBe('LeBron James'); expect(__internals.playerFromKey('gamelogs:nba:LeBron James:20', 'nba')).toBe('LeBron James');
}); });
}); });
// Session 25 — box-score aggregation bridge (prefetch key alignment).
describe('rosterLogs — Tank01 box-score aggregation', () => {
// Route scans by their MATCH pattern (3rd arg) so gamelogs + boxscore
// scans can return different key sets.
function routeScan(map) {
mockScan.mockImplementation(async (_cursor, _m, match) => ['0', map[match] || []]);
}
test('aggregates MLB box scores into per-player multi-game logs', async () => {
routeScan({
'gamelogs:mlb:*': [],
'tank01:mlb:boxscore:*': [
'tank01:mlb:boxscore:20260612_ARI@CIN',
'tank01:mlb:boxscore:20260611_ARI@LAD',
],
});
// Newer game (0612) and older game (0611) for the same batter.
store['tank01:mlb:boxscore:20260612_ARI@CIN'] = [
{ role: 'batter', name: 'Acuna', playerId: 'B1', team: 'ARI', _raw: { H: 2, HR: 1 }, _final: true },
];
store['tank01:mlb:boxscore:20260611_ARI@LAD'] = [
{ role: 'batter', name: 'Acuna', playerId: 'B1', team: 'ARI', _raw: { H: 1, HR: 0 }, _final: true },
];
const roster = await loadRosterLogs('mlb');
expect(roster).toHaveLength(1);
const acuna = roster[0];
expect(acuna.name).toBe('Acuna');
expect(acuna.team).toBe('ARI');
expect(acuna.games).toHaveLength(2);
// Most-recent first → the 2-hit game leads (flattened from _raw).
expect(acuna.games[0].H).toBe(2);
expect(acuna.games[1].H).toBe(1);
});
test('aggregated MLB logs feed the streaks engine (hit streak)', async () => {
routeScan({
'gamelogs:mlb:*': [],
'tank01:mlb:boxscore:*': ['tank01:mlb:boxscore:20260612_A@B', 'tank01:mlb:boxscore:20260611_A@B'],
});
store['tank01:mlb:boxscore:20260612_A@B'] = [{ role: 'batter', name: 'X', team: 'A', _raw: { H: 2 }, _final: true }];
store['tank01:mlb:boxscore:20260611_A@B'] = [{ role: 'batter', name: 'X', team: 'A', _raw: { H: 1 }, _final: true }];
const roster = await loadRosterLogs('mlb');
const { computeStreaks } = require('../../src/services/streaksService');
const streaks = computeStreaks(roster, 'mlb');
const hit = streaks.find((s) => s.type === 'hit_streak');
expect(hit).toBeDefined();
expect(hit.currentStreak).toBe(2);
});
test('NBA box-score rows are consumed without _raw flattening', async () => {
routeScan({
'gamelogs:nba:*': [],
'tank01:nba:boxscore:*': ['tank01:nba:boxscore:20260612_NYK@SA'],
});
store['tank01:nba:boxscore:20260612_NYK@SA'] = [
{ playerId: 'W1', name: 'Wemby', team: 'SA', pts: 30, reb: 12, ast: 3, threes: 2, blk: 4, stl: 1 },
];
const roster = await loadRosterLogs('nba');
expect(roster[0].games[0].pts).toBe(30);
});
test('gamelogs path still wins over box scores when present', async () => {
routeScan({ 'gamelogs:mlb:*': ['gamelogs:mlb:Star:20'] });
store['gamelogs:mlb:Star:20'] = [{ hits: 3 }, { hits: 2 }];
const roster = await loadRosterLogs('mlb');
expect(roster[0].name).toBe('Star');
expect(roster[0].games).toHaveLength(2);
});
test('boxScoreKeyDate extracts the date for recency ordering', () => {
expect(__internals.boxScoreKeyDate('tank01:mlb:boxscore:20260612_ARI@CIN')).toBe('20260612');
expect(__internals.boxScoreKeyDate('tank01:mlb:boxscore:weird')).toBe('0');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Game-lines proxy (Session 25). Thin forwarder to Express
* `/api/gamelines/:sport` (Tank01 book-by-book moneylines / spreads /
* totals). See the schedule proxy for why these were missing.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/gamelines/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, games: {}, source: 'tank01' });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Hot-list proxy (Session 25). Forwards to Express `/api/hotlist/:sport`,
* preserving the `?stat=` filter. Computed from cached game logs — no
* odds-api credits. See the schedule proxy for why these were missing.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/hotlist/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, players: [], source: 'computed' });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Schedule proxy (Session 25).
*
* The all-day intelligence endpoints (schedule / gamelines / streaks /
* hotlist) were built on the Express backend in Sessions 23-24 but had
* NO Next.js proxy route — so the browser's `fetch('/api/schedule/mlb')`
* 404'd on the Next origin and the slate showed zero games even though
* the backend was serving 8+. This thin forwarder fixes that, mirroring
* the existing `/api/odds/*` proxies.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
// Schedule is the foundation layer — never blow up the page. Return an
// empty-but-valid slate so the UI degrades to "no games" gracefully.
return NextResponse.json({ sport: sportLc, games: [], source: 'espn' });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Streaks proxy (Session 25). Forwards to Express `/api/streaks/:sport`,
* preserving the `?stat=` filter. Computed from cached game logs — no
* odds-api credits. See the schedule proxy for why these were missing.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/streaks/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, streaks: [], source: 'computed' });
}
}
+48 -1
View File
@@ -52,6 +52,14 @@ export interface GameLines {
export type GameStatus = 'pre' | 'in' | 'post'; export type GameStatus = 'pre' | 'in' | 'post';
// Session 25 — a computed streak for a player whose team is in this game.
export interface GameStreak {
player: string;
team?: string | null;
description: string;
currentStreak: number;
}
export interface GameCardProps { export interface GameCardProps {
sport: SlateSport; sport: SlateSport;
homeTeam: string; homeTeam: string;
@@ -73,6 +81,10 @@ export interface GameCardProps {
status?: GameStatus; status?: GameStatus;
score?: { home: number; away: number } | null; score?: { home: number; away: number } | null;
gameLines?: GameLines | null; gameLines?: GameLines | null;
// Session 25 — streaks for players in THIS game, matched by team in the
// Slate. Renders inline below the props/lines so the streak context
// lives with the game it belongs to.
streaks?: GameStreak[];
} }
// Map ESPN status → a compact, human badge. 'in' is live; 'post' is final. // Map ESPN status → a compact, human badge. 'in' is live; 'post' is final.
@@ -173,11 +185,12 @@ 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, status, score, gameLines, streaks,
} = props; } = props;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const badge = statusBadge(status, score); const badge = statusBadge(status, score);
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : []; const bookRows = gameLines?.books ? Object.entries(gameLines.books) : [];
const streakRows = (streaks || []).filter((s) => s && s.player && s.description);
// 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
@@ -406,6 +419,40 @@ export default function GameCard(props: GameCardProps) {
)} )}
</div> </div>
)} )}
{/* Session 25 — streaks for players in THIS game, inline. The Slate
matches streaks to games by team, so a card shows the streak
context for the players actually on the floor/field. Renders
only when there's at least one — no empty section. */}
{streakRows.length > 0 && (
<div
style={{
padding: '10px 16px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 6,
}}
>
<div
className="mono"
style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
>
🔥 Streaks
</div>
{streakRows.map((s) => (
<div
key={`${s.player}-${s.description}`}
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12, alignItems: 'baseline' }}
>
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 600 }}>
{s.player}
{s.team ? <span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400 }}> · {s.team}</span> : null}
</span>
<span style={{ color: 'var(--text-secondary, #8A8A9A)', textAlign: 'right' }}>{s.description}</span>
</div>
))}
</div>
)}
</article> </article>
); );
} }
+14
View File
@@ -145,6 +145,20 @@ export default function LiveHeroProp() {
marginInline: 'auto', marginInline: 'auto',
}} }}
> >
{/* Session 25 — the static fallback is an ILLUSTRATIVE example, not
a live pick. Labelling it prevents the fixed stats from reading
as stale real data when no live hero-prop is flowing. */}
<span
className="mono"
aria-label="Example grade"
style={{
position: 'absolute', top: 12, right: 12, fontSize: 9, fontWeight: 800,
letterSpacing: '0.12em', padding: '3px 7px', borderRadius: 4,
background: 'rgba(255,255,255,0.06)', color: 'var(--text-tertiary)', textTransform: 'uppercase',
}}
>
Example
</span>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div> <div>
<span <span
+52 -4
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, GameLines } from '@/components/GameCard'; import GameCard, { SlateSport, GameLines, GameStreak } 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
@@ -138,8 +138,20 @@ interface SlateGame {
status?: 'pre' | 'in' | 'post'; status?: 'pre' | 'in' | 'post';
score?: { home: number; away: number } | null; score?: { home: number; away: number } | null;
gameLines?: GameLines | null; gameLines?: GameLines | null;
// Session 25 — team abbreviations (for streak matching) + matched streaks.
homeAbbr?: string | null;
awayAbbr?: string | null;
streaks?: GameStreak[];
} }
interface StreakApiRow {
player: string;
team?: string | null;
description: string;
currentStreak: number;
}
interface StreaksResponse { streaks?: StreakApiRow[] }
// ---- Session 24: schedule + game-lines response shapes ---- // ---- Session 24: schedule + game-lines response shapes ----
interface ScheduleTeam { name?: string | null; abbreviation?: string | null } interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
interface ScheduleGame { interface ScheduleGame {
@@ -194,22 +206,42 @@ function findGameLines(home?: ScheduleTeam, away?: ScheduleTeam, lines?: Record<
* appended so we never drop props. When schedule is empty, the odds * appended so we never drop props. When schedule is empty, the odds
* games become the base (odds-only fallback). * games become the base (odds-only fallback).
*/ */
// Match streaks to a game by team abbreviation. A streak's `team` is the
// player's team abbrev (ESPN/Tank01 standard), which lines up with the
// schedule's home/away abbreviations.
function streaksForGame(home?: string | null, away?: string | null, streaks?: StreakApiRow[]): GameStreak[] {
if (!streaks || streaks.length === 0) return [];
const h = (home || '').toUpperCase();
const a = (away || '').toUpperCase();
if (!h && !a) return [];
return streaks
.filter((s) => {
const t = (s.team || '').toUpperCase();
return t && (t === h || t === a);
})
.map((s) => ({ player: s.player, team: s.team, description: s.description, currentStreak: s.currentStreak }));
}
function mergeSlate( function mergeSlate(
sport: SlateSport, sport: SlateSport,
scheduleGames: ScheduleGame[], scheduleGames: ScheduleGame[],
oddsGames: SlateGame[], oddsGames: SlateGame[],
lines?: Record<string, GameLines>, lines?: Record<string, GameLines>,
streaks?: StreakApiRow[],
): SlateGame[] { ): SlateGame[] {
const base: SlateGame[] = scheduleGames.map((sg) => ({ const base: SlateGame[] = scheduleGames.map((sg) => ({
sport, sport,
homeTeam: sg.homeTeam?.name || '', homeTeam: sg.homeTeam?.name || '',
awayTeam: sg.awayTeam?.name || '', awayTeam: sg.awayTeam?.name || '',
homeAbbr: sg.homeTeam?.abbreviation || null,
awayAbbr: sg.awayTeam?.abbreviation || null,
gameTime: sg.gameTime || undefined, gameTime: sg.gameTime || undefined,
venue: sg.venue || undefined, venue: sg.venue || undefined,
status: sg.status || undefined, status: sg.status || undefined,
score: sg.score || undefined, score: sg.score || undefined,
props: [], props: [],
gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines), gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
streaks: streaksForGame(sg.homeTeam?.abbreviation, sg.awayTeam?.abbreviation, streaks),
})); }));
const unmatched: SlateGame[] = []; const unmatched: SlateGame[] = [];
@@ -342,17 +374,18 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const perSport = await Promise.all( const perSport = await Promise.all(
sportsToFetch.map(async (sport) => { sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[]; const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines] = await Promise.all([ const [oddsResults, schedule, lines, streaksRes] = await Promise.all([
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))), Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null), SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
]); ]);
const oddsOk = oddsResults.some((o) => o !== null); const oddsOk = oddsResults.some((o) => o !== null);
const oddsProps = oddsResults.flatMap((o) => o?.props || []); const oddsProps = oddsResults.flatMap((o) => o?.props || []);
const oddsGames = groupByGame(oddsProps, sport); const oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || []; const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games); const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 }; return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 };
}), }),
); );
@@ -453,6 +486,20 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
.filter((g): g is SlateGame => g !== null); .filter((g): g is SlateGame => g !== null);
}, [games, searchQuery]); }, [games, searchQuery]);
// Session 25 — per-sport game counts for the tab labels, derived from
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
// free ESPN schedule even when odds are empty. Counts only appear for
// sports currently loaded (the active tab fetches its own sports).
const countBySport = useMemo(() => {
const m: Partial<Record<SlateSport, number>> = {};
for (const g of games) m[g.sport] = (m[g.sport] || 0) + 1;
return m;
}, [games]);
const tabCount = (id: SlateTab): number | null => {
if (id === 'all') return games.length || null;
return countBySport[id as SlateSport] ?? null;
};
// Manual scan fallback URL — pre-fills /scan with the search query // Manual scan fallback URL — pre-fills /scan with the search query
// so the user lands on a partially-filled form instead of empty. // so the user lands on a partially-filled form instead of empty.
const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`; const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`;
@@ -522,7 +569,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
{t.label} {t.label}{tabCount(t.id) != null ? ` (${tabCount(t.id)})` : ''}
</button> </button>
); );
})} })}
@@ -658,6 +705,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
status={g.status} status={g.status}
score={g.score} score={g.score}
gameLines={g.gameLines} gameLines={g.gameLines}
streaks={g.streaks}
gradedProps={gradedProps} gradedProps={gradedProps}
loadingKey={gradingKey} loadingKey={gradingKey}
errorByKey={errorByKey} errorByKey={errorByKey}