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

This commit is contained in:
Kev
2026-06-12 17:58:55 -04:00
parent 433e827103
commit 956cdb863a
15 changed files with 602 additions and 39 deletions
+83 -9
View File
@@ -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,
},
};