Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)

This commit is contained in:
Kev
2026-06-12 11:16:58 -04:00
parent 6ab49d4c37
commit 0538205fab
32 changed files with 2276 additions and 2 deletions
+95
View File
@@ -0,0 +1,95 @@
/**
* Roster game-log loader (Session 23).
*
* Streaks and hot lists both need "every player's recent game log" — but
* VYNDR caches logs per-player on demand (`gamelogs:{sport}:{player}:{n}`)
* 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:
* 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.
*
* 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
* streaks/hot-list panels simply render nothing while other layers carry
* the slate.
*/
const { cacheGet, getRedisClient, isDegraded } = require('../utils/redis');
const SCAN_COUNT = 200;
const MAX_KEYS = 600; // safety cap so a huge cache can't stall a request
/**
* Parse a player display name out of a gamelogs key.
* Key shape: `gamelogs:{sport}:{playerName}:{count}` — playerName may
* itself contain colons in theory, so split off the known head/tail.
*/
function playerFromKey(key, sport) {
const prefix = `gamelogs:${sport}:`;
if (!key.startsWith(prefix)) return null;
const rest = key.slice(prefix.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return rest;
return rest.slice(0, lastColon);
}
async function scanGameLogKeys(sport) {
if (isDegraded && isDegraded()) return [];
const redis = getRedisClient();
if (!redis || typeof redis.scan !== 'function') return [];
const match = `gamelogs:${sport}:*`;
const keys = [];
let cursor = '0';
try {
do {
const [next, batch] = await redis.scan(cursor, 'MATCH', match, 'COUNT', SCAN_COUNT);
cursor = next;
for (const k of batch) {
if (!keys.includes(k)) keys.push(k);
if (keys.length >= MAX_KEYS) return keys;
}
} while (cursor !== '0');
} catch (err) {
console.warn('[rosterLogs] scan failed:', err.message);
return keys;
}
return keys;
}
/**
* Returns [{ name, playerId, team, games }] for a sport. Dedupes players
* (the highest game-count key wins) so one player isn't double-counted
* across `:10` / `:20` cache variants.
*/
async function loadRosterLogs(sport) {
const key = String(sport || '').toLowerCase();
if (!key) return [];
// Fast path — a prefetched roster blob.
const blob = await cacheGet(`rosterlogs:${key}`);
if (Array.isArray(blob) && blob.length > 0) return blob;
const keys = await scanGameLogKeys(key);
if (keys.length === 0) return [];
const byPlayer = new Map();
for (const k of keys) {
const name = playerFromKey(k, key);
if (!name) continue;
const games = await cacheGet(k);
if (!Array.isArray(games) || games.length === 0) continue;
const existing = byPlayer.get(name);
if (!existing || games.length > existing.games.length) {
const playerId = games[0]?.playerId ?? games[0]?.player_id ?? null;
const team = games[0]?.team ?? games[0]?.teamAbv ?? null;
byPlayer.set(name, { name, playerId, team, games });
}
}
return Array.from(byPlayer.values());
}
module.exports = { loadRosterLogs, __internals: { playerFromKey, scanGameLogKeys } };