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
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
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';
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user