Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)
This commit is contained in:
@@ -4,6 +4,77 @@
|
||||
2026-06-12
|
||||
|
||||
## 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)
|
||||
|
||||
## Session 24 (2026-06-12) — SHIPPED
|
||||
|
||||
@@ -73,6 +73,19 @@ empty. NONE of these spend odds-api credits:
|
||||
- Dead providers: set `status: 'dead'` in `config/providers.js` to drop a
|
||||
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
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -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":"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-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
@@ -57,12 +57,28 @@ function teamsFromGameId(gameId) {
|
||||
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.
|
||||
* 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) {
|
||||
if (!odds || typeof odds !== 'object') return null;
|
||||
if (!odds || typeof odds !== 'object' || Array.isArray(odds)) return null;
|
||||
const pick = (...keys) => {
|
||||
for (const k of keys) {
|
||||
if (odds[k] !== undefined && odds[k] !== null && odds[k] !== '') return odds[k];
|
||||
@@ -70,18 +86,53 @@ function normalizeBook(odds) {
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
homeML: pick('homeTeamMLOdds', 'homeML', 'moneyLineHome'),
|
||||
awayML: pick('awayTeamMLOdds', 'awayML', 'moneyLineAway'),
|
||||
total: pick('totalOver', 'total', 'overUnder'),
|
||||
homeML: pick('homeTeamML', 'homeTeamMLOdds', 'homeML', 'moneyLineHome'),
|
||||
awayML: pick('awayTeamML', 'awayTeamMLOdds', 'awayML', 'moneyLineAway'),
|
||||
total: pick('totalOver', 'totalUnder', 'total', 'overUnder'),
|
||||
overOdds: pick('totalOverOdds', 'overOdds'),
|
||||
underOdds: pick('totalUnderOdds', 'underOdds'),
|
||||
homeSpread: pick('homeTeamSpread', 'homeSpread'),
|
||||
awaySpread: pick('awayTeamSpread', 'awaySpread'),
|
||||
homeSpreadOdds: pick('homeTeamSpreadOdds'),
|
||||
awaySpreadOdds: pick('awayTeamSpreadOdds'),
|
||||
homeSpread: pick('homeTeamRunLine', 'homeTeamSpread', 'homeSpread'),
|
||||
awaySpread: pick('awayTeamRunLine', 'awayTeamSpread', 'awaySpread'),
|
||||
homeSpreadOdds: pick('homeTeamSpreadOdds', 'homeTeamRunLineOdds'),
|
||||
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
|
||||
* route's `games` shape. Defensive against both the documented map form
|
||||
@@ -98,19 +149,10 @@ function normalizeGameLines(body) {
|
||||
for (const [gameId, game] of entries) {
|
||||
if (!gameId || !game || typeof game !== 'object') continue;
|
||||
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] = {
|
||||
homeTeam: game.homeTeam || homeTeam,
|
||||
awayTeam: game.awayTeam || awayTeam,
|
||||
books,
|
||||
books: extractBooks(game),
|
||||
};
|
||||
}
|
||||
return games;
|
||||
@@ -141,4 +183,4 @@ router.get('/:sport', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines };
|
||||
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines, extractBooks };
|
||||
|
||||
@@ -6,11 +6,20 @@
|
||||
* 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).
|
||||
*
|
||||
* 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
|
||||
* one (fast path — a single read).
|
||||
* 2. Otherwise SCAN the per-player `gamelogs:{sport}:*` keys and assemble
|
||||
* a roster from whatever's warm.
|
||||
* 2. The per-player `gamelogs:{sport}:*` keys (NBA/WNBA: written by
|
||||
* 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
|
||||
* 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);
|
||||
}
|
||||
|
||||
async function scanGameLogKeys(sport) {
|
||||
async function scanKeys(match) {
|
||||
if (isDegraded && isDegraded()) return [];
|
||||
const redis = getRedisClient();
|
||||
if (!redis || typeof redis.scan !== 'function') return [];
|
||||
const match = `gamelogs:${sport}:*`;
|
||||
const keys = [];
|
||||
let cursor = '0';
|
||||
try {
|
||||
@@ -60,6 +68,63 @@ async function scanGameLogKeys(sport) {
|
||||
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
|
||||
* (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}`);
|
||||
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);
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const byPlayer = new Map();
|
||||
for (const k of keys) {
|
||||
const name = playerFromKey(k, key);
|
||||
@@ -89,7 +153,17 @@ async function loadRosterLogs(sport) {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,12 +30,26 @@ function mountApp() {
|
||||
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 = {
|
||||
'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': {
|
||||
gameID: '20260612_ARI@CIN',
|
||||
sportsBooks: [
|
||||
{ sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5', totalOverOdds: '-105', totalUnderOdds: '-115' } },
|
||||
{ sportsBook: 'betmgm', odds: { homeTeamMLOdds: '-115', awayTeamMLOdds: '-105', totalOver: '9' } },
|
||||
{ sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -49,7 +63,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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);
|
||||
const res = await request(mountApp()).get('/api/gamelines/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
@@ -57,9 +71,38 @@ describe('GET /api/gamelines/:sport', () => {
|
||||
const game = res.body.games['20260612_ARI@CIN'];
|
||||
expect(game.homeTeam).toBe('CIN');
|
||||
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.awayML).toBe('+100');
|
||||
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.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 () => {
|
||||
@@ -89,7 +132,7 @@ describe('GET /api/gamelines/:sport', () => {
|
||||
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);
|
||||
const app = mountApp();
|
||||
await request(app).get('/api/gamelines/mlb');
|
||||
|
||||
@@ -58,3 +58,78 @@ describe('rosterLogs', () => {
|
||||
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
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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,14 @@ export interface GameLines {
|
||||
|
||||
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 {
|
||||
sport: SlateSport;
|
||||
homeTeam: string;
|
||||
@@ -73,6 +81,10 @@ export interface GameCardProps {
|
||||
status?: GameStatus;
|
||||
score?: { home: number; away: number } | 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.
|
||||
@@ -173,11 +185,12 @@ export default function GameCard(props: GameCardProps) {
|
||||
props: propList, gradedProps, loadingKey, errorByKey,
|
||||
tier = 'free', onGrade, onUpgrade,
|
||||
defaultVisible = 4,
|
||||
status, score, gameLines,
|
||||
status, score, gameLines, streaks,
|
||||
} = props;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const badge = statusBadge(status, score);
|
||||
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
|
||||
// props. Showing the first 4 prop rows that all belonged to the
|
||||
@@ -406,6 +419,40 @@ export default function GameCard(props: GameCardProps) {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,6 +145,20 @@ export default function LiveHeroProp() {
|
||||
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>
|
||||
<span
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 { useAuth } from '@/contexts/AuthContext';
|
||||
// Session 23 — all-day intelligence layer. The stat filter is the
|
||||
@@ -138,8 +138,20 @@ interface SlateGame {
|
||||
status?: 'pre' | 'in' | 'post';
|
||||
score?: { home: number; away: number } | null;
|
||||
gameLines?: GameLines | null;
|
||||
// Session 25 — team abbreviations (for streak matching) + matched streaks.
|
||||
homeAbbr?: string | null;
|
||||
awayAbbr?: string | null;
|
||||
streaks?: GameStreak[];
|
||||
}
|
||||
|
||||
interface StreakApiRow {
|
||||
player: string;
|
||||
team?: string | null;
|
||||
description: string;
|
||||
currentStreak: number;
|
||||
}
|
||||
interface StreaksResponse { streaks?: StreakApiRow[] }
|
||||
|
||||
// ---- Session 24: schedule + game-lines response shapes ----
|
||||
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
|
||||
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
|
||||
* games become the base (odds-only fallback).
|
||||
*/
|
||||
// Match streaks to a game by team abbreviation. A streak's `team` is the
|
||||
// player's team abbrev (ESPN/Tank01 standard), which lines up with the
|
||||
// schedule's home/away abbreviations.
|
||||
function streaksForGame(home?: string | null, away?: string | null, streaks?: StreakApiRow[]): GameStreak[] {
|
||||
if (!streaks || streaks.length === 0) return [];
|
||||
const h = (home || '').toUpperCase();
|
||||
const a = (away || '').toUpperCase();
|
||||
if (!h && !a) return [];
|
||||
return streaks
|
||||
.filter((s) => {
|
||||
const t = (s.team || '').toUpperCase();
|
||||
return t && (t === h || t === a);
|
||||
})
|
||||
.map((s) => ({ player: s.player, team: s.team, description: s.description, currentStreak: s.currentStreak }));
|
||||
}
|
||||
|
||||
function mergeSlate(
|
||||
sport: SlateSport,
|
||||
scheduleGames: ScheduleGame[],
|
||||
oddsGames: SlateGame[],
|
||||
lines?: Record<string, GameLines>,
|
||||
streaks?: StreakApiRow[],
|
||||
): SlateGame[] {
|
||||
const base: SlateGame[] = scheduleGames.map((sg) => ({
|
||||
sport,
|
||||
homeTeam: sg.homeTeam?.name || '',
|
||||
awayTeam: sg.awayTeam?.name || '',
|
||||
homeAbbr: sg.homeTeam?.abbreviation || null,
|
||||
awayAbbr: sg.awayTeam?.abbreviation || null,
|
||||
gameTime: sg.gameTime || undefined,
|
||||
venue: sg.venue || undefined,
|
||||
status: sg.status || undefined,
|
||||
score: sg.score || undefined,
|
||||
props: [],
|
||||
gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
|
||||
streaks: streaksForGame(sg.homeTeam?.abbreviation, sg.awayTeam?.abbreviation, streaks),
|
||||
}));
|
||||
|
||||
const unmatched: SlateGame[] = [];
|
||||
@@ -342,17 +374,18 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
const perSport = await Promise.all(
|
||||
sportsToFetch.map(async (sport) => {
|
||||
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))),
|
||||
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<StreaksResponse>(`/api/streaks/${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);
|
||||
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
|
||||
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);
|
||||
}, [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
|
||||
// so the user lands on a partially-filled form instead of empty.
|
||||
const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`;
|
||||
@@ -522,7 +569,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{t.label}{tabCount(t.id) != null ? ` (${tabCount(t.id)})` : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -658,6 +705,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
status={g.status}
|
||||
score={g.score}
|
||||
gameLines={g.gameLines}
|
||||
streaks={g.streaks}
|
||||
gradedProps={gradedProps}
|
||||
loadingKey={gradingKey}
|
||||
errorByKey={errorByKey}
|
||||
|
||||
Reference in New Issue
Block a user