80481d6f6a
- StreaksPanel renders THE LENS (interpreted read + tonight difficulty) + snapshot grade letters; streaks are FREE for every tier per the product definition (the picture is free, the read is paid) — only hot lists keep the free top-3 gate. - One stat selection now filters ALL layers: card props narrow together with streaks + hot lists (Slate activeStat → slateGameToCardData). - /explore = server SEO shell (daily-indexable: 'MLB Hit Streaks, Hot Hitters & Stat Leaders') + ExploreHub client: leaders + full streaks + hot lists, real-tier gated, defaults to the in-season sport. - Landing teaser fixed: it pointed at NBA (off-season → self-hid forever); now MLB top-3 through the lens — the anonymous-visitor hook actually shows. - Player dossier: ACTIVE STREAKS block (free, lens reads) via new ?player= filter on /api/streaks/:sport. - Schedule layer already the Slate base (mergeSlate) — verified, no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
118 lines
5.0 KiB
JavaScript
118 lines
5.0 KiB
JavaScript
/**
|
|
* /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;
|