Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)
This commit is contained in:
@@ -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 } };
|
||||
Reference in New Issue
Block a user