/** * /api/streaks/:sport (Session 23; Session 60 night2/B — the lens). * * Computed player streaks + form heat from cached game logs, every row * interpreted through the VYNDR lens: what the streak was built against, * tonight's matchup (opponent + MLB opposing SP w/ ERA), difficulty, and a * one-line read. NO paid API calls — warm Redis + the free ESPN/statsapi * caches only. Where a snapshot grade exists for a streaking player's * category, the grade letter rides along (the slate already shows locked * grades publicly — consistent, not a leak). * * Response: { sport, stat, streaks: [...], source: 'computed' } * An empty array is a valid, non-error state. */ const express = require('express'); const streaksService = require('../services/streaksService'); const { applyLens } = require('../services/streakLens'); const { loadRosterLogs } = require('../services/rosterLogs'); const { nameKey } = require('../utils/playerName'); const { createRateLimit } = require('../middleware/rateLimit'); // Lens context reads are best-effort: a dead cache or slow upstream must // NEVER hang the public route — the lens just says less. Timer is unref'd // so it can't keep the process (or Jest) alive. function withTimeout(promise, ms) { return Promise.race([ promise, new Promise((resolve) => { const t = setTimeout(() => resolve(null), ms); if (t.unref) t.unref(); }), ]).catch(() => null); } const LENS_BUDGET_MS = 1500; const inTest = () => process.env.NODE_ENV === 'test'; const router = express.Router(); // Session 32 — public throttle (60/min; pure engine over cached logs). router.use(createRateLimit({ windowMs: 60_000, max: 60 })); const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' }; const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']); function todayET() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); } /** Tonight's context for the lens — best-effort, time-bounded, degrades to * less context rather than hanging. Skipped under NODE_ENV=test (the lens * builder itself is unit-tested; route tests exercise engine + shape). */ async function lensContext(sport) { const ctx = { scheduleGames: [], pitcherGames: [] }; if (inTest()) return ctx; const { cacheGet } = require('../utils/redis'); const sched = await withTimeout(cacheGet(`schedule:${sport}:${todayET()}`), LENS_BUDGET_MS); if (Array.isArray(sched)) ctx.scheduleGames = sched; if (sport === 'mlb') { // Warm in prod (the slate's /pitchers endpoint fills the same caches); // hard-capped so a cold cache costs at most the budget, never a hang. const { getProbablePitchers } = require('../services/probablePitchers'); const pg = await withTimeout(getProbablePitchers(todayET()), LENS_BUDGET_MS); if (Array.isArray(pg)) ctx.pitcherGames = pg; } return ctx; } /** Grade letters for streaking players (snapshot cache; public data). */ async function gradeJoin(sport) { if (inTest()) return {}; try { const { cacheGet } = require('../utils/redis'); const env = await withTimeout(cacheGet(`grades:${sport}`), LENS_BUDGET_MS); const map = {}; for (const g of (env && env.grades) || []) { map[nameKey(g.player || g.player_name)] = map[nameKey(g.player || g.player_name)] || g.grade; } return map; } catch { return {}; } } router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); if (!SUPPORTED.has(sport)) { return res.status(404).set(MISSION_HEADER).json({ error: `No streaks for sport: ${sport}` }); } const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all'; const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0; // Session 60 (night2/C) — a player's active streaks for the dossier page. const playerKey = req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : null; try { let roster = await loadRosterLogs(sport); if (playerKey) roster = roster.filter((p) => nameKey(p.name) === playerKey); const streaks = streaksService.computeStreaks(roster, sport, { stat }); // Session 60 — form heat (hot hitters/sluggers/shooters) joins the feed. const heat = streaksService.computeFormHeat(roster, sport, {}) .filter((h) => stat === 'all' || h.category === stat); let rows = [...streaks, ...heat]; if (limit > 0) rows = rows.slice(0, limit); // THE LENS — no raw streak renders alone. const [ctx, grades] = await Promise.all([lensContext(sport), gradeJoin(sport)]); rows = applyLens(rows, ctx).map((r) => ({ ...r, grade: grades[nameKey(r.player)] || null })); return res.set(MISSION_HEADER).json({ sport, stat, streaks: rows, source: 'computed' }); } catch (err) { console.error(`[streaks/${sport}]`, err.message); return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' }); } }); module.exports = router;