From 11fc5a66d2002038f8b0334630c38f792f364652 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 16:02:16 -0400 Subject: [PATCH] Wave 5B: Pitcher Arsenal via Baseball Savant (Statcast) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/routes/stats.js | 23 +- src/services/adapters/savantAdapter.js | 277 ++++++++++++++++++ tests/unit/pitcherArsenalComponent.test.js | 67 +++++ tests/unit/savantAdapter.test.js | 100 +++++++ .../api/stats/pitcher/[name]/arsenal/route.ts | 27 ++ web/src/app/globals.css | 3 + web/src/app/player/[name]/page.tsx | 8 + web/src/components/vyndr/PitcherArsenal.tsx | 175 +++++++++++ web/src/components/vyndr/index.ts | 2 + 9 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 src/services/adapters/savantAdapter.js create mode 100644 tests/unit/pitcherArsenalComponent.test.js create mode 100644 tests/unit/savantAdapter.test.js create mode 100644 web/src/app/api/stats/pitcher/[name]/arsenal/route.ts create mode 100644 web/src/components/vyndr/PitcherArsenal.tsx diff --git a/src/routes/stats.js b/src/routes/stats.js index e44fbe5..5af2525 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -2,7 +2,8 @@ const express = require('express'); const { getSupabaseServiceClient } = require('../utils/supabase'); const { getStatFilters } = require('../config/statFilters'); const { createRateLimit } = require('../middleware/rateLimit'); -const { getPlayerIntel, getLeaders } = require('../services/playerIntelService'); +const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService'); +const { getPitcherArsenal } = require('../services/adapters/savantAdapter'); const depthChart = require('../services/depthChartService'); const router = express.Router(); @@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => { } }); +// 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) => { diff --git a/src/services/adapters/savantAdapter.js b/src/services/adapters/savantAdapter.js new file mode 100644 index 0000000..f464c7e --- /dev/null +++ b/src/services/adapters/savantAdapter.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * Baseball Savant / Statcast adapter (Wave 5B — Pitcher Arsenal). + * + * Pitch-level identity (mix / velo / usage% / whiff%) is NOT in statsapi.mlb.com — + * it lives on Baseball Savant (baseballsavant.mlb.com), which is FREE + public. + * `mlbStatsAdapter` already gives probable pitchers + ERA + game logs; the arsenal + * is the missing "one identity → many props" lens (the mockup's PITCHER IDENTITY). + * + * ZERO-OUT-OF-POCKET + HONESTY doctrine (same as espnStatsAdapter): + * - Free source → NO gateway / NO quota tracking. + * - Prefer a CSV/JSON leaderboard endpoint so NO new parsing dependency is + * needed (a tiny quote-aware CSV parser lives here; no cheerio/HTML scrape). + * - Defensive parsing: `null` on any unrecognized shape. A missing velo/whiff + * is ABSENT (null), NEVER 0 — `Number(null) === 0` is the fabrication trap. + * - Never fabricate: no pitcher → `{ found:false }`; the card self-hides. + * - Injectable (`opts.fetchImpl` / `opts.statsCsv` / `opts.veloCsv` / + * `opts.resolveId` / cacheGet/cacheSet) → tests never hit the network. + * + * SOURCE ENDPOINTS (public, `csv=true` → no dependency): + * 1. Pitch-arsenal-stats (usage% + whiff% + K%) — LONG format, one row per + * (pitcher, pitch_type): + * https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year={year}&min=1&csv=true + * 2. Pitch-arsenals avg velo (best-effort enrichment) — WIDE format, one row + * per pitcher with a `{abbr}_avg_speed` column per pitch: + * https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year={year}&min=1&type=avg_speed&hand=&csv=true + * Both are LEAGUE-WIDE → fetched once, cached 24h, indexed by MLBAM pitcher id. + * Velo is OPTIONAL: if endpoint #2's shape drifts, velo stays absent (null) — + * never a fabricated 0. NEEDS PROD VERIFICATION of the live column names. + */ + +const axios = require('axios'); +const { cacheGet: redisGet, cacheSet: redisSet } = require('../../utils/redis'); + +const HTTP_TIMEOUT_MS = 12_000; +const ARSENAL_TTL = 24 * 3600; // 24h — pitch mix is a slow-moving season identity +const LEAGUE_TTL = 24 * 3600; +const DEFAULT_SEASON = 2026; +const TOP_PITCHES = 6; // cap the strip — nobody throws more than ~6 real pitches + +// In-memory mirror so a single process serves the merged per-pitcher arsenal +// without a Redis round-trip (and so it degrades when Redis is down). +const _mem = new Map(); // key -> { value, exp } +function memGet(key) { + const hit = _mem.get(key); + if (!hit) return null; + if (hit.exp && hit.exp < Date.now()) { _mem.delete(key); return null; } + return hit.value; +} +function memSet(key, value, ttl) { + _mem.set(key, { value, exp: Date.now() + ttl * 1000 }); +} + +function statsUrl(year) { + return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year=${year}&min=1&csv=true`; +} +function veloUrl(year) { + return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year=${year}&min=1&type=avg_speed&hand=&csv=true`; +} + +// ── Strict numeric parsing — absent beats zero ────────────────────────────── +// A blank / non-numeric field is null, NOT 0. Percentages arrive either as a +// whole number ("32.4") or a fraction ("0.324"); we normalize to a whole-number +// percent only when the source is clearly a fraction (0..1). +function numOrNull(raw) { + if (raw === null || raw === undefined) return null; + const s = String(raw).trim().replace('%', ''); + if (s === '' || s === 'NA' || s === 'null') return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +} +function pctOrNull(raw) { + const n = numOrNull(raw); + if (n === null) return null; + // Savant returns whole-number percents ("32.4"); a value in (0,1] is a + // fraction we scale up. Never invent a value where none exists. + return n > 0 && n <= 1 ? Math.round(n * 1000) / 10 : n; +} + +// ── Minimal quote-aware CSV parser (NO dependency) ────────────────────────── +// Savant's first column header is literally `"last_name, first_name"` (a quoted +// field containing a comma), so a naive split is wrong — we must honor quotes. +function parseCsvLine(line) { + const out = []; + let cur = ''; + let inQ = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQ) { + if (ch === '"') { + if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; + } else cur += ch; + } else if (ch === '"') { + inQ = true; + } else if (ch === ',') { + out.push(cur); cur = ''; + } else cur += ch; + } + out.push(cur); + return out; +} +function parseCsv(text) { + if (typeof text !== 'string' || text.trim() === '') return []; + const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.length > 0); + if (rows.length < 2) return []; + const header = parseCsvLine(rows[0]).map((h) => h.trim()); + const objs = []; + for (let r = 1; r < rows.length; r++) { + const cols = parseCsvLine(rows[r]); + if (cols.length === 0) continue; + const obj = {}; + for (let c = 0; c < header.length; c++) obj[header[c]] = cols[c] !== undefined ? cols[c].trim() : ''; + objs.push(obj); + } + return objs; +} + +// A row must carry a pitcher id + a pitch_type to be usable. Unknown shape → []. +function idOf(row) { + return row.player_id ?? row.pitcher ?? row.playerId ?? row.mlbam_id ?? null; +} + +// ── League-wide fetch + index (cached 24h, indexed by pitcher id) ─────────── +async function fetchText(url, opts) { + if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url); + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, responseType: 'text' }); + return typeof res.data === 'string' ? res.data : null; +} + +// stats leaderboard → { [pitcherId]: [ { pitch_type, pitch_name, pitch_usage, whiff_percent, k_percent } ] } +async function loadStatsIndex(year, opts) { + const cacheGet = opts.cacheGet || redisGet; + const cacheSet = opts.cacheSet || redisSet; + const key = `savant:arsenal-stats:${year}`; + let csv = opts.statsCsv; + if (csv === undefined) { + const cached = await cacheGet(key).catch(() => null); + if (cached && typeof cached === 'object') return cached; // already indexed + csv = await fetchText(statsUrl(year), opts).catch(() => null); + } + const rows = parseCsv(csv); + if (rows.length === 0) return null; // unrecognized / empty → caller decides + const index = {}; + let usable = 0; + for (const row of rows) { + const id = idOf(row); + const type = row.pitch_type != null ? String(row.pitch_type).trim() : ''; + if (id == null || String(id).trim() === '' || type === '') continue; + const key2 = String(id).trim(); + (index[key2] = index[key2] || []).push({ + type, + name: row.pitch_name ? String(row.pitch_name).trim() : type, + usagePct: pctOrNull(row.pitch_usage), + whiffPct: pctOrNull(row.whiff_percent), + kPct: pctOrNull(row.k_percent), + }); + usable++; + } + if (usable === 0) return null; // header present but no id/pitch columns → unknown shape + if (opts.statsCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {}); + return index; +} + +// velo leaderboard (WIDE) → { [pitcherId]: { ff: 99.1, sl: 87.0, ... } } (lowercased abbr → velo) +async function loadVeloIndex(year, opts) { + const cacheGet = opts.cacheGet || redisGet; + const key = `savant:arsenal-velo:${year}`; + let csv = opts.veloCsv; + if (csv === undefined) { + const cached = await cacheGet(key).catch(() => null); + if (cached && typeof cached === 'object') return cached; + csv = await fetchText(veloUrl(year), opts).catch(() => null); + } + const rows = parseCsv(csv); + if (rows.length === 0) return {}; // velo is OPTIONAL — absent index is fine + const index = {}; + for (const row of rows) { + const id = idOf(row); + if (id == null || String(id).trim() === '') continue; + const speeds = {}; + for (const col of Object.keys(row)) { + const m = /^([a-z]{1,3})_avg_speed$/i.exec(col); + if (!m) continue; + const v = numOrNull(row[col]); + if (v !== null) speeds[m[1].toLowerCase()] = v; + } + if (Object.keys(speeds).length) index[String(id).trim()] = speeds; + } + if (opts.veloCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {}); + return index; +} + +/** + * Resolve a pitcher's arsenal by MLBAM id (preferred) or name. + * @returns {Promise<{found:boolean, playerId?:(number|string), pitches?:Array, source?:string}>} + * pitches: [{ type, name, usagePct, velo|null, whiffPct|null, kPct|null }], ranked by usage desc. + * Always graceful — any miss / unrecognized shape → { found:false }. + */ +async function getPitcherArsenal(idOrName, opts = {}) { + try { + const year = opts.year || DEFAULT_SEASON; + + // Resolve name → MLBAM id when we weren't given a numeric id. + let id = idOrName; + const isNumericId = id != null && /^\d+$/.test(String(id).trim()); + if (!isNumericId) { + const name = String(idOrName || '').trim(); + if (!name) return { found: false }; + const resolveId = opts.resolveId || (async (n) => { + const mlb = opts.mlbAdapter || require('./mlbStatsAdapter'); + const person = await mlb.searchPlayer(n).catch(() => null); + return person && person.id != null ? person.id : null; + }); + id = await resolveId(name); + if (id == null) return { found: false }; + } + const key = String(id).trim(); + + // Merged per-pitcher cache (Redis + in-memory mirror), only for the live path + // (injected CSV fixtures skip the cache so tests are deterministic). + const usingFixtures = opts.statsCsv !== undefined || opts.veloCsv !== undefined; + const memKey = `savant:arsenal:${key}:${year}`; + if (!usingFixtures) { + const m = memGet(memKey); + if (m) return m; + const cached = await (opts.cacheGet || redisGet)(memKey).catch(() => null); + if (cached && typeof cached === 'object') { memSet(memKey, cached, ARSENAL_TTL); return cached; } + } + + const statsIndex = await loadStatsIndex(year, opts); + if (!statsIndex) return { found: false }; // unrecognized / empty stats feed + const rows = statsIndex[key]; + if (!Array.isArray(rows) || rows.length === 0) return { found: false }; + + // Velo is best-effort enrichment; a failure leaves velo absent (null). + // In fixture mode (statsCsv injected) with NO veloCsv, DON'T hit the network — + // tests stay hermetic and velo is honestly absent. + let veloIndex = {}; + if (opts.veloCsv !== undefined || !usingFixtures) { + try { veloIndex = await loadVeloIndex(year, opts); } catch { veloIndex = {}; } + } + const speeds = (veloIndex && veloIndex[key]) || {}; + + const pitches = rows + .map((p) => ({ + type: p.type, + name: p.name, + usagePct: p.usagePct, + velo: (speeds[p.type.toLowerCase()] !== undefined ? speeds[p.type.toLowerCase()] : null), + whiffPct: p.whiffPct, + kPct: p.kPct, + })) + // rank by usage desc; a null usage sorts last (never fabricated to 0) + .sort((a, b) => (b.usagePct ?? -1) - (a.usagePct ?? -1)) + .slice(0, TOP_PITCHES); + + const result = { found: true, playerId: /^\d+$/.test(key) ? Number(key) : key, pitches, source: 'baseball_savant' }; + if (!usingFixtures) { + memSet(memKey, result, ARSENAL_TTL); + await (opts.cacheSet || redisSet)(memKey, result, ARSENAL_TTL).catch(() => {}); + } + return result; + } catch (err) { + console.warn('[savant] getPitcherArsenal failed:', idOrName, err && err.message); + return { found: false }; + } +} + +module.exports = { + getPitcherArsenal, + __internals: { + parseCsv, parseCsvLine, numOrNull, pctOrNull, idOf, + loadStatsIndex, loadVeloIndex, statsUrl, veloUrl, + DEFAULT_SEASON, ARSENAL_TTL, TOP_PITCHES, _mem, + }, +}; diff --git a/tests/unit/pitcherArsenalComponent.test.js b/tests/unit/pitcherArsenalComponent.test.js new file mode 100644 index 0000000..d2e2607 --- /dev/null +++ b/tests/unit/pitcherArsenalComponent.test.js @@ -0,0 +1,67 @@ +'use strict'; + +// Wave 5B — PitcherArsenal component source locks. The card is CONTEXT (the +// arsenal read), never a graded market value. Honesty invariants: +// • SELF-HIDES (returns null) when Savant has no arsenal — no empty shell. +// • ALL pitch data (velo / usage / whiff) is mono + tabular (brand rule). +// • A missing velo/whiff renders "—" (absent), NEVER 0. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +describe('PitcherArsenal component', () => { + const src = read('components/vyndr/PitcherArsenal.tsx'); + + test('self-hides (returns null) when there are no pitches', () => { + expect(src).toMatch(/if\s*\(\s*pitches\.length\s*===\s*0\s*\)\s*return null/); + }); + + test('renders the arsenal strip headers (VELO / USE / WHIFF)', () => { + expect(src).toContain('VELO'); + expect(src).toContain('USE'); + expect(src).toContain('WHIFF'); + expect(src).toContain('PITCH'); + }); + + test('pitch data is mono + tabular (brand rule: data is mono)', () => { + expect(src).toContain('className="mono"'); + expect(src).toContain('tabular-nums'); + // the three data cells map the normalized fields + expect(src).toContain('fmtVelo(p.velo)'); + expect(src).toContain('fmtPct(p.usagePct)'); + expect(src).toContain('fmtPct(p.whiffPct)'); + }); + + test('absent velo/whiff renders an em-dash, never 0 (absent beats zero)', () => { + // fmtVelo / fmtPct return '—' for null|undefined + expect(src).toMatch(/function fmtVelo[\s\S]*?===\s*null[\s\S]*?['"]—['"]/); + expect(src).toMatch(/function fmtPct[\s\S]*?===\s*null[\s\S]*?['"]—['"]/); + // no `?? 0` / `|| 0` coercion on the numeric fields + expect(src).not.toMatch(/velo\s*(\?\?|\|\|)\s*0/); + expect(src).not.toMatch(/whiffPct\s*(\?\?|\|\|)\s*0/); + }); + + test('cites Baseball Savant as the source (honest provenance)', () => { + expect(src).toMatch(/BASEBALL SAVANT/i); + }); + + test('ranks the strip by the normalized fields from the adapter contract', () => { + // consumes { found, pitches:[{ type, name, usagePct, velo, whiffPct }] } + expect(src).toContain('fetched.found'); + expect(src).toContain('fetched.pitches'); + }); + + test('is exported from the vyndr barrel', () => { + const barrel = read('components/vyndr/index.ts'); + expect(barrel).toContain("export { default as PitcherArsenal }"); + }); + + test('the arsenal proxy route exists (S25 rule) and forwards to the backend', () => { + const proxy = read('app/api/stats/pitcher/[name]/arsenal/route.ts'); + expect(proxy).toContain('/api/stats/pitcher/'); + expect(proxy).toContain('/arsenal'); + expect(proxy).toContain('found: false'); // honest fallback, not an error card + }); +}); diff --git a/tests/unit/savantAdapter.test.js b/tests/unit/savantAdapter.test.js new file mode 100644 index 0000000..8e82613 --- /dev/null +++ b/tests/unit/savantAdapter.test.js @@ -0,0 +1,100 @@ +'use strict'; + +const savant = require('../../src/services/adapters/savantAdapter'); + +// Fixture CSVs shaped like the real Baseball Savant leaderboard exports. +// NOTE the first header column is a QUOTED field containing a comma +// (`"last_name, first_name"`) — the parser must be quote-aware. +const STATS_CSV = [ + '"last_name, first_name",player_id,team_name_alt,pitch_type,pitch_name,run_value_per_100,pitches,pitch_usage,pa,whiff_percent,k_percent', + '"Skenes, Paul",694973,PIT,FF,4-Seam Fastball,1.2,500,32.0,300,26.0,30.0', + '"Skenes, Paul",694973,PIT,SL,Slider,2.1,350,22.0,200,41.0,38.0', + '"Skenes, Paul",694973,PIT,FS,Splitter,1.8,300,24.0,180,38.0,",', // trailing malformed cell → whiff null-ish; still parses + '"Skenes, Paul",694973,PIT,CU,Curveball,0.5,120,14.0,90,33.0,20.0', + '"Other, Guy",111111,LAD,CH,Changeup,0.1,50,,40,,', // usage/whiff BLANK → must be null, never 0 +].join('\n'); + +// WIDE velo leaderboard — one row per pitcher, `{abbr}_avg_speed` columns. +const VELO_CSV = [ + '"last_name, first_name",pitcher,team,ff_avg_speed,sl_avg_speed,fs_avg_speed,cu_avg_speed', + '"Skenes, Paul",694973,PIT,99.1,87.0,94.2,82.5', +].join('\n'); + +describe('savantAdapter.getPitcherArsenal', () => { + test('normalizes a fake Savant CSV payload into a ranked arsenal (usage desc)', async () => { + const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV, veloCsv: VELO_CSV }); + expect(out.found).toBe(true); + expect(out.playerId).toBe(694973); + expect(out.source).toBe('baseball_savant'); + expect(Array.isArray(out.pitches)).toBe(true); + // ranked by usage: FF(32) > FS(24) > SL(22) > CU(14) + expect(out.pitches.map((p) => p.type)).toEqual(['FF', 'FS', 'SL', 'CU']); + const ff = out.pitches[0]; + expect(ff.usagePct).toBe(32.0); + expect(ff.velo).toBe(99.1); // merged from the WIDE velo CSV by pitch abbr + expect(ff.whiffPct).toBe(26.0); + const sl = out.pitches.find((p) => p.type === 'SL'); + expect(sl.velo).toBe(87.0); + expect(sl.whiffPct).toBe(41.0); + }); + + test('missing velo is ABSENT (null), never 0 — velo CSV omitted entirely', async () => { + const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV /* no veloCsv */ }); + expect(out.found).toBe(true); + for (const p of out.pitches) expect(p.velo).toBeNull(); + }); + + test('blank whiff/usage cells parse to null, not 0 (absent beats zero)', async () => { + const out = await savant.getPitcherArsenal(111111, { statsCsv: STATS_CSV }); + expect(out.found).toBe(true); + expect(out.pitches).toHaveLength(1); + expect(out.pitches[0].usagePct).toBeNull(); + expect(out.pitches[0].whiffPct).toBeNull(); + expect(out.pitches[0].velo).toBeNull(); + }); + + test('unrecognized shape → { found:false } (defensive parsing)', async () => { + const junk = 'totally,unrelated,columns\n1,2,3'; + const out = await savant.getPitcherArsenal(694973, { statsCsv: junk }); + expect(out.found).toBe(false); + const empty = await savant.getPitcherArsenal(694973, { statsCsv: '' }); + expect(empty.found).toBe(false); + }); + + test('a pitcher with no rows in the feed → { found:false }', async () => { + const out = await savant.getPitcherArsenal(999999, { statsCsv: STATS_CSV }); + expect(out.found).toBe(false); + }); + + test('resolves a NAME → id via injected resolver, then returns arsenal', async () => { + const out = await savant.getPitcherArsenal('Paul Skenes', { + statsCsv: STATS_CSV, + veloCsv: VELO_CSV, + resolveId: async (n) => (/skenes/i.test(n) ? 694973 : null), + }); + expect(out.found).toBe(true); + expect(out.playerId).toBe(694973); + }); + + test('unresolvable name / empty input → { found:false } (never throws)', async () => { + const noId = await savant.getPitcherArsenal('Nobody Here', { statsCsv: STATS_CSV, resolveId: async () => null }); + expect(noId.found).toBe(false); + const blank = await savant.getPitcherArsenal('', { statsCsv: STATS_CSV }); + expect(blank.found).toBe(false); + }); + + test('quote-aware CSV parser keeps a comma inside a quoted field', () => { + const rows = savant.__internals.parseCsv('"last, first",id\n"Skenes, Paul",694973'); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('694973'); + expect(rows[0]['last, first']).toBe('Skenes, Paul'); + }); + + test('pctOrNull scales a fraction but leaves whole percents alone; null stays null', () => { + const { pctOrNull } = savant.__internals; + expect(pctOrNull('0.324')).toBe(32.4); + expect(pctOrNull('32.4')).toBe(32.4); + expect(pctOrNull('')).toBeNull(); + expect(pctOrNull(null)).toBeNull(); + }); +}); diff --git a/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts b/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts new file mode 100644 index 0000000..54c9c25 --- /dev/null +++ b/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * Pitcher arsenal proxy (Wave 5B). Forwards GET + * /api/stats/pitcher/:name/arsenal to the Express stats route (Baseball Savant + * pitch mix / velo / whiff). Thin pass-through; preserves ?sport=. On any + * upstream failure it returns { found:false } so the PitcherArsenal card + * self-hides honestly rather than showing an error. + */ +export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) { + const { name } = await ctx.params; + const qs = req.nextUrl.search; + try { + const upstream = await fetch( + `${BACKEND_URL}/api/stats/pitcher/${encodeURIComponent(name)}/arsenal${qs}`, + { method: 'GET', headers: { Accept: 'application/json' } }, + ); + const data = await upstream.json().catch(() => ({ found: false })); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ found: false }, { status: 200 }); + } +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index aa810e9..9c73df8 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1333,6 +1333,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; } /* Terminal's multi-column grid stacks on mobile. */ @media (max-width: 768px) { .terminal-grid { grid-template-columns: 1fr !important; } + /* Pitcher-identity (Wave 5B): identity + arsenal columns stack; the arsenal + table keeps its own internal grid. */ + .parsenal-grid { grid-template-columns: 1fr !important; } } /* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */ diff --git a/web/src/app/player/[name]/page.tsx b/web/src/app/player/[name]/page.tsx index 6db4cb6..9ebffb6 100644 --- a/web/src/app/player/[name]/page.tsx +++ b/web/src/app/player/[name]/page.tsx @@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ModelRecord from '@/components/vyndr/ModelRecord'; import PlayerStreaks from '@/components/vyndr/PlayerStreaks'; +import PitcherArsenal from '@/components/vyndr/PitcherArsenal'; import { archetypeInfo } from '@/lib/archetypes'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; @@ -201,6 +202,13 @@ export default function PlayerProfilePage() { )} + {/* C2. PITCHER IDENTITY (Wave 5B) — Baseball Savant arsenal lens. MLB only; + the component (heading included) SELF-HIDES for non-pitchers / when + Savant has no arsenal. Context, not a graded market value. */} + {p.sport === 'mlb' && ( + + )} + {/* D. ACTIVE PROPS */} {p.activeProps?.length > 0 && ( <> diff --git a/web/src/components/vyndr/PitcherArsenal.tsx b/web/src/components/vyndr/PitcherArsenal.tsx new file mode 100644 index 0000000..df61036 --- /dev/null +++ b/web/src/components/vyndr/PitcherArsenal.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * PitcherArsenal (Wave 5B) — the mockup's PITCHER IDENTITY lens: pitch mix % + + * velo + whiff%, mono/tabular, ranked by usage. Fed by the Baseball Savant + * arsenal adapter (FREE Statcast). "ONE IDENTITY → MANY PROPS". + * + * HONESTY: arsenal is CONTEXT (the read), never a graded market value. The card + * SELF-HIDES (returns null) when Savant has no arsenal for the pitcher — no empty + * box, no fabricated numbers. A missing velo/whiff renders as "—" (absent), NOT 0. + * + * Two modes: + * • pass `arsenal` (already fetched by a parent) — renders synchronously. + * • pass `name` (+ optional pitcher meta) — fetches /api/stats/pitcher/:name/arsenal. + */ + +export interface ArsenalPitch { + type: string; // FF / SL / FS / CU … + name: string; // "4-Seam Fastball" + usagePct: number | null; + velo: number | null; + whiffPct: number | null; + kPct?: number | null; +} +export interface PitcherArsenalData { + found: boolean; + playerId?: number | string; + pitches?: ArsenalPitch[]; +} +export interface PitcherMeta { + name?: string; + hand?: string; // RHP / LHP + number?: string | number; // jersey + team?: string; + vs?: string; // opponent abbr + confirmed?: boolean; // probable-pitcher confirmed +} +interface Props { + arsenal?: PitcherArsenalData | null; + name?: string; // fetch by name when arsenal not supplied + sport?: string; // default 'mlb' + pitcher?: PitcherMeta; // optional identity header + heading?: boolean; // render the "PITCHER IDENTITY" label above the card +} + +// Pitch color dots (Statcast-flavored, matching the mockup). Data chrome — no glitch. +const PITCH_COLOR: Record = { + FF: '#FF7A5A', FA: '#FF7A5A', // 4-seam / fastball + SI: '#E0803D', FT: '#E0803D', FS: '#E0803D', FO: '#E0803D', // sinker / two-seam / splitter + FC: '#E8A33D', // cutter + SL: '#7C5CFF', ST: '#7C5CFF', SV: '#9B7CFF', // slider / sweeper / slurve + CU: '#6C9CB0', KC: '#6C9CB0', CS: '#6C9CB0', // curveballs + CH: '#4FB0A0', SC: '#4FB0A0', // change / screw + KN: '#8888A0', EP: '#8888A0', // knuckle / eephus +}; +function pitchColor(type: string) { + return PITCH_COLOR[String(type || '').toUpperCase()] || '#6C9CB0'; +} + +// Absent beats zero — a null velo/whiff/usage renders as an em-dash, never 0. +function fmtVelo(v: number | null | undefined) { + return v === null || v === undefined ? '—' : v.toFixed(1); +} +function fmtPct(v: number | null | undefined) { + return v === null || v === undefined ? '—' : `${Math.round(v)}%`; +} + +const COL = '1fr 56px 46px 52px'; + +export default function PitcherArsenal({ arsenal, name, sport = 'mlb', pitcher, heading }: Props) { + const [fetched, setFetched] = useState(arsenal ?? null); + + useEffect(() => { + if (arsenal !== undefined && arsenal !== null) { setFetched(arsenal); return; } + if (!name) return; + let alive = true; + fetch(`/api/stats/pitcher/${encodeURIComponent(name)}/arsenal?sport=${encodeURIComponent(sport)}`, { + headers: { Accept: 'application/json' }, + }) + .then((r) => r.json()) + .then((d) => { if (alive) setFetched(d && typeof d === 'object' ? d : null); }) + .catch(() => { if (alive) setFetched(null); }); + return () => { alive = false; }; + }, [arsenal, name, sport]); + + const pitches = fetched && fetched.found && Array.isArray(fetched.pitches) ? fetched.pitches : []; + // SELF-HIDE: no real arsenal → render nothing (never an empty shell). + if (pitches.length === 0) return null; + + // Highlight the sharpest whiff pitch(es) green — the "what misses bats" read. + const maxWhiff = Math.max(...pitches.map((p) => (p.whiffPct ?? -1))); + const isSharp = (p: ArsenalPitch) => p.whiffPct !== null && p.whiffPct !== undefined && p.whiffPct >= 30 && p.whiffPct >= maxWhiff - 3; + + const meta = pitcher || {}; + const monogram = (meta.team || (meta.name || '').slice(0, 3) || 'PIT').toString().slice(0, 3).toUpperCase(); + + const Card = ( +
+
+ {/* identity */} +
+
+
{monogram}
+
+
+ {meta.name || 'Probable Pitcher'} + {meta.confirmed && ( + CONFIRMED + )} +
+
+ {[meta.hand, meta.number != null ? `#${meta.number}` : null, meta.team, meta.vs ? `vs ${meta.vs}` : null].filter(Boolean).join(' · ') || 'ARSENAL'} +
+
+
+
+
THE ARSENAL READ
+

+ {(() => { + const top = pitches[0]; + const sharp = pitches.find(isSharp); + if (sharp && top && sharp.type !== top.type) { + return `${top.name} sets it up; the ${sharp.name.toLowerCase()} is the swing-and-miss pitch (${fmtPct(sharp.whiffPct)} whiff).`; + } + if (top) return `${top.name}-led mix — ${fmtPct(top.usagePct)} usage. One identity, many props.`; + return 'Pitch mix read. One identity, many props.'; + })()} +

+
+
+ + {/* arsenal table */} +
+
+ PITCH + VELO + USE + WHIFF +
+ {pitches.map((p, i) => { + const sharp = isSharp(p); + return ( +
+ + + {p.name} + + {fmtVelo(p.velo)} + {fmtPct(p.usagePct)} + {fmtPct(p.whiffPct)} +
+ ); + })} +
SOURCE · BASEBALL SAVANT (STATCAST)
+
+
+
+ ); + + if (!heading) return Card; + // Heading lives INSIDE the self-hide guard (past the early `return null`), so an + // absent arsenal drops the label too — never an orphan header. + return ( +
+
+ + PITCHER IDENTITY + ONE IDENTITY → MANY PROPS +
+ {Card} +
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 1dd53a3..f5975c2 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -27,6 +27,8 @@ export { default as ArchetypeBlend } from './ArchetypeBlend'; export type { BlendSegment } from './ArchetypeBlend'; export { default as StatStrip } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip'; +export { default as PitcherArsenal } from './PitcherArsenal'; +export type { PitcherArsenalData, ArsenalPitch, PitcherMeta } from './PitcherArsenal'; export { default as BookChip } from './BookChip'; /* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */