Session 48: Name normalization at every layer + usage field (2156 tests)
Trace-first: the normalizer functions were correct (S47) but raw names still
flowed through paths that skipped them. Fixed each leaking path.
- 2a (source chokepoint): snapshotService.runSnapshot normalizes each grade's
player to the de-dotted display AND dedupes to one grade per nameKey|stat
(highest confidence) before writing grades:{sport} + snapshot:latest. Every
consumer (GameCard, Explore, leaders, profile) now gets clean merged names.
- 2b: buildPlayerStripsFromProps dedupes a player's props by stat (graded >
awaiting) → one row per stat (kills "Ks 5.5 AND Ks 3.5" variant dupes).
- 2c: scan tonightsPlayers grid groups by nameKey, displays normalized name.
- 3: profile VYNDR INTELLIGENCE "+0%"/"—" was buildIntel's defaults (separate
from the grade card's buildIntelFields, which already works). resolvePlayerStats
now attaches real usage (AB/G) + rest (B2B/Xd); buildIntel renders them; REST
default is now "—".
Backend 2149 -> 2156 tests (+7), 181 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,16 @@ inline with `GradeBadge`. `onPlayerClick` → `/player/:name?sport=` (`lib/playe
|
||||
|
||||
---
|
||||
|
||||
## 5a. Normalization is applied at the SNAPSHOT source (Session 48)
|
||||
|
||||
`snapshotService.runSnapshot` normalizes every grade's player name (de-dotted
|
||||
display) and dedupes to ONE grade per `nameKey|stat_type` (highest confidence)
|
||||
BEFORE writing `grades:{sport}` + `snapshot:{sport}:latest`. Every downstream
|
||||
consumer (GameCard overlay, `/api/snapshot`, `/leaders`, profile `activeProps`)
|
||||
therefore receives clean, merged names. New consumers don't need their own
|
||||
normalization — but UI lists built from RAW odds (e.g. the scan player grid)
|
||||
must group by `nameKey` + display `normalizeName().display`.
|
||||
|
||||
## 5b. Player name normalization (Session 46)
|
||||
|
||||
`src/utils/playerName.js` (+ identical `web/src/lib/playerName.js`) is the ONE
|
||||
|
||||
+32
-2
@@ -4,8 +4,38 @@
|
||||
2026-06-18
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v47.0 — Name normalization completed (nicknames/accents/parens),
|
||||
full MLB VYNDR INTELLIGENCE (rest/usage), ticker SCAN dedup, BOMBER threshold.
|
||||
SHIP BUILD v48.0 — Name normalization applied at EVERY layer (snapshot source,
|
||||
game-card strips, scan grid) + profile usage/rest wired. The dup-player arc closed.
|
||||
|
||||
## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER
|
||||
|
||||
The normalizer functions were correct (S47) but raw names still flowed through
|
||||
paths that skipped them. Traced top-down, fixed each. Backend 2149 → **2156
|
||||
tests** (+7), 181 suites. Web build clean (exit 0).
|
||||
|
||||
### The trace (3 leaking paths)
|
||||
1. **Snapshot grades** stored RAW player names + no dedup → Explore/leaders/
|
||||
profile/GameCard-overlay all saw variants.
|
||||
2. **Game-card strips** merged the player but pushed EVERY variant prop → "Ks 5.5
|
||||
AND Ks 3.5" duplicate rows.
|
||||
3. **Scan player grid** (`tonightsPlayers`) grouped by raw `p.player` → variant tiles.
|
||||
|
||||
### Fixes
|
||||
- **2a (source chokepoint):** `snapshotService.runSnapshot` now normalizes each
|
||||
grade's player to the de-dotted display AND dedupes to ONE grade per
|
||||
normalized player+stat (highest confidence) BEFORE writing `grades:{sport}` +
|
||||
`snapshot:{sport}:latest`. Every consumer now gets clean, merged names for free.
|
||||
- **2b:** `buildPlayerStripsFromProps` dedupes a player's props by stat (prefers
|
||||
the graded over an awaiting row) → one row per stat.
|
||||
- **2c:** scan `tonightsPlayers` groups by `nameKey`, displays `normalizeName().display`.
|
||||
- **3 (profile usage/rest):** the "+0%"/"—" was the PLAYER PROFILE's `buildIntel`
|
||||
(separate from the grade card's `buildIntelFields`, which already works — the
|
||||
full feature merge carries ab_per_game/rest_days, NOT filtered by FEATURE_NAMES
|
||||
which is meta-only). `resolvePlayerStats` (MLB) now attaches real `usage`
|
||||
(AB/G) + `rest` (B2B/Xd) and `buildIntel` renders them; REST default is now
|
||||
"—" not "+0%".
|
||||
|
||||
## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH
|
||||
|
||||
## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH
|
||||
|
||||
|
||||
@@ -493,6 +493,25 @@ snapshot, locked to the line, and read from cache.
|
||||
moderate). If you re-tune archetype thresholds, remember season totals are
|
||||
partial mid-season — don't use full-season cutoffs.
|
||||
|
||||
## Normalization Chokepoints (Session 48 — non-obvious)
|
||||
- **The snapshot is the normalization SOURCE.** `snapshotService.runSnapshot`
|
||||
normalizes every grade's player to `normalizeName().display` AND dedupes to one
|
||||
grade per `nameKey|stat_type` (highest confidence) before writing
|
||||
`grades:{sport}` + `snapshot:{sport}:latest`. So GameCard overlay, Explore,
|
||||
leaders, and profile activeProps ALL inherit clean, merged names — don't add
|
||||
per-consumer normalization, fix it here.
|
||||
- **Three paths that needed it (all fixed):** snapshot grades (above);
|
||||
`buildPlayerStripsFromProps` dedupes a player's props by stat (graded > awaiting);
|
||||
scan `tonightsPlayers` groups by `nameKey`. If a NEW surface lists players,
|
||||
group by `nameKey` + display `normalizeName().display`.
|
||||
- **TWO intel renderers — don't confuse them:** the GRADE CARD (scan) uses
|
||||
`analyzeViaEngine1.buildIntelFields(features)`; the PLAYER PROFILE uses
|
||||
`playerIntelService.buildIntel(stats)`. The "+0%"/"—" bug was the PROFILE's
|
||||
defaults — fixed by `resolvePlayerStats` attaching real `usage` (AB/G) + `rest`
|
||||
and `buildIntel` reading them. The grade card already worked (the full feature
|
||||
merge carries ab_per_game/rest_days; `FEATURE_NAMES` is meta bookkeeping only,
|
||||
NOT a whitelist that filters the vector).
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -119,6 +119,10 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
||||
const res = await mlb.getPlayerStats(name);
|
||||
if (!res || !res.found) return { found: false };
|
||||
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
|
||||
// Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
|
||||
// + rest (days off between the two most recent games; 0 = B2B). The
|
||||
// classify() scorer ignores these extra keys; buildIntel reads them.
|
||||
Object.assign(classifierInput, mlbProfileIntel(res));
|
||||
return {
|
||||
found: true,
|
||||
team: res.team || '',
|
||||
@@ -164,18 +168,43 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile VYNDR INTELLIGENCE bits from real MLB stats (Session 48):
|
||||
* usage = AB/G (the MLB usage equivalent), rest = days off between the two most
|
||||
* recent games ("B2B"/"Xd rest"). Returns {} when unavailable.
|
||||
*/
|
||||
function mlbProfileIntel(res) {
|
||||
const out = {};
|
||||
const ab = parseFloat(res.season && res.season.atBats);
|
||||
const gp = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted));
|
||||
if (Number.isFinite(ab) && Number.isFinite(gp) && gp > 0) {
|
||||
out.usage = `${Math.round((ab / gp) * 10) / 10} AB/G`;
|
||||
}
|
||||
const dated = Array.isArray(res.last10) ? res.last10.filter((g) => g && g.date) : [];
|
||||
if (dated.length >= 2) {
|
||||
const gap = Math.round((new Date(dated[dated.length - 1].date) - new Date(dated[dated.length - 2].date)) / 86_400_000);
|
||||
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) {
|
||||
const off = gap - 1;
|
||||
out.rest = off === 0 ? 'B2B' : `${off}d rest`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Derive the VYNDR Intelligence metric row from whatever we have. */
|
||||
function buildIntel(stats, arch, propCount) {
|
||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||
// Form: lean on last-10 vs season if provided, else a neutral baseline that
|
||||
// scales gently with the strongest graded prop's confidence proxy.
|
||||
const form = stats.form != null ? clamp(Math.round(stats.form), 0, 100) : 70 + clamp(propCount * 4, 0, 22);
|
||||
const usage = stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—';
|
||||
// Session 48 — prefer a preformatted usage string (e.g. "3.6 AB/G"); rest
|
||||
// shows the real value or "—" (no more bogus "+0%").
|
||||
const usage = stats.usage || (stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—');
|
||||
return [
|
||||
{ label: 'FORM', kind: 'form', value: String(form), score: `${clamp(form, 0, 100)}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' },
|
||||
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
|
||||
{ label: 'MATCHUP', kind: 'grade', value: arch.primary ? gradeFromForm(form) : 'B', color: '#00D4A0' },
|
||||
{ label: 'REST', kind: 'plain', value: stats.rest || '+0%', color: '#00D4A0' },
|
||||
{ label: 'REST', kind: 'plain', value: stats.rest || '—', color: '#00D4A0' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const DELTA_NOISE = 0.5; // ignore movements smaller than this
|
||||
const DELTA_MOVE = 1.0; // ticker MOVE threshold
|
||||
const STATS_CONCURRENCY = 5;
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
const { nameKey, normalizeName } = 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);
|
||||
@@ -200,8 +200,23 @@ async function runSnapshot(sport, opts = {}) {
|
||||
now: deps.now,
|
||||
cacheSet: async (_k, v) => { envelope = v; },
|
||||
});
|
||||
const graded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : [];
|
||||
if (graded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 };
|
||||
const rawGraded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : [];
|
||||
if (rawGraded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 };
|
||||
|
||||
// Session 48 — normalize player display names + dedupe variant grades at the
|
||||
// SOURCE so every consumer (GameCard, Explore, leaders, profile) gets clean,
|
||||
// merged names. PropLine sends "Matt"/"Matthew", "A.J."/"AJ", "(STL)" tags as
|
||||
// separate players; collapse to ONE grade per normalized player + stat (keep
|
||||
// the highest-confidence; rawGraded is already confidence-desc).
|
||||
const dedup = new Map();
|
||||
for (const g of rawGraded) {
|
||||
const disp = normalizeName(g.player || g.player_name).display || g.player || g.player_name || '';
|
||||
const k = `${nameKey(disp)}|${String(g.stat_type || g.stat || '').toLowerCase()}`;
|
||||
const cur = { ...g, player: disp, player_name: disp };
|
||||
const prev = dedup.get(k);
|
||||
if (!prev || (Number(g.confidence) || 0) > (Number(prev.confidence) || 0)) dedup.set(k, cur);
|
||||
}
|
||||
const graded = [...dedup.values()];
|
||||
|
||||
// Archetype per unique player (pure math once we have stats). Best-effort —
|
||||
// a missing stat line → no badge (not a fallback archetype).
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Session 48 — name normalization applied at every layer (snapshot grades,
|
||||
// game-card strips, scan grid).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const snap = require('../../src/services/snapshotService');
|
||||
const slate = require('../../web/src/lib/slateAdapter');
|
||||
|
||||
function memCache() {
|
||||
const store = {};
|
||||
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
|
||||
}
|
||||
|
||||
describe('Phase 2a — snapshot normalizes + dedupes grades at the source', () => {
|
||||
const baseGrades = [
|
||||
{ player: 'Matthew Liberatore', stat_type: 'strikeouts', line: 5.5, direction: 'over', grade: 'A', confidence: 80 },
|
||||
{ player: 'Matt Liberatore', stat_type: 'strikeouts', line: 3.5, direction: 'over', grade: 'B', confidence: 60 },
|
||||
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B+', confidence: 70 },
|
||||
{ player: 'AJ Ewing', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'C', confidence: 55 },
|
||||
];
|
||||
const deps = (cache) => ({
|
||||
getOdds: async () => ({ sport: 'mlb', props: [{ player: 'x', stat_type: 'hits', line: 1.5 }], provider: 'test' }),
|
||||
gradeAndCacheSlate: async (_s, _p, opts) => { await opts.cacheSet('grades:x', { grades: baseGrades }); return { written: true, count: baseGrades.length }; },
|
||||
resolveStats: async () => ({ found: false }),
|
||||
classify: require('../../src/services/archetypeService').classify,
|
||||
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
||||
now: () => '2026-06-19T20:00:00Z', nowMs: () => 1,
|
||||
});
|
||||
|
||||
it('writes ONE grade per normalized player+stat (highest confidence), names normalized', async () => {
|
||||
const cache = memCache();
|
||||
await snap.runSnapshot('mlb', deps(cache));
|
||||
const grades = cache.store['snapshot:mlb:latest'].grades;
|
||||
// Liberatore: one strikeouts grade (the A/conf80), Ewing: one hits grade (B+/conf70)
|
||||
expect(grades).toHaveLength(2);
|
||||
const lib = grades.find((g) => g.stat_type === 'strikeouts');
|
||||
expect(lib.grade).toBe('A');
|
||||
expect(lib.player).toBe('Matthew Liberatore'); // normalized display
|
||||
const ew = grades.find((g) => g.stat_type === 'hits');
|
||||
expect(ew.player).toBe('AJ Ewing'); // de-dotted
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2b — game-card strips show one row per stat', () => {
|
||||
it('collapses variant prop dupes into one strip, one row per stat', () => {
|
||||
const strips = slate.buildPlayerStripsFromProps([
|
||||
{ player: 'Matt Liberatore', stat_type: 'strikeouts', line: 5.5 },
|
||||
{ player: 'Matthew Liberatore', stat_type: 'strikeouts', line: 3.5 },
|
||||
{ player: 'Matthew Liberatore', stat_type: 'hits_allowed', line: 4.5 },
|
||||
], {}, {});
|
||||
expect(strips).toHaveLength(1);
|
||||
// strikeouts deduped to one row; hits_allowed separate
|
||||
const ks = strips[0].props.filter((p) => p.stat === 'Ks');
|
||||
expect(ks).toHaveLength(1);
|
||||
expect(strips[0].props).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('prefers the graded prop over an awaiting one for the same stat', () => {
|
||||
const gradeIndex = slate.indexGrades([{ player: 'AJ Ewing', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', gradedAt: { line: 1.5, timestamp: '2026-06-19T18:00:00Z' } }]);
|
||||
const strips = slate.buildPlayerStripsFromProps([
|
||||
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5 }, // matches grade
|
||||
{ player: 'AJ Ewing', stat_type: 'hits', line: 1.5 }, // variant, ungraded dup
|
||||
], gradeIndex, {});
|
||||
expect(strips).toHaveLength(1);
|
||||
expect(strips[0].props).toHaveLength(1);
|
||||
expect(strips[0].props[0].grade).toBe('A'); // kept the graded one
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2c — scan player grid groups by nameKey', () => {
|
||||
const src = fs.readFileSync(path.join(WEB, 'app', 'scan', 'page.tsx'), 'utf8');
|
||||
it('groups tonightsPlayers by nameKey + displays normalized', () => {
|
||||
expect(src).toContain('nameKey(p.player)');
|
||||
expect(src).toContain('normalizeName(p.player).display');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Session 48 — Phase 3: player profile VYNDR INTELLIGENCE shows real usage
|
||||
// (AB/G) + rest, not "—"/"+0%".
|
||||
|
||||
const svc = require('../../src/services/playerIntelService');
|
||||
|
||||
const judgeAdapter = {
|
||||
async getPlayerStats() {
|
||||
return {
|
||||
found: true, group: 'hitting', team: 'New York Yankees',
|
||||
season: { homeRuns: 34, atBats: 330, gamesPlayed: 92, avg: '.288', ops: '1.012', rbi: 87 },
|
||||
last10: [
|
||||
{ date: '2026-06-14', stat: { totalBases: 2 } },
|
||||
{ date: '2026-06-16', stat: { totalBases: 4 } }, // gap 2 → 1 day off
|
||||
{ date: '2026-06-18', stat: { totalBases: 3 } },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
describe('resolvePlayerStats attaches usage + rest for MLB', () => {
|
||||
it('computes AB/G usage and rest from the game log', async () => {
|
||||
const r = await svc.resolvePlayerStats('Aaron Judge', 'mlb', { mlbAdapter: judgeAdapter });
|
||||
expect(r.classifierInput.usage).toMatch(/AB\/G$/);
|
||||
expect(r.classifierInput.usage).toBe(`${Math.round((330 / 92) * 10) / 10} AB/G`);
|
||||
expect(r.classifierInput.rest).toBe('1d rest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerIntel surfaces real usage + rest (no "—"/"+0%")', () => {
|
||||
it('USAGE shows AB/G, REST shows a real value', async () => {
|
||||
const r = await svc.getPlayerIntel('Aaron Judge', 'mlb', {
|
||||
cacheGet: async () => null,
|
||||
resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: judgeAdapter }),
|
||||
});
|
||||
const usage = r.intel.find((m) => m.label === 'USAGE');
|
||||
const rest = r.intel.find((m) => m.label === 'REST');
|
||||
expect(usage.value).toMatch(/AB\/G$/);
|
||||
expect(rest.value).toBe('1d rest');
|
||||
expect(rest.value).not.toBe('+0%');
|
||||
});
|
||||
|
||||
it('REST falls back to "—" not "+0%" when no data', async () => {
|
||||
const r = await svc.getPlayerIntel('Nobody', 'nba', { cacheGet: async () => null, resolveStats: async () => ({ found: false }) });
|
||||
expect(r.intel.find((m) => m.label === 'REST').value).toBe('—');
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
|
||||
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
|
||||
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
|
||||
import { normalizeName, nameKey } from '@/lib/playerName';
|
||||
import { markReadComplete } from '@/lib/reads';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
@@ -175,15 +176,21 @@ export default function ScanPage() {
|
||||
})
|
||||
.then((data: { props?: Array<{ player?: string; stat_type?: string }> }) => {
|
||||
if (cancelled) return;
|
||||
const byPlayer = new Map<string, Set<string>>();
|
||||
// Session 48 — group by the normalized name key so variants
|
||||
// ("A.J."/"AJ", "Matt"/"Matthew", "Jazz Chisholm"/"Jr.") show as ONE
|
||||
// tile; display the normalized (longest) name.
|
||||
const byPlayer = new Map<string, { name: string; stats: Set<string> }>();
|
||||
for (const p of data.props || []) {
|
||||
if (!p.player || !p.stat_type) continue;
|
||||
const set = byPlayer.get(p.player) || new Set<string>();
|
||||
set.add(p.stat_type);
|
||||
byPlayer.set(p.player, set);
|
||||
const key = nameKey(p.player);
|
||||
const disp = normalizeName(p.player).display || p.player;
|
||||
const entry = byPlayer.get(key) || { name: disp, stats: new Set<string>() };
|
||||
if (disp.length > entry.name.length) entry.name = disp;
|
||||
entry.stats.add(p.stat_type);
|
||||
byPlayer.set(key, entry);
|
||||
}
|
||||
const list = Array.from(byPlayer.entries())
|
||||
.map(([name, stats]) => ({ name, stats: Array.from(stats) }))
|
||||
const list = Array.from(byPlayer.values())
|
||||
.map(({ name, stats }) => ({ name, stats: Array.from(stats) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setTonightsPlayers(list);
|
||||
})
|
||||
|
||||
@@ -291,7 +291,18 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
});
|
||||
}
|
||||
}
|
||||
return order.map((key) => byPlayer[key]);
|
||||
// Session 48 — one prop row per stat (variant dupes like "Ks 5.5" + "Ks 3.5"
|
||||
// from "Matt"/"Matthew" collapse). Prefer the graded prop over an awaiting one.
|
||||
return order.map((key) => {
|
||||
const e = byPlayer[key];
|
||||
const byStat = new Map();
|
||||
for (const pr of e.props) {
|
||||
const sk = String(pr.stat).toLowerCase();
|
||||
const ex = byStat.get(sk);
|
||||
if (!ex || (pr.grade && !ex.grade)) byStat.set(sk, pr);
|
||||
}
|
||||
return { ...e, props: [...byStat.values()] };
|
||||
});
|
||||
}
|
||||
|
||||
// ── MLB probable pitchers (Session 46) ──────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user