11fc5a66d2
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>
229 lines
8.7 KiB
JavaScript
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;
|