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:
Kev
2026-06-18 23:56:26 -04:00
parent f8b120c0aa
commit c8fc9f577e
20 changed files with 608 additions and 32 deletions
+27
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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)
+19
View File
@@ -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();
+19 -6
View File
@@ -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`;
+61
View File
@@ -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,
+7 -2
View File
@@ -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}`);
+52
View File
@@ -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 };
+4 -1
View File
@@ -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] || '');
+38
View File
@@ -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 };
+71
View File
@@ -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();
});
});
+2 -2
View File
@@ -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);
}); });
+61
View File
@@ -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');
});
});
+68
View File
@@ -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');
});
});
+2 -2
View File
@@ -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
View File
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 });
}
}
+20 -5
View File
@@ -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')}
/> />
))} ))}
+22
View File
@@ -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
View File
@@ -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,
}; };