Files
vyndr/src/routes/stats.js
T
builtbykev 11fc5a66d2 Wave 5B: Pitcher Arsenal via Baseball Savant (Statcast)
D4 — build the FREE Baseball Savant adapter for pitch-level identity
(mix / velo / usage% / whiff%), the missing layer statsapi doesn't carry.

- savantAdapter.getPitcherArsenal(id|name) — normalizes two public Savant
  CSV leaderboards (csv=true, NO parsing dependency): pitch-arsenal-stats
  (usage% + whiff% + K%) + pitch-arsenals avg_speed (velo). League-wide,
  cached 24h + in-memory mirror, indexed by MLBAM id. Defensive: null on any
  unrecognized shape; a missing velo/whiff is ABSENT (null), never 0.
  Injectable (fetchImpl/statsCsv/veloCsv/resolveId) → tests hit no network.
  Live endpoints VERIFIED (200, exact columns) from the sandbox.
- GET /api/stats/pitcher/:name/arsenal (stats.js) + Next proxy. MLB-only;
  an error/miss returns { found:false } so the card self-hides honestly.
- PitcherArsenal.tsx (+ barrel) — the mockup's PITCHER IDENTITY strip:
  pitch mix % + velo + whiff%, mono/tabular, ranked by usage, sharpest-whiff
  pitch highlighted green. Self-hides (heading included) when arsenal absent.
  Mounted on the MLB player profile (a pitcher surface). Context, not a
  graded market value.
- Tests: savantAdapter (fake CSV → ranked arsenal; unknown shape/blank cells
  → absent not 0; name→id resolve) + PitcherArsenal source locks (self-hide,
  mono/tabular, em-dash-not-zero). +2 suites / +17 tests (3012 → 3029).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:02:16 -04:00

229 lines
8.7 KiB
JavaScript

const express = require('express');
const { getSupabaseServiceClient } = require('../utils/supabase');
const { getStatFilters } = require('../config/statFilters');
const { createRateLimit } = require('../middleware/rateLimit');
const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService');
const { getPitcherArsenal } = require('../services/adapters/savantAdapter');
const depthChart = require('../services/depthChartService');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
// Player-intelligence endpoints are public + cache-backed (Session 42). Cap
// per-IP like the other public cached routers (60/min).
const intelLimit = createRateLimit({ windowMs: 60_000, max: 60 });
// GET /filters/:sport — stat-filter categories for the StatFilterPills UI
// (Session 23). Lets the frontend stay data-driven without re-declaring
// the category list. NO auth / NO DB — pure config.
router.get('/filters/:sport', (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
res.set(MISSION_HEADER).json({ sport, filters: getStatFilters(sport) });
});
// GET /parlays-graded — total scan count
router.get('/parlays-graded', async (req, res) => {
try {
const supabase = getSupabaseServiceClient();
const { count, error } = await supabase
.from('scan_sessions')
.select('*', { count: 'exact', head: true });
if (error) throw error;
res.set(MISSION_HEADER).json({ count: count || 0 });
} catch (err) {
console.error('[stats/parlays-graded]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /public — public dashboard stats
router.get('/public', async (req, res) => {
try {
const supabase = getSupabaseServiceClient();
// Total parlays graded
const { count: parlaysGraded, error: countErr } = await supabase
.from('scan_sessions')
.select('*', { count: 'exact', head: true });
if (countErr) throw countErr;
// Most common grade
const { data: grades, error: gradesErr } = await supabase
.from('scan_sessions')
.select('final_grade');
if (gradesErr) throw gradesErr;
let avg_grade = null;
if (grades && grades.length > 0) {
const freq = {};
for (const row of grades) {
const g = row.final_grade;
if (g) freq[g] = (freq[g] || 0) + 1;
}
let maxCount = 0;
for (const [grade, c] of Object.entries(freq)) {
if (c > maxCount) {
maxCount = c;
avg_grade = grade;
}
}
}
// Kill conditions caught
const { data: picks, error: picksErr } = await supabase
.from('picks')
.select('kill_conditions')
.not('kill_conditions', 'eq', '[]');
if (picksErr) throw picksErr;
const kill_conditions_caught = picks ? picks.filter(p =>
p.kill_conditions && Array.isArray(p.kill_conditions) && p.kill_conditions.length > 0
).length : 0;
res.set(MISSION_HEADER).json({
parlays_graded: parlaysGraded || 0,
avg_grade,
kill_conditions_caught,
sports_covered: ['NBA', 'MLB'],
});
} catch (err) {
console.error('[stats/public]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /live — top 3 most recently graded props
router.get('/live', async (req, res) => {
try {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('picks')
.select('player, stat_type, line, direction, grade, confidence, created_at')
.order('created_at', { ascending: false })
.limit(3);
if (error) throw error;
const result = (data || []).map(row => ({
player: row.player,
stat: row.stat_type,
line: row.line,
direction: row.direction,
grade: row.grade,
confidence: row.confidence,
sport: 'NBA',
graded_at: row.created_at,
}));
res.set(MISSION_HEADER).json(result);
} catch (err) {
console.error('[stats/live]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /player/:name?sport=nba — full player intelligence payload (Session 42):
// standard stats + archetype DNA + VYNDR intelligence + tonight's graded props.
// Name is sanitized inside the service. Always 200 with a valid shape (degrades
// gracefully on cold caches) — the profile page must never hard-fail.
router.get('/player/:name', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const data = await getPlayerIntel(req.params.name, sport);
res.set(MISSION_HEADER).json(data);
} catch (err) {
console.error('[stats/player]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /pitcher/:name/arsenal?sport=mlb — Baseball Savant pitch arsenal (Wave 5B).
// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast
// source; the adapter resolves name→MLBAM id + caches 24h. MLB only — any other
// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates.
router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'mlb').toLowerCase();
if (sport !== 'mlb') {
return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' });
}
const name = sanitizePlayerName(req.params.name);
const arsenal = await getPitcherArsenal(name);
res.set(MISSION_HEADER).json(arsenal || { found: false });
} catch (err) {
console.error('[stats/pitcher/arsenal]', err.message);
// Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides.
res.set(MISSION_HEADER).json({ found: false });
}
});
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
// graded props by confidence) for the Terminal / Stats Explorer.
router.get('/leaders', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const leaders = await getLeaders(sport, { stat: req.query.stat, limit: req.query.limit });
res.set(MISSION_HEADER).json({ sport, stat: req.query.stat || null, leaders });
} catch (err) {
console.error('[stats/leaders]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /game/:id?sport=nba — game-level enrichment (pitchers, injuries, leaders,
// team records) via the ESPN summary (Session 30). Best-effort.
router.get('/game/:id', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const { getGameSummary } = require('../services/scheduleService');
const summary = await getGameSummary(sport, req.params.id);
res.set(MISSION_HEADER).json(summary || { error: 'not_found' });
} catch (err) {
console.error('[stats/game]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// Depth chart / lineup / cascade (Session 43). Graceful — always 200 with a
// valid (possibly empty) shape so the UI never hard-fails.
// GET /lineup/:team?sport=mlb
router.get('/lineup/:team', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const lineup = await depthChart.getLineup(sport, req.params.team);
res.set(MISSION_HEADER).json({ sport, team: req.params.team, lineup });
} catch (err) {
console.error('[stats/lineup]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /depth/:team?sport=nba
router.get('/depth/:team', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const chart = await depthChart.getDepthChart(sport, req.params.team);
res.set(MISSION_HEADER).json(chart);
} catch (err) {
console.error('[stats/depth]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /cascade/:player?sport=nba&team=SA
router.get('/cascade/:player', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const cascade = await depthChart.getCascadeProjection(sport, req.params.player, req.query.team);
res.set(MISSION_HEADER).json({ sport, player: req.params.player, cascade });
} catch (err) {
console.error('[stats/cascade]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
module.exports = router;