Session 46: Grade card intel + name normalization + pitchers (2122 tests)
Three focused P1 fixes on the Session-45 snapshot model.
- Grade card intel ROOT CAUSE: gameLogService is NBA/WNBA-only (offline Python),
so MLB props never got l5_avg/l20_avg and buildIntelFields returned {}. Wired
MLB game logs into featureCache.gameLogFeatures via mlbStatsAdapter.getPlayerStats
(pure mlbGameLogFeatures + MLB stat_type->field map). buildIntelFields gained
playerStats/projection fallbacks for partial intel.
- Player name normalization: src/utils/playerName.js (+ web/src/lib copy):
normalizeName -> {display,key}. Strips periods, de-dots suffix, accent-folds
the key. Applied in snapshotService grouping, slateAdapter grade index +
player-strip merge (variants collapse, longest name shown), and
playerIntelService. "A.J. Ewing"/"AJ Ewing" + "Jazz Chisholm"/"Jr." now merge.
- MLB starting pitchers: new GET /api/schedule/:sport/pitchers (probablePitchers
service wrapping mlbStatsAdapter.getScheduleWithPitchers + best-effort ERA).
Slate fetches it, builds a team->pitcher map (full name + mascot match),
attaches pitchers to MLB GameCardData. + Next proxy.
Backend 2100 -> 2122 tests (+22), 176 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,33 @@ inline with `GradeBadge`. `onPlayerClick` → `/player/:name?sport=` (`lib/playe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5b. Player name normalization (Session 46)
|
||||||
|
|
||||||
|
`src/utils/playerName.js` (+ identical `web/src/lib/playerName.js`) is the ONE
|
||||||
|
source of truth for comparing/deduping names. `normalizeName(raw)` →
|
||||||
|
`{ display, key }`: `display` strips periods + de-dots the suffix (keeps accents
|
||||||
|
+ casing); `key` is accent-folded, lowercased, suffix-stripped for comparison.
|
||||||
|
Used by snapshot grouping, `slateAdapter` (grade index + player strips), and
|
||||||
|
`playerIntelService` so "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz
|
||||||
|
Chisholm Jr." collapse to one player.
|
||||||
|
|
||||||
|
## 5c. MLB intel features (Session 46)
|
||||||
|
|
||||||
|
`buildIntelFields` (grade-card STAT CONTEXT + VYNDR INTELLIGENCE) reads
|
||||||
|
`l5_avg`/`l10_avg`/`l20_avg`/`opp_rank_stat`/`rest_days` from the feature vector.
|
||||||
|
MLB game logs are now wired into `featureCache.gameLogFeatures` via
|
||||||
|
`mlbStatsAdapter.getPlayerStats` (the old Python game-log path was NBA/WNBA-only,
|
||||||
|
so MLB props had no intel). `buildIntelFields(features, { playerStats, projection })`
|
||||||
|
also accepts fallbacks so partial intel still renders.
|
||||||
|
|
||||||
|
## 5d. MLB probable pitchers (Session 46)
|
||||||
|
|
||||||
|
`GET /api/schedule/:sport/pitchers` (MLB only) → `{ sport, date, games:
|
||||||
|
[{ home:{team,pitcher,era}, away:{...} }] }` from `probablePitchers` /
|
||||||
|
`mlbStatsAdapter.getScheduleWithPitchers` (the ESPN schedule lacks them). The
|
||||||
|
Slate builds a team→pitcher map (`slateAdapter.buildPitcherMap` /
|
||||||
|
`pitchersForGameTeams`) and attaches `pitchers` to MLB GameCardData.
|
||||||
|
|
||||||
## 6. Freshness & caching
|
## 6. Freshness & caching
|
||||||
|
|
||||||
- Schedule cache TTL ≤ 30 min (`scheduleService`). Frontend filters completed
|
- Schedule cache TTL ≤ 30 min (`scheduleService`). Frontend filters completed
|
||||||
|
|||||||
+39
-3
@@ -4,9 +4,45 @@
|
|||||||
2026-06-18
|
2026-06-18
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v45.0 — Snapshot pipeline + GameCard swap + live ticker. The product
|
SHIP BUILD v46.0 — Grade-card intel (MLB game logs) + player-name normalization
|
||||||
shifted: on-demand "Read" grading is RETIRED; a scheduled pipeline pre-grades the
|
+ MLB starting pitchers. Three focused P1 fixes on the Session-45 snapshot model.
|
||||||
slate, locks grades to the line, and the dashboard shows them already there.
|
|
||||||
|
## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES
|
||||||
|
|
||||||
|
Backend 2100 → **2122 tests** (+22), 176 suites. Web build clean (exit 0).
|
||||||
|
|
||||||
|
### Phase 1 — grade card intel (ROOT CAUSE)
|
||||||
|
The STAT CONTEXT + VYNDR INTELLIGENCE sections were empty for MLB because
|
||||||
|
`gameLogService.getGameLogs` is NBA/WNBA-only (offline Python service) — MLB
|
||||||
|
props NEVER got `l5_avg`/`l20_avg`, so `buildIntelFields` always returned `{}`.
|
||||||
|
FIX: `featureCache.gameLogFeatures` now has an MLB branch that derives
|
||||||
|
l5/l10/l20 averages from `mlbStatsAdapter.getPlayerStats` (free statsapi.mlb.com)
|
||||||
|
via the pure `mlbGameLogFeatures` + an MLB stat_type→game-log-field map.
|
||||||
|
`buildIntelFields(features, opts)` also gained `playerStats`/`projection`
|
||||||
|
fallbacks so partial intel renders (Sessions 43/44 had the wiring; the engine
|
||||||
|
just never produced the values for MLB).
|
||||||
|
|
||||||
|
### Phase 2 — player name normalization
|
||||||
|
`src/utils/playerName.js` + `web/src/lib/playerName.js` (identical; cross-checked
|
||||||
|
by a test): `normalizeName(raw)` → `{ display, key }`. Strips periods, de-dots
|
||||||
|
suffixes, accent-folds the key. Applied in `snapshotService` (grouping/deltas),
|
||||||
|
`slateAdapter` (grade index + `buildPlayerStripsFromProps` merges variants,
|
||||||
|
displays the longest), and `playerIntelService.sanitizePlayerName`/`normName`.
|
||||||
|
"A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." now merge.
|
||||||
|
|
||||||
|
### Phase 3 — MLB starting pitchers
|
||||||
|
The ESPN schedule lacks probable pitchers. NEW `GET /api/schedule/:sport/pitchers`
|
||||||
|
(MLB) → `probablePitchers` service wrapping `mlbStatsAdapter.getScheduleWithPitchers`
|
||||||
|
+ best-effort season ERA. The Slate fetches it, builds a team→pitcher map
|
||||||
|
(`slateAdapter.buildPitcherMap`/`pitchersForGameTeams`, matched by full name +
|
||||||
|
mascot), and attaches `pitchers` to MLB GameCardData. + Next proxy.
|
||||||
|
|
||||||
|
### Phase 4 — verify
|
||||||
|
`computeLineDeltas` confirmed structurally sound (S45 tests); `deltas: 0` in the
|
||||||
|
audit was just the first snapshot (no previous to diff). BACKEND_HANDOFF.md
|
||||||
|
updated.
|
||||||
|
|
||||||
|
## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE
|
||||||
|
|
||||||
## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE
|
## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,29 @@ snapshot, locked to the line, and read from cache.
|
|||||||
- **Env:** PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1,
|
- **Env:** PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1,
|
||||||
SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array).
|
SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array).
|
||||||
|
|
||||||
|
## Grade Intel + Name Norm + Pitchers (Session 46 — non-obvious)
|
||||||
|
- **Grade-card intel root cause:** `buildIntelFields` reads l5_avg/l20_avg/
|
||||||
|
opp_rank_stat/rest_days from the feature vector. `gameLogService.getGameLogs`
|
||||||
|
is NBA/WNBA-ONLY (offline Python service) → MLB props had NO recent/season
|
||||||
|
averages → intel always empty. FIX: `featureCache.gameLogFeatures` has an MLB
|
||||||
|
branch using `mlbStatsAdapter.getPlayerStats` + the pure `mlbGameLogFeatures`
|
||||||
|
(MLB stat_type→game-log field via `MLB_LOG_FIELD`). If you add an MLB stat
|
||||||
|
type, add it to `MLB_LOG_FIELD` or its intel won't compute. `buildIntelFields`
|
||||||
|
also takes `{ playerStats, projection }` fallbacks — don't remove the resilience.
|
||||||
|
- **Player name normalization:** `src/utils/playerName.js` is the source of truth
|
||||||
|
(frontend copy at `web/src/lib/playerName.js` — keep them identical; a test
|
||||||
|
cross-checks). `normalizeName(raw)→{display,key}`: display strips periods +
|
||||||
|
de-dots suffix (keeps accents); key accent-folds + suffix-strips for comparison.
|
||||||
|
Used in snapshotService grouping, slateAdapter grade-index + player-strip
|
||||||
|
merge, and playerIntelService. `sanitizePlayerName` now returns the de-dotted
|
||||||
|
display ("A.J. Ewing"→"AJ Ewing") — tests that asserted the dotted form were
|
||||||
|
updated.
|
||||||
|
- **MLB pitchers:** the ESPN `/api/schedule` has NO probable pitchers. They come
|
||||||
|
from `GET /api/schedule/:sport/pitchers` (MLB) → `probablePitchers` service →
|
||||||
|
`mlbStatsAdapter.getScheduleWithPitchers`. The Slate matches them to games by
|
||||||
|
team (full name OR mascot via `slateAdapter.buildPitcherMap`/`pitchersForGameTeams`).
|
||||||
|
ERA is best-effort (season stats per pitcher id, cached).
|
||||||
|
|
||||||
## Active Skills
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
|||||||
|
|
||||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
||||||
|
|
||||||
|
// GET /api/schedule/:sport/pitchers (Session 46) — today's MLB probable starters
|
||||||
|
// (statsapi.mlb.com; the ESPN schedule above doesn't carry them). MLB only.
|
||||||
|
router.get('/:sport/pitchers', async (req, res) => {
|
||||||
|
const sport = String(req.params.sport || '').toLowerCase();
|
||||||
|
if (sport !== 'mlb') {
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, date: null, games: [] });
|
||||||
|
}
|
||||||
|
const date = req.query.date || scheduleService.todayET();
|
||||||
|
try {
|
||||||
|
const { getProbablePitchers } = require('../services/probablePitchers');
|
||||||
|
const games = await getProbablePitchers(date);
|
||||||
|
res.set('Cache-Control', 'public, max-age=300');
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, date, games });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[schedule/pitchers]', err.message);
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, date, games: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:sport', async (req, res) => {
|
router.get('/:sport', async (req, res) => {
|
||||||
const sport = String(req.params.sport || '').toLowerCase();
|
const sport = String(req.params.sport || '').toLowerCase();
|
||||||
const date = req.query.date || scheduleService.todayET();
|
const date = req.query.date || scheduleService.todayET();
|
||||||
|
|||||||
@@ -296,18 +296,31 @@ function matchupGradeFromRank(rank) {
|
|||||||
* yield the fallback. The archetype strip lights up once the snapshot pipeline
|
* yield the fallback. The archetype strip lights up once the snapshot pipeline
|
||||||
* (Session 44) feeds per-player season lines into the grade response.
|
* (Session 44) feeds per-player season lines into the grade response.
|
||||||
*/
|
*/
|
||||||
function buildIntelFields(features = {}) {
|
const firstFinite = (...vals) => vals.find((v) => Number.isFinite(v));
|
||||||
|
|
||||||
|
function buildIntelFields(features = {}, opts = {}) {
|
||||||
const out = {};
|
const out = {};
|
||||||
const round1 = (n) => Math.round(n * 10) / 10;
|
const round1 = (n) => Math.round(n * 10) / 10;
|
||||||
if (Number.isFinite(features.l20_avg)) out.season_avg = round1(features.l20_avg);
|
// Resilience (Session 46): fall back to a caller-supplied playerStats bundle
|
||||||
else if (Number.isFinite(features.season_avg)) out.season_avg = round1(features.season_avg);
|
// and the model projection when the feature vector is sparse. Partial intel
|
||||||
if (Number.isFinite(features.l10_avg)) out.last10_avg = round1(features.l10_avg);
|
// beats none — we add only the fields we can actually back with a number.
|
||||||
else if (Number.isFinite(features.l5_avg)) out.last10_avg = round1(features.l5_avg);
|
const ps = opts.playerStats || {};
|
||||||
|
const proj = Number.isFinite(opts.projection) ? opts.projection : undefined;
|
||||||
|
|
||||||
const form = computeFormScore(features);
|
const seasonAvg = firstFinite(features.l20_avg, features.season_avg, ps.season_avg, proj);
|
||||||
|
if (seasonAvg != null) out.season_avg = round1(seasonAvg);
|
||||||
|
|
||||||
|
const last10 = firstFinite(features.l10_avg, features.l5_avg, ps.last10_avg);
|
||||||
|
if (last10 != null) out.last10_avg = round1(last10);
|
||||||
|
|
||||||
|
let form = computeFormScore(features);
|
||||||
|
if (form == null && Number.isFinite(ps.form)) form = Math.round(ps.form);
|
||||||
if (form != null) out.form = form;
|
if (form != null) out.form = form;
|
||||||
|
|
||||||
if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`;
|
if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`;
|
||||||
else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`;
|
else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`;
|
||||||
|
else if (ps.usage) out.usage = String(ps.usage);
|
||||||
|
|
||||||
const matchup = matchupGradeFromRank(features.opp_rank_stat);
|
const matchup = matchupGradeFromRank(features.opp_rank_stat);
|
||||||
if (matchup) out.matchup_grade = matchup;
|
if (matchup) out.matchup_grade = matchup;
|
||||||
if (Number.isFinite(features.rest_days)) out.rest = `${features.rest_days}d rest`;
|
if (Number.isFinite(features.rest_days)) out.rest = `${features.rest_days}d rest`;
|
||||||
|
|||||||
@@ -76,7 +76,66 @@ function daysBetween(aIso, bIso) {
|
|||||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MLB stat_type → the per-game field name in a statsapi.mlb.com game-log row.
|
||||||
|
const MLB_LOG_FIELD = {
|
||||||
|
total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi',
|
||||||
|
runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
|
||||||
|
strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
|
||||||
|
innings_pitched: 'inningsPitched',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mlbStatValue(statObj, statType) {
|
||||||
|
const f = MLB_LOG_FIELD[statType];
|
||||||
|
if (!f || !statObj) return null;
|
||||||
|
const n = parseFloat(statObj[f]);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session 46 — derive recent/season averages from a real MLB game log
|
||||||
|
* (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without
|
||||||
|
* the network. Produces l5_avg / l10_avg (recent form) + l20_avg (the season
|
||||||
|
* per-game reference) — exactly the fields buildIntelFields consumes, which the
|
||||||
|
* NBA/WNBA-only Python game-log path never populated for MLB.
|
||||||
|
*/
|
||||||
|
function mlbGameLogFeatures(res, statType) {
|
||||||
|
if (!res || !res.found) return {};
|
||||||
|
const out = {};
|
||||||
|
const logs = Array.isArray(res.last10) ? res.last10 : [];
|
||||||
|
const vals = logs.map((g) => mlbStatValue(g.stat, statType)).filter((v) => v != null);
|
||||||
|
if (vals.length) {
|
||||||
|
const m5 = avg(vals.slice(-5)); // game logs are chronological (recent last)
|
||||||
|
const m10 = avg(vals.slice(-10));
|
||||||
|
const s10 = stddev(vals.slice(-10));
|
||||||
|
if (m5 != null) out.l5_avg = m5;
|
||||||
|
if (m10 != null) out.l10_avg = m10;
|
||||||
|
if (s10 != null) out.l10_stddev = s10;
|
||||||
|
}
|
||||||
|
const seasonTotal = mlbStatValue(res.season, statType);
|
||||||
|
const games = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted ?? res.season.gamesPitched));
|
||||||
|
if (seasonTotal != null && Number.isFinite(games) && games > 0) {
|
||||||
|
out.l20_avg = seasonTotal / games;
|
||||||
|
} else if (out.l10_avg != null) {
|
||||||
|
out.l20_avg = out.l10_avg; // baseline so form has a reference
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function gameLogFeatures(playerName, sport, statType) {
|
async function gameLogFeatures(playerName, sport, statType) {
|
||||||
|
// MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python
|
||||||
|
// gameLogService only covers NBA/WNBA, so MLB props had no recent/season
|
||||||
|
// averages and the grade card's intel sections stayed empty.
|
||||||
|
if (sport === 'mlb') {
|
||||||
|
try {
|
||||||
|
const mlbStats = require('../adapters/mlbStatsAdapter');
|
||||||
|
const res = await mlbStats.getPlayerStats(playerName);
|
||||||
|
return mlbGameLogFeatures(res, statType);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[featureCache] MLB game-log features failed:', e.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
||||||
if (!logs || logs.length === 0) return {};
|
if (!logs || logs.length === 0) return {};
|
||||||
|
|
||||||
@@ -261,6 +320,8 @@ module.exports = {
|
|||||||
coachFeatures,
|
coachFeatures,
|
||||||
lineupFeatures,
|
lineupFeatures,
|
||||||
statFromGameLog,
|
statFromGameLog,
|
||||||
|
mlbGameLogFeatures,
|
||||||
|
mlbStatValue,
|
||||||
avg,
|
avg,
|
||||||
stddev,
|
stddev,
|
||||||
daysBetween,
|
daysBetween,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { classify } = require('./archetypeService');
|
const { classify } = require('./archetypeService');
|
||||||
|
const { normalizeName, nameKey } = require('../utils/playerName');
|
||||||
|
|
||||||
const toNum = (v) => {
|
const toNum = (v) => {
|
||||||
const n = parseFloat(v);
|
const n = parseFloat(v);
|
||||||
@@ -31,14 +32,18 @@ const fmt3 = (v) => {
|
|||||||
function sanitizePlayerName(raw) {
|
function sanitizePlayerName(raw) {
|
||||||
let decoded = String(raw == null ? '' : raw);
|
let decoded = String(raw == null ? '' : raw);
|
||||||
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
|
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
|
||||||
return decoded
|
const cleaned = decoded
|
||||||
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
|
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 60);
|
.slice(0, 60);
|
||||||
|
// Session 46 — normalize periods/suffix for display ("A.J. Ewing" → "AJ Ewing").
|
||||||
|
return normalizeName(cleaned).display;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
|
// Session 46 — match by the normalized name key so name variants resolve to the
|
||||||
|
// same player (snapshot grades, profile lookups).
|
||||||
|
const normName = (n) => nameKey(n);
|
||||||
|
|
||||||
async function loadPlayerGrades(sport, name, cacheGetFn) {
|
async function loadPlayerGrades(sport, name, cacheGetFn) {
|
||||||
const env = await cacheGetFn(`grades:${sport}`);
|
const env = await cacheGetFn(`grades:${sport}`);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* probablePitchers — today's MLB probable starters (Session 46).
|
||||||
|
*
|
||||||
|
* The ESPN schedule (`scheduleService`) doesn't carry probable pitchers, so MLB
|
||||||
|
* game cards never showed them. This wraps `mlbStatsAdapter.getScheduleWithPitchers`
|
||||||
|
* (FREE statsapi.mlb.com) and best-effort enriches each starter's season ERA.
|
||||||
|
* Everything is graceful + injectable. `shapePitcherGames` is pure/unit-tested.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mlbStats = require('./adapters/mlbStatsAdapter');
|
||||||
|
|
||||||
|
/** Map the adapter's schedule shape → a compact { home, away } pitcher record. */
|
||||||
|
function shapePitcherGames(scheduleGames) {
|
||||||
|
return (scheduleGames || [])
|
||||||
|
.map((g) => ({
|
||||||
|
home: { team: g.home?.team || null, pitcher: g.home?.probablePitcher?.name || null, pitcherId: g.home?.probablePitcher?.id || null, era: null },
|
||||||
|
away: { team: g.away?.team || null, pitcher: g.away?.probablePitcher?.name || null, pitcherId: g.away?.probablePitcher?.id || null, era: null },
|
||||||
|
}))
|
||||||
|
.filter((x) => x.home.pitcher || x.away.pitcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProbablePitchers(date, opts = {}) {
|
||||||
|
const adapter = opts.mlbAdapter || mlbStats;
|
||||||
|
let games;
|
||||||
|
try {
|
||||||
|
games = await adapter.getScheduleWithPitchers(date);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[probablePitchers] schedule fetch failed:', e.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const shaped = shapePitcherGames(games);
|
||||||
|
|
||||||
|
if (opts.withEra !== false) {
|
||||||
|
const eraLookup = opts.eraLookup || (async (id) => {
|
||||||
|
const s = await adapter.getSeasonAverages(id, undefined, 'pitching').catch(() => null);
|
||||||
|
const era = s && parseFloat(s.era);
|
||||||
|
return Number.isFinite(era) ? era : null;
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
shaped.flatMap((g) => [g.home, g.away]).map(async (side) => {
|
||||||
|
if (side.pitcherId) {
|
||||||
|
try { side.era = await eraLookup(side.pitcherId); } catch { side.era = null; }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return shaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getProbablePitchers, shapePitcherGames };
|
||||||
@@ -25,7 +25,10 @@ const DELTA_NOISE = 0.5; // ignore movements smaller than this
|
|||||||
const DELTA_MOVE = 1.0; // ticker MOVE threshold
|
const DELTA_MOVE = 1.0; // ticker MOVE threshold
|
||||||
const STATS_CONCURRENCY = 5;
|
const STATS_CONCURRENCY = 5;
|
||||||
|
|
||||||
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
const { nameKey } = require('../utils/playerName');
|
||||||
|
// Session 46 — group/dedupe by the normalized name key so "A.J. Ewing" and
|
||||||
|
// "AJ Ewing" (or "Jazz Chisholm" / "Jazz Chisholm Jr.") collapse to one player.
|
||||||
|
const norm = (s) => nameKey(s);
|
||||||
const lastName = (full) => {
|
const lastName = (full) => {
|
||||||
const parts = String(full || '').trim().split(/\s+/);
|
const parts = String(full || '').trim().split(/\s+/);
|
||||||
return parts.length > 1 ? parts[parts.length - 1] : (parts[0] || '');
|
return parts.length > 1 ? parts[parts.length - 1] : (parts[0] || '');
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player-name normalization (Session 46) — the ONE source of truth for comparing
|
||||||
|
* and de-duplicating player names. PropLine sends variants ("A.J. Ewing" vs
|
||||||
|
* "AJ Ewing", "Jazz Chisholm" vs "Jazz Chisholm Jr.") as different players; this
|
||||||
|
* collapses them.
|
||||||
|
*
|
||||||
|
* normalizeName("A.J. Ewing") → { display: "AJ Ewing", key: "aj ewing" }
|
||||||
|
* normalizeName("Jazz Chisholm Jr.")→ { display: "Jazz Chisholm Jr", key: "jazz chisholm" }
|
||||||
|
* normalizeName("Jazz Chisholm") → { display: "Jazz Chisholm", key: "jazz chisholm" }
|
||||||
|
* normalizeName("Ronald Acuña Jr.") → { display: "Ronald Acuña Jr", key: "ronald acuna" }
|
||||||
|
*
|
||||||
|
* `display` keeps proper casing + accents (periods stripped, suffix de-dotted).
|
||||||
|
* `key` is accent-folded, lowercased, suffix-stripped for comparison.
|
||||||
|
*
|
||||||
|
* NOTE: an identical copy lives at web/src/lib/playerName.js for the frontend
|
||||||
|
* (the Next bundle can't import from src/). A test cross-checks they agree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
|
||||||
|
|
||||||
|
function normalizeName(raw) {
|
||||||
|
const display = String(raw == null ? '' : raw)
|
||||||
|
.replace(/\./g, '') // "A.J." → "AJ", "Jr." → "Jr"
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
|
||||||
|
return { display, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comparison key only (the common case). */
|
||||||
|
function nameKey(raw) {
|
||||||
|
return normalizeName(raw).key;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeName, nameKey, SUFFIXES };
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Session 46 — Phase 1: MLB game-log features (root cause of the empty grade
|
||||||
|
// card intel) + buildIntelFields resilience.
|
||||||
|
|
||||||
|
const { __internals: fc } = require('../../src/services/intelligence/featureCache');
|
||||||
|
const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1');
|
||||||
|
|
||||||
|
const judgeStats = {
|
||||||
|
found: true, group: 'hitting',
|
||||||
|
season: { totalBases: 180, homeRuns: 34, hits: 95, rbi: 87, gamesPlayed: 92 },
|
||||||
|
last10: [
|
||||||
|
{ stat: { totalBases: 2, homeRuns: 0 } }, { stat: { totalBases: 4, homeRuns: 1 } },
|
||||||
|
{ stat: { totalBases: 1, homeRuns: 0 } }, { stat: { totalBases: 3, homeRuns: 1 } },
|
||||||
|
{ stat: { totalBases: 0, homeRuns: 0 } }, { stat: { totalBases: 5, homeRuns: 1 } },
|
||||||
|
{ stat: { totalBases: 2, homeRuns: 0 } }, { stat: { totalBases: 3, homeRuns: 1 } },
|
||||||
|
{ stat: { totalBases: 1, homeRuns: 0 } }, { stat: { totalBases: 4, homeRuns: 1 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mlbGameLogFeatures (root-cause fix)', () => {
|
||||||
|
it('derives l5/l10/l20 averages for an MLB stat from the real game log', () => {
|
||||||
|
const f = fc.mlbGameLogFeatures(judgeStats, 'total_bases');
|
||||||
|
expect(f.l5_avg).toBeGreaterThan(0);
|
||||||
|
expect(f.l10_avg).toBeGreaterThan(0);
|
||||||
|
// season per-game = 180 / 92 ≈ 1.96
|
||||||
|
expect(f.l20_avg).toBeCloseTo(180 / 92, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns {} for an unfound player or unmapped stat (graceful)', () => {
|
||||||
|
expect(fc.mlbGameLogFeatures({ found: false }, 'hits')).toEqual({});
|
||||||
|
expect(fc.mlbGameLogFeatures(judgeStats, 'not_a_stat')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mlbStatValue maps stat_type → MLB game-log field', () => {
|
||||||
|
expect(fc.mlbStatValue({ homeRuns: 2 }, 'home_runs')).toBe(2);
|
||||||
|
expect(fc.mlbStatValue({ totalBases: 3 }, 'total_bases')).toBe(3);
|
||||||
|
expect(fc.mlbStatValue({}, 'home_runs')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildIntelFields — feature-populated + resilient', () => {
|
||||||
|
it('produces season + last10 + form from MLB-derived features', () => {
|
||||||
|
const f = fc.mlbGameLogFeatures(judgeStats, 'total_bases');
|
||||||
|
const intel = eng.buildIntelFields(f);
|
||||||
|
expect(intel.season_avg).toBeDefined();
|
||||||
|
expect(intel.last10_avg).toBeDefined();
|
||||||
|
expect(intel.form).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to playerStats when the feature vector is empty', () => {
|
||||||
|
const intel = eng.buildIntelFields({}, { playerStats: { season_avg: 26.9, last10_avg: 28.4, form: 92, usage: '31%' } });
|
||||||
|
expect(intel.season_avg).toBe(26.9);
|
||||||
|
expect(intel.last10_avg).toBe(28.4);
|
||||||
|
expect(intel.form).toBe(92);
|
||||||
|
expect(intel.usage).toBe('31%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the model projection for season when nothing else exists', () => {
|
||||||
|
const intel = eng.buildIntelFields({}, { projection: 1.9 });
|
||||||
|
expect(intel.season_avg).toBe(1.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns {} when there is genuinely nothing (backward compatible)', () => {
|
||||||
|
expect(eng.buildIntelFields({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces partial output (matchup only) when only opp rank exists', () => {
|
||||||
|
const intel = eng.buildIntelFields({ opp_rank_stat: 0.8 });
|
||||||
|
expect(intel.matchup_grade).toBe('A');
|
||||||
|
expect(intel.season_avg).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,12 +9,12 @@ describe('sanitizePlayerName', () => {
|
|||||||
it('decodes URL encoding and keeps name punctuation', () => {
|
it('decodes URL encoding and keeps name punctuation', () => {
|
||||||
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
|
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
|
||||||
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
|
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
|
||||||
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr.');
|
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr'); // S46: suffix de-dotted
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips injection / control characters', () => {
|
it('strips injection / control characters', () => {
|
||||||
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
|
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
|
||||||
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('a....etcpasswd');
|
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('aetcpasswd'); // S46: periods stripped
|
||||||
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
|
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Session 46 — Phase 2: player name normalization.
|
||||||
|
|
||||||
|
const be = require('../../src/utils/playerName');
|
||||||
|
const fe = require('../../web/src/lib/playerName');
|
||||||
|
const slate = require('../../web/src/lib/slateAdapter');
|
||||||
|
const intel = require('../../src/services/playerIntelService');
|
||||||
|
|
||||||
|
describe('normalizeName', () => {
|
||||||
|
it('collapses period variants to one key', () => {
|
||||||
|
expect(be.nameKey('A.J. Ewing')).toBe(be.nameKey('AJ Ewing'));
|
||||||
|
expect(be.normalizeName('A.J. Ewing').display).toBe('AJ Ewing');
|
||||||
|
});
|
||||||
|
it('collapses suffix variants to one key', () => {
|
||||||
|
expect(be.nameKey('Jazz Chisholm Jr.')).toBe(be.nameKey('Jazz Chisholm'));
|
||||||
|
expect(be.normalizeName('Jazz Chisholm Jr.').display).toBe('Jazz Chisholm Jr');
|
||||||
|
});
|
||||||
|
it('keeps the accent in the display but folds it in the key', () => {
|
||||||
|
const r = be.normalizeName('Ronald Acuña Jr.');
|
||||||
|
expect(r.display).toBe('Ronald Acuña Jr');
|
||||||
|
expect(r.key).toBe('ronald acuna');
|
||||||
|
expect(be.nameKey('Ronald Acuna')).toBe(r.key);
|
||||||
|
});
|
||||||
|
it('backend + frontend copies agree', () => {
|
||||||
|
for (const n of ['A.J. Ewing', 'Jazz Chisholm Jr.', 'Ronald Acuña Jr.', 'Shohei Ohtani']) {
|
||||||
|
expect(fe.nameKey(n)).toBe(be.nameKey(n));
|
||||||
|
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizePlayerName normalizes periods/suffix', () => {
|
||||||
|
it('strips periods for display', () => {
|
||||||
|
expect(intel.sanitizePlayerName('A.J. Ewing')).toBe('AJ Ewing');
|
||||||
|
expect(intel.sanitizePlayerName('Jazz%20Chisholm%20Jr.')).toBe('Jazz Chisholm Jr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPlayerStripsFromProps merges name variants', () => {
|
||||||
|
it('merges "A.J. Ewing" + "AJ Ewing" into one strip', () => {
|
||||||
|
const strips = slate.buildPlayerStripsFromProps(
|
||||||
|
[
|
||||||
|
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5 },
|
||||||
|
{ player: 'AJ Ewing', stat_type: 'total_bases', line: 1.5 },
|
||||||
|
],
|
||||||
|
{}, {},
|
||||||
|
);
|
||||||
|
expect(strips).toHaveLength(1);
|
||||||
|
expect(strips[0].props).toHaveLength(2);
|
||||||
|
});
|
||||||
|
it('displays the longer name variant', () => {
|
||||||
|
const strips = slate.buildPlayerStripsFromProps(
|
||||||
|
[
|
||||||
|
{ player: 'Jazz Chisholm', stat_type: 'hits', line: 1.5 },
|
||||||
|
{ player: 'Jazz Chisholm Jr', stat_type: 'runs', line: 0.5 },
|
||||||
|
],
|
||||||
|
{}, {},
|
||||||
|
);
|
||||||
|
expect(strips).toHaveLength(1);
|
||||||
|
expect(strips[0].player).toBe('Jazz Chisholm Jr');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Session 46 — Phase 3: MLB starting pitchers.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const svc = require('../../src/services/probablePitchers');
|
||||||
|
const slate = require('../../web/src/lib/slateAdapter');
|
||||||
|
|
||||||
|
const scheduleGames = [
|
||||||
|
{ home: { team: 'Philadelphia Phillies', probablePitcher: { id: 1, name: 'Zack Wheeler' } }, away: { team: 'Atlanta Braves', probablePitcher: { id: 2, name: 'Spencer Strider' } } },
|
||||||
|
{ home: { team: 'New York Yankees', probablePitcher: null }, away: { team: 'Boston Red Sox', probablePitcher: { id: 3, name: 'Brayan Bello' } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('shapePitcherGames', () => {
|
||||||
|
it('extracts pitcher names + team, drops games with no probables', () => {
|
||||||
|
const shaped = svc.shapePitcherGames(scheduleGames);
|
||||||
|
expect(shaped).toHaveLength(2);
|
||||||
|
expect(shaped[0].away.pitcher).toBe('Spencer Strider');
|
||||||
|
expect(shaped[0].home.pitcher).toBe('Zack Wheeler');
|
||||||
|
expect(svc.shapePitcherGames([{ home: {}, away: {} }])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProbablePitchers (injected adapter)', () => {
|
||||||
|
it('returns shaped games with best-effort ERA', async () => {
|
||||||
|
const games = await svc.getProbablePitchers('2026-06-18', {
|
||||||
|
mlbAdapter: { getScheduleWithPitchers: async () => scheduleGames },
|
||||||
|
eraLookup: async (id) => (id === 1 ? 2.89 : id === 2 ? 3.21 : null),
|
||||||
|
});
|
||||||
|
const phi = games[0];
|
||||||
|
expect(phi.home.era).toBe(2.89);
|
||||||
|
expect(phi.away.era).toBe(3.21);
|
||||||
|
});
|
||||||
|
it('degrades to [] when the adapter throws', async () => {
|
||||||
|
const games = await svc.getProbablePitchers('x', { mlbAdapter: { getScheduleWithPitchers: async () => { throw new Error('down'); } } });
|
||||||
|
expect(games).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slateAdapter pitcher mapping', () => {
|
||||||
|
const shaped = svc.shapePitcherGames(scheduleGames).map((g) => ({
|
||||||
|
home: { ...g.home, era: 2.89 }, away: { ...g.away, era: 3.21 },
|
||||||
|
}));
|
||||||
|
it('builds a team→pitcher map and resolves a game by team name', () => {
|
||||||
|
const map = slate.buildPitcherMap(shaped);
|
||||||
|
const p = slate.pitchersForGameTeams('Atlanta Braves', 'Philadelphia Phillies', map);
|
||||||
|
expect(p.away.name).toBe('Spencer Strider');
|
||||||
|
expect(p.away.era).toBe('3.21');
|
||||||
|
expect(p.home.name).toBe('Zack Wheeler');
|
||||||
|
});
|
||||||
|
it('matches by mascot when the full name differs slightly', () => {
|
||||||
|
const map = slate.buildPitcherMap(shaped);
|
||||||
|
const p = slate.pitchersForGameTeams('Braves', 'Phillies', map);
|
||||||
|
expect(p.away.name).toBe('Spencer Strider');
|
||||||
|
});
|
||||||
|
it('returns undefined when no pitchers match', () => {
|
||||||
|
expect(slate.pitchersForGameTeams('Dodgers', 'Giants', slate.buildPitcherMap(shaped))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Slate wires MLB pitchers', () => {
|
||||||
|
const src = fs.readFileSync(path.join(WEB, 'components', 'Slate.tsx'), 'utf8');
|
||||||
|
it('fetches /api/schedule/mlb/pitchers and attaches pitchers for MLB', () => {
|
||||||
|
expect(src).toContain('/api/schedule/mlb/pitchers');
|
||||||
|
expect(src).toContain('buildPitcherMap');
|
||||||
|
expect(src).toContain('pitchersForGameTeams');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,9 +15,9 @@ describe('snapshot overlay adapter', () => {
|
|||||||
|
|
||||||
it('indexes grades + deltas for lookup', () => {
|
it('indexes grades + deltas for lookup', () => {
|
||||||
const gi = a.indexGrades(grades);
|
const gi = a.indexGrades(grades);
|
||||||
expect(gi['aaronjudge|total_bases'].grade).toBe('A+');
|
expect(gi['aaron judge|total_bases'].grade).toBe('A+');
|
||||||
const di = a.indexDeltas(deltas);
|
const di = a.indexDeltas(deltas);
|
||||||
expect(di['aaronjudge|total_bases|O'].direction).toBe('toward');
|
expect(di['aaron judge|total_bases|O'].direction).toBe('toward');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overlays grades onto game props → playerStrips (name once, archetype, gradedAt, delta)', () => {
|
it('overlays grades onto game props → playerStrips (name once, archetype, gradedAt, delta)', () => {
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MLB probable-pitchers proxy (Session 46) — forwards
|
||||||
|
* GET /api/schedule/:sport/pitchers to Express (statsapi.mlb.com starters).
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
|
||||||
|
const { sport } = await params;
|
||||||
|
const sportLc = String(sport || '').toLowerCase();
|
||||||
|
const qs = req.nextUrl.search;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(sportLc)}/pitchers${qs}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({ games: [] }));
|
||||||
|
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ sport: sportLc, games: [] }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
import { PropRowProp, Tier } from '@/components/PropRow';
|
||||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime } from '@/lib/slateAdapter';
|
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
// Session 23 — all-day intelligence layer. The stat filter is the
|
// Session 23 — all-day intelligence layer. The stat filter is the
|
||||||
// navigation system; streaks + hot lists layer ON TOP of the odds the
|
// navigation system; streaks + hot lists layer ON TOP of the odds the
|
||||||
@@ -165,11 +165,17 @@ interface SnapshotGrade { player?: string; player_name?: string; stat_type?: str
|
|||||||
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
|
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
|
||||||
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
|
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
|
||||||
|
|
||||||
|
// Session 46 — MLB probable pitchers response.
|
||||||
|
interface PitcherSide { team?: string | null; pitcher?: string | null; era?: number | null }
|
||||||
|
interface PitcherGame { home?: PitcherSide; away?: PitcherSide }
|
||||||
|
interface PitcherResponse { games?: PitcherGame[] }
|
||||||
|
|
||||||
// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
|
// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
|
||||||
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
|
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
|
||||||
type GradeIndex = ReturnType<typeof indexGrades>;
|
type GradeIndex = ReturnType<typeof indexGrades>;
|
||||||
type DeltaIndex = ReturnType<typeof indexDeltas>;
|
type DeltaIndex = ReturnType<typeof indexDeltas>;
|
||||||
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex): GameCardData {
|
type PitcherMap = ReturnType<typeof buildPitcherMap>;
|
||||||
|
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap): GameCardData {
|
||||||
return {
|
return {
|
||||||
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
||||||
sport: g.sport,
|
sport: g.sport,
|
||||||
@@ -181,6 +187,8 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
|
|||||||
venue: g.venue,
|
venue: g.venue,
|
||||||
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
||||||
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex),
|
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex),
|
||||||
|
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
|
||||||
|
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
|
||||||
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -352,6 +360,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
// Session 45 — merged pre-graded snapshot across the loaded sports.
|
// Session 45 — merged pre-graded snapshot across the loaded sports.
|
||||||
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
|
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
|
||||||
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
|
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
|
||||||
|
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
// Session 26 — per-sport schedule counts for the tab labels, fetched
|
// Session 26 — per-sport schedule counts for the tab labels, fetched
|
||||||
@@ -411,13 +420,15 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
const perSport = await Promise.all(
|
const perSport = await Promise.all(
|
||||||
sportsToFetch.map(async (sport) => {
|
sportsToFetch.map(async (sport) => {
|
||||||
const oddsUrls = FETCH_URLS[sport] as string[];
|
const oddsUrls = FETCH_URLS[sport] as string[];
|
||||||
const [oddsResults, schedule, lines, streaksRes, snap] = await Promise.all([
|
const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes] = await Promise.all([
|
||||||
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
|
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
|
||||||
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
|
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
|
||||||
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
|
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
|
||||||
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
|
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
|
||||||
// Session 45 — pre-graded snapshot (locked grades + line deltas).
|
// Session 45 — pre-graded snapshot (locked grades + line deltas).
|
||||||
getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
|
getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
|
||||||
|
// Session 46 — MLB probable starting pitchers.
|
||||||
|
sport === 'mlb' ? getJson<PitcherResponse>(`/api/schedule/mlb/pitchers`) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const oddsOk = oddsResults.some((o) => o !== null);
|
const oddsOk = oddsResults.some((o) => o !== null);
|
||||||
@@ -425,19 +436,21 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
const oddsGames = groupByGame(oddsProps, sport);
|
const oddsGames = groupByGame(oddsProps, sport);
|
||||||
const scheduleGames = schedule?.games || [];
|
const scheduleGames = schedule?.games || [];
|
||||||
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
|
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
|
||||||
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [] };
|
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [], pitcherGames: pitchersRes?.games || [] };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allGames: SlateGame[] = [];
|
const allGames: SlateGame[] = [];
|
||||||
const allSnapGrades: SnapshotGrade[] = [];
|
const allSnapGrades: SnapshotGrade[] = [];
|
||||||
const allSnapDeltas: SnapshotDelta[] = [];
|
const allSnapDeltas: SnapshotDelta[] = [];
|
||||||
|
const allPitcherGames: PitcherGame[] = [];
|
||||||
let anyOddsOk = false;
|
let anyOddsOk = false;
|
||||||
let anyScheduleShown = false;
|
let anyScheduleShown = false;
|
||||||
for (const s of perSport) {
|
for (const s of perSport) {
|
||||||
allGames.push(...s.merged);
|
allGames.push(...s.merged);
|
||||||
allSnapGrades.push(...s.snapGrades);
|
allSnapGrades.push(...s.snapGrades);
|
||||||
allSnapDeltas.push(...s.snapDeltas);
|
allSnapDeltas.push(...s.snapDeltas);
|
||||||
|
allPitcherGames.push(...s.pitcherGames);
|
||||||
if (s.oddsOk) anyOddsOk = true;
|
if (s.oddsOk) anyOddsOk = true;
|
||||||
if (s.hadSchedule) anyScheduleShown = true;
|
if (s.hadSchedule) anyScheduleShown = true;
|
||||||
}
|
}
|
||||||
@@ -445,6 +458,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
setGames(allGames);
|
setGames(allGames);
|
||||||
setSnapGrades(allSnapGrades);
|
setSnapGrades(allSnapGrades);
|
||||||
setSnapDeltas(allSnapDeltas);
|
setSnapDeltas(allSnapDeltas);
|
||||||
|
setPitcherGames(allPitcherGames);
|
||||||
|
|
||||||
// Odds down but schedule carried the slate → soft notice, not a wall.
|
// Odds down but schedule carried the slate → soft notice, not a wall.
|
||||||
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
|
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
|
||||||
@@ -497,6 +511,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
// Session 45 — index the pre-graded snapshot once for the overlay.
|
// Session 45 — index the pre-graded snapshot once for the overlay.
|
||||||
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
|
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
|
||||||
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
|
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
|
||||||
|
const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]);
|
||||||
|
|
||||||
const filteredGames = useMemo(() => {
|
const filteredGames = useMemo(() => {
|
||||||
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
|
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
|
||||||
@@ -735,7 +750,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
{filteredGames.map((g, i) => (
|
{filteredGames.map((g, i) => (
|
||||||
<VyndrGameCard
|
<VyndrGameCard
|
||||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||||
game={slateGameToCardData(g, gradeIndex, deltaIndex)}
|
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
|
||||||
onOpen={() => router.push('/scan')}
|
onOpen={() => router.push('/scan')}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* Player-name normalization (Session 46) — frontend copy of
|
||||||
|
src/utils/playerName.js (the Next bundle can't import from src/). Keep the two
|
||||||
|
in sync; tests/unit cross-checks they agree. CommonJS so .tsx imports it AND
|
||||||
|
Jest requires it directly. */
|
||||||
|
|
||||||
|
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
|
||||||
|
|
||||||
|
function normalizeName(raw) {
|
||||||
|
const display = String(raw == null ? '' : raw)
|
||||||
|
.replace(/\./g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
|
||||||
|
return { display, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameKey(raw) {
|
||||||
|
return normalizeName(raw).key;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeName, nameKey, SUFFIXES };
|
||||||
+47
-10
@@ -193,8 +193,9 @@ function isRelevantGame(game, now = Date.now()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
|
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
|
||||||
const snorm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
// Session 46 — key by the normalized name so "A.J. Ewing"/"AJ Ewing" merge.
|
||||||
const gradeKey = (player, stat) => `${snorm(player)}|${String(stat || '').toLowerCase()}`;
|
const { nameKey } = require('./playerName');
|
||||||
|
const gradeKey = (player, stat) => `${nameKey(player)}|${String(stat || '').toLowerCase()}`;
|
||||||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
|
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
|
||||||
|
|
||||||
/** Index snapshot grades by player|stat → the locked grade record. */
|
/** Index snapshot grades by player|stat → the locked grade record. */
|
||||||
@@ -250,23 +251,27 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
const order = [];
|
const order = [];
|
||||||
for (const p of gameProps || []) {
|
for (const p of gameProps || []) {
|
||||||
if (!p || !p.player) continue;
|
if (!p || !p.player) continue;
|
||||||
|
// Session 46 — group by the normalized key so name variants ("A.J. Ewing"
|
||||||
|
// / "AJ Ewing") merge into ONE strip; display the longest seen variant.
|
||||||
|
const pk = nameKey(p.player);
|
||||||
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
|
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
|
||||||
if (!byPlayer[p.player]) {
|
if (!byPlayer[pk]) {
|
||||||
byPlayer[p.player] = {
|
byPlayer[pk] = {
|
||||||
player: p.player,
|
player: p.player,
|
||||||
team: p.team || '',
|
team: p.team || '',
|
||||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||||
stats: [],
|
stats: [],
|
||||||
props: [],
|
props: [],
|
||||||
};
|
};
|
||||||
order.push(p.player);
|
order.push(pk);
|
||||||
} else if (!byPlayer[p.player].archetype && rec && rec.archetype) {
|
} else {
|
||||||
byPlayer[p.player].archetype = { primary: rec.archetype };
|
if (String(p.player).length > String(byPlayer[pk].player).length) byPlayer[pk].player = p.player;
|
||||||
|
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
|
||||||
}
|
}
|
||||||
if (rec) {
|
if (rec) {
|
||||||
const side = sideCh(rec.direction);
|
const side = sideCh(rec.direction);
|
||||||
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
|
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
|
||||||
byPlayer[p.player].props.push({
|
byPlayer[pk].props.push({
|
||||||
stat: statShort(rec.stat_type || rec.stat),
|
stat: statShort(rec.stat_type || rec.stat),
|
||||||
line: rec.line,
|
line: rec.line,
|
||||||
side,
|
side,
|
||||||
@@ -277,12 +282,42 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
|
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
byPlayer[p.player].props.push({
|
byPlayer[pk].props.push({
|
||||||
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return order.map((name) => byPlayer[name]);
|
return order.map((key) => byPlayer[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MLB probable pitchers (Session 46) ──────────────────────────────
|
||||||
|
const teamToken = (name) => String(name == null ? '' : name).toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
|
||||||
|
const teamMascot = (name) => { const t = teamToken(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; };
|
||||||
|
|
||||||
|
/** Index probable-pitcher games by team (full + mascot) → { pitcher, era }. */
|
||||||
|
function buildPitcherMap(pitcherGames) {
|
||||||
|
const map = {};
|
||||||
|
for (const g of pitcherGames || []) {
|
||||||
|
for (const side of [g.home, g.away]) {
|
||||||
|
if (!side || !side.pitcher || !side.team) continue;
|
||||||
|
const entry = { name: side.pitcher, era: side.era != null ? String(side.era) : null };
|
||||||
|
map[teamToken(side.team)] = entry;
|
||||||
|
const m = teamMascot(side.team);
|
||||||
|
if (m && map[m] == null) map[m] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve { away, home } pitchers for a game's team names → GameCard shape. */
|
||||||
|
function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) {
|
||||||
|
if (!pitcherMap) return undefined;
|
||||||
|
const look = (name) => pitcherMap[teamToken(name)] || pitcherMap[teamMascot(name)] || null;
|
||||||
|
const a = look(awayTeam);
|
||||||
|
const h = look(homeTeam);
|
||||||
|
if (!a && !h) return undefined;
|
||||||
|
const one = (p) => ({ name: (p && p.name) || 'TBD', era: (p && p.era) || '—' });
|
||||||
|
return { away: one(a), home: one(h) };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -299,4 +334,6 @@ module.exports = {
|
|||||||
statShort,
|
statShort,
|
||||||
gradedAgo,
|
gradedAgo,
|
||||||
buildPlayerStripsFromProps,
|
buildPlayerStripsFromProps,
|
||||||
|
buildPitcherMap,
|
||||||
|
pitchersForGameTeams,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user