Session 47: Name normalization + grade intel + ticker polish (2149 tests)
- Name normalization completed: NICKNAMES table (Matt↔Matthew, Mike↔Michael...)
resolved in nameKey, parenthetical team-tag strip "(STL)", verified accent-fold
(Iván/Ivan, José/Jose). Slate strip now DISPLAYS the normalized de-dotted name
("AJ Ewing" not "A.J. Ewing") via buildPlayerStripsFromProps.
- Complete MLB VYNDR INTELLIGENCE: mlbGameLogFeatures derives rest_days (days off
between latest games; 0=B2B) + ab_per_game (usage). buildIntelFields renders
usage as "X AB/G", rest as B2B/Xd, matchup from bvp_advantage fallback.
- Ticker SCAN dedup: pushTickerItems keeps one SCAN per sport (sport field or
text-prefix parse for legacy); MOVE/GRADE preserved; cap 50.
- BOMBER threshold prorated for mid-season (hr>=15 strong / >=10 mod) so June
sluggers classify BOMBER not FLEX/DRIVER.
Backend 2122 -> 2149 tests (+27), 179 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-2
@@ -4,8 +4,41 @@
|
||||
2026-06-18
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v46.0 — Grade-card intel (MLB game logs) + player-name normalization
|
||||
+ MLB starting pitchers. Three focused P1 fixes on the Session-45 snapshot model.
|
||||
SHIP BUILD v47.0 — Name normalization completed (nicknames/accents/parens),
|
||||
full MLB VYNDR INTELLIGENCE (rest/usage), ticker SCAN dedup, BOMBER threshold.
|
||||
|
||||
## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH
|
||||
|
||||
Backend 2122 → **2149 tests** (+27), 179 suites. Web build clean (exit 0).
|
||||
|
||||
### Phase 1 — complete name normalization
|
||||
`playerName.js` (both copies) gained: parenthetical-tag strip ("Jose Fermin (STL)"
|
||||
→ "Jose Fermin"), a ~50-entry NICKNAMES table resolved in `nameKey` ("Matt"↔
|
||||
"Matthew", "Mike"↔"Michael"), and the existing accent-fold now verified for
|
||||
Iván/Ivan, José/Jose. The slate strip now DISPLAYS the normalized de-dotted name
|
||||
(`buildPlayerStripsFromProps` uses `normalizeName().display`) — "A.J. Ewing" shows
|
||||
as "AJ Ewing", not the raw PropLine string.
|
||||
|
||||
### Phase 2 — complete VYNDR INTELLIGENCE for MLB
|
||||
The audit showed only Form. `mlbGameLogFeatures` now also derives `rest_days`
|
||||
(days off between the two most recent game-log dates; 0 = B2B, matching the NBA
|
||||
convention) and `ab_per_game` (the MLB "usage" equivalent). `buildIntelFields`
|
||||
renders `usage` as "X AB/G", `rest` as "B2B"/"Xd rest", and a matchup grade from
|
||||
`bvp_advantage` when no opp rank. So MLB grade cards now show Form + Usage + Rest
|
||||
(+ Matchup when available).
|
||||
|
||||
### Phase 3 — ticker SCAN dedup
|
||||
`pushTickerItems` keeps only the LATEST SCAN per sport (drops prior SCAN events
|
||||
for any sport with a fresh scan; parses the sport from a `sport` field or the
|
||||
text prefix for legacy items). MOVE/GRADE events preserved; cap stays 50.
|
||||
|
||||
### Phase 4 — archetype threshold
|
||||
BOMBER's HR thresholds prorated for mid-season (`hr>=15` strong / `hr>=10`
|
||||
moderate, was `>=20`/`>=15`), so June sluggers (Schwarber/Harper ~17-18 HR)
|
||||
classify as BOMBER instead of FLEX/DRIVER. `computeLineDeltas` re-verified
|
||||
structurally sound (deltas populate on the 2nd+ snapshot).
|
||||
|
||||
## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES
|
||||
|
||||
## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES
|
||||
|
||||
|
||||
@@ -473,6 +473,26 @@ snapshot, locked to the line, and read from cache.
|
||||
team (full name OR mascot via `slateAdapter.buildPitcherMap`/`pitchersForGameTeams`).
|
||||
ERA is best-effort (season stats per pitcher id, cached).
|
||||
|
||||
## Name Norm + Intel + Ticker (Session 47 — non-obvious)
|
||||
- **Name normalization is now complete:** `playerName.js` (both copies, kept
|
||||
identical) strips parenthetical team tags ("(STL)"), de-dots, suffix-strips,
|
||||
accent-folds the key, AND resolves first-name nicknames via the `NICKNAMES`
|
||||
table ("matt"→"matthew"). `nameKey` does the nickname resolution; `normalizeName`
|
||||
does display/key. To add a nickname, edit BOTH copies. The SLATE displays the
|
||||
normalized de-dotted name (`buildPlayerStripsFromProps` → `normalizeName().display`),
|
||||
not raw PropLine — that's why "AJ Ewing" shows, not "A.J. Ewing".
|
||||
- **MLB VYNDR INTELLIGENCE fields** come from `mlbGameLogFeatures`: `rest_days`
|
||||
(gap-1 between the two latest game dates; 0 = B2B), `ab_per_game` (usage). If a
|
||||
field is missing from the card, check that `mlbGameLogFeatures` produced it and
|
||||
`buildIntelFields` has a branch (usage reads usage_rate→minutes→ab_per_game;
|
||||
matchup reads opp_rank_stat→bvp_advantage).
|
||||
- **Ticker SCAN dedup:** `pushTickerItems` keeps one SCAN per sport (via the
|
||||
`sport` field on the event, or parsed from the text prefix for legacy items).
|
||||
MOVE/GRADE are time-specific and never deduped.
|
||||
- **BOMBER threshold is prorated for mid-season** (`hr>=15` strong / `hr>=10`
|
||||
moderate). If you re-tune archetype thresholds, remember season totals are
|
||||
partial mid-season — don't use full-season cutoffs.
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -343,8 +343,10 @@ function scoreMLB(s) {
|
||||
ops = num(s.ops), runs = num(s.runs), kRate = num(s.k_rate), doubles = num(s.doubles);
|
||||
return {
|
||||
// BOMBER is the single power archetype — fires for any high-HR bat (incl.
|
||||
// high-strikeout sluggers like Judge), so power hitters classify as BOMBER.
|
||||
BOMBER: hr >= 20 ? 0.6 + hr / 60 : hr >= 15 ? 0.3 : 0,
|
||||
// high-strikeout sluggers like Judge). Session 47: thresholds prorated for
|
||||
// mid-season HR totals (a 15-HR June pace is full-season slugger territory),
|
||||
// so power leads over DRIVER for a slugger who also drives in runs.
|
||||
BOMBER: hr >= 15 ? 0.6 + hr / 60 : hr >= 10 ? 0.35 : 0,
|
||||
BRUSH: avg >= 0.28 && kRate < 16 ? 0.6 + (avg - 0.25) * 2 : avg >= 0.29 ? 0.4 : 0,
|
||||
DRIVER: rbi >= 50 && hr >= 12 ? 0.5 + rbi / 200 : 0,
|
||||
GHOST: sb >= 15 ? 0.6 + sb / 60 : sb >= 10 ? 0.35 : 0,
|
||||
|
||||
@@ -319,11 +319,14 @@ function buildIntelFields(features = {}, opts = {}) {
|
||||
|
||||
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.ab_per_game)) out.usage = `${round1(features.ab_per_game)} AB/G`; // MLB usage equivalent
|
||||
else if (ps.usage) out.usage = String(ps.usage);
|
||||
|
||||
const matchup = matchupGradeFromRank(features.opp_rank_stat);
|
||||
const matchup = matchupGradeFromRank(features.opp_rank_stat)
|
||||
|| (Number.isFinite(features.bvp_advantage) ? (features.bvp_advantage > 0.05 ? 'A' : features.bvp_advantage > 0 ? 'B+' : 'C') : null);
|
||||
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 === 0 ? 'B2B' : `${features.rest_days}d rest`;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,23 @@ function mlbGameLogFeatures(res, statType) {
|
||||
} else if (out.l10_avg != null) {
|
||||
out.l20_avg = out.l10_avg; // baseline so form has a reference
|
||||
}
|
||||
|
||||
// Session 47 — complete VYNDR INTELLIGENCE for MLB:
|
||||
// - rest_days: days between the two most recent games (0 = back-to-back).
|
||||
// - ab_per_game: at-bats per game, the MLB "usage" equivalent.
|
||||
const dated = logs.filter((g) => g && g.date);
|
||||
if (dated.length >= 2) {
|
||||
const last = new Date(dated[dated.length - 1].date).getTime();
|
||||
const prev = new Date(dated[dated.length - 2].date).getTime();
|
||||
const gap = Math.round((last - prev) / 86_400_000);
|
||||
// rest_days = days OFF (0 = played the day before = B2B), matching the
|
||||
// NBA convention buildIntelFields uses. Consecutive calendar days → 0.
|
||||
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
|
||||
}
|
||||
const ab = parseFloat(res.season && res.season.atBats);
|
||||
if (Number.isFinite(ab) && Number.isFinite(games) && games > 0) {
|
||||
out.ab_per_game = ab / games;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const isTopGrade = (g) => g === 'A+' || g === 'A';
|
||||
function generateTickerEvents(sport, grades, deltas, ts) {
|
||||
const events = [];
|
||||
events.push({
|
||||
tag: 'SCAN', color: 'var(--g-a)', ts,
|
||||
tag: 'SCAN', color: 'var(--g-a)', ts, sport, // sport tag → dedupe one SCAN per sport
|
||||
text: `${sport.toUpperCase()} slate scanned · ${grades.length} props graded`,
|
||||
});
|
||||
for (const g of grades.filter((x) => isTopGrade(x.grade)).slice(0, 6)) {
|
||||
@@ -136,11 +136,28 @@ function generateTickerEvents(sport, grades, deltas, ts) {
|
||||
return events;
|
||||
}
|
||||
|
||||
// Session 47 — a SCAN event's sport, from the event field or its text prefix
|
||||
// (defends ticker items written before the `sport` field existed).
|
||||
function scanSportOf(e) {
|
||||
if (e.tag !== 'SCAN') return null;
|
||||
if (e.sport) return String(e.sport).toLowerCase();
|
||||
const m = String(e.text || '').match(/^([a-z]+)\s+slate scanned/i);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
async function pushTickerItems(events, deps) {
|
||||
if (!events || events.length === 0) return;
|
||||
const existing = await deps.cacheGet('ticker:items');
|
||||
const arr = Array.isArray(existing) ? existing : [];
|
||||
const merged = [...events, ...arr].slice(0, TICKER_CAP);
|
||||
// Keep only the LATEST SCAN per sport: drop existing SCAN events for any sport
|
||||
// that has a fresh SCAN in this batch. MOVE/GRADE events are time-specific and
|
||||
// preserved.
|
||||
const freshScanSports = new Set(events.map(scanSportOf).filter(Boolean));
|
||||
const pruned = arr.filter((e) => {
|
||||
const sp = scanSportOf(e);
|
||||
return !(sp && freshScanSports.has(sp));
|
||||
});
|
||||
const merged = [...events, ...pruned].slice(0, TICKER_CAP);
|
||||
await deps.cacheSet('ticker:items', merged, TICKER_TTL);
|
||||
}
|
||||
|
||||
|
||||
+39
-12
@@ -1,18 +1,21 @@
|
||||
'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.
|
||||
* Player-name normalization (Session 46, completed Session 47) — the ONE source
|
||||
* of truth for comparing/de-duplicating player names. PropLine sends the same
|
||||
* player many ways: "A.J. Ewing"/"AJ Ewing", "Jazz Chisholm"/"Jazz Chisholm Jr.",
|
||||
* "Iván Herrera"/"Ivan Herrera", "Matt Liberatore"/"Matthew Liberatore",
|
||||
* "Jose Fermin (STL)". This collapses them all.
|
||||
*
|
||||
* 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("Jose Fermin (STL)") → { display: "Jose Fermin", key: "jose fermin" }
|
||||
* normalizeName("Ronald Acuña Jr.") → { display: "Ronald Acuña Jr", key: "ronald acuna" }
|
||||
* nameKey("Matt Liberatore") === nameKey("Matthew Liberatore") === "matthew liberatore"
|
||||
* nameKey("Iván Herrera") === nameKey("Ivan Herrera") === "ivan herrera"
|
||||
*
|
||||
* `display` keeps proper casing + accents (periods stripped, suffix de-dotted).
|
||||
* `key` is accent-folded, lowercased, suffix-stripped for comparison.
|
||||
* `display` keeps proper casing + accents (periods, suffix dots, and parenthetical
|
||||
* team tags stripped). `key` is accent-folded, lowercased, suffix-stripped, and
|
||||
* first-name-nickname-resolved 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.
|
||||
@@ -20,9 +23,27 @@
|
||||
|
||||
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
|
||||
|
||||
// Common first-name nicknames in sports. KEY = short form, VALUE = canonical
|
||||
// long form. Both sides reduce to the long form so "Matt"/"Matthew" merge.
|
||||
const NICKNAMES = {
|
||||
matt: 'matthew', mike: 'michael', chris: 'christopher', jake: 'jacob',
|
||||
josh: 'joshua', nick: 'nicholas', nate: 'nathaniel', dan: 'daniel',
|
||||
danny: 'daniel', dave: 'david', rob: 'robert', bob: 'robert', joe: 'joseph',
|
||||
joey: 'joseph', jon: 'jonathan', johnny: 'john', tony: 'anthony',
|
||||
alex: 'alexander', andy: 'andrew', drew: 'andrew', ben: 'benjamin',
|
||||
will: 'william', bill: 'william', billy: 'william', willy: 'william',
|
||||
zach: 'zachary', zack: 'zachary', tom: 'thomas', tommy: 'thomas',
|
||||
ty: 'tyler', ed: 'edward', eddie: 'edward', teddy: 'theodore',
|
||||
charlie: 'charles', chuck: 'charles', rick: 'richard', dick: 'richard',
|
||||
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
|
||||
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis',
|
||||
};
|
||||
|
||||
function normalizeName(raw) {
|
||||
const display = String(raw == null ? '' : raw)
|
||||
.replace(/\./g, '') // "A.J." → "AJ", "Jr." → "Jr"
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ') // strip parenthetical team tags "(STL)"
|
||||
.replace(/\./g, '') // strip dots: "A.J." → "AJ", "Jr." → "Jr"
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
@@ -30,9 +51,15 @@ function normalizeName(raw) {
|
||||
return { display, key };
|
||||
}
|
||||
|
||||
/** Comparison key only (the common case). */
|
||||
/**
|
||||
* Comparison key: normalized + first-name nickname resolved to the canonical
|
||||
* form ("matt" → "matthew") so nicknames collapse.
|
||||
*/
|
||||
function nameKey(raw) {
|
||||
return normalizeName(raw).key;
|
||||
const { key } = normalizeName(raw);
|
||||
const parts = key.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2 && NICKNAMES[parts[0]]) parts[0] = NICKNAMES[parts[0]];
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
module.exports = { normalizeName, nameKey, SUFFIXES };
|
||||
module.exports = { normalizeName, nameKey, SUFFIXES, NICKNAMES };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Session 47 — Phase 2: complete VYNDR INTELLIGENCE for MLB (rest + usage).
|
||||
|
||||
const { __internals: fc } = require('../../src/services/intelligence/featureCache');
|
||||
const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1');
|
||||
|
||||
const judge = (dates) => ({
|
||||
found: true, group: 'hitting',
|
||||
season: { totalBases: 180, atBats: 330, gamesPlayed: 92 },
|
||||
last10: [
|
||||
{ date: dates[0], stat: { totalBases: 2 } },
|
||||
{ date: dates[1], stat: { totalBases: 4 } },
|
||||
{ date: dates[2], stat: { totalBases: 3 } },
|
||||
],
|
||||
});
|
||||
|
||||
describe('mlbGameLogFeatures — rest + usage', () => {
|
||||
it('computes rest_days from the two most recent game dates', () => {
|
||||
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-16', '2026-06-18']), 'total_bases');
|
||||
expect(f.rest_days).toBe(1); // 06-16 → 06-18 (gap 2 = 1 day off)
|
||||
});
|
||||
it('marks back-to-back as 0', () => {
|
||||
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-17', '2026-06-18']), 'total_bases');
|
||||
expect(f.rest_days).toBe(0);
|
||||
});
|
||||
it('computes ab_per_game (MLB usage equivalent)', () => {
|
||||
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-16', '2026-06-18']), 'total_bases');
|
||||
expect(f.ab_per_game).toBeCloseTo(330 / 92, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIntelFields — all four MLB fields', () => {
|
||||
it('renders usage as AB/G and rest from MLB features', () => {
|
||||
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-16', '2026-06-18']), 'total_bases');
|
||||
const intel = eng.buildIntelFields(f);
|
||||
expect(intel.usage).toMatch(/AB\/G$/);
|
||||
expect(intel.rest).toBe('1d rest');
|
||||
expect(intel.season_avg).toBeDefined();
|
||||
});
|
||||
it('renders B2B for zero rest', () => {
|
||||
expect(eng.buildIntelFields({ rest_days: 0 }).rest).toBe('B2B');
|
||||
});
|
||||
it('produces all four fields for a complete feature set', () => {
|
||||
const intel = eng.buildIntelFields({ l20_avg: 1.9, l10_avg: 2.1, l5_avg: 2.4, ab_per_game: 3.6, opp_rank_stat: 0.7, rest_days: 1 });
|
||||
expect(intel.season_avg).toBeDefined();
|
||||
expect(intel.form).toBeDefined();
|
||||
expect(intel.usage).toBe('3.6 AB/G');
|
||||
expect(intel.matchup_grade).toBe('A');
|
||||
expect(intel.rest).toBe('1d rest');
|
||||
});
|
||||
it('derives matchup from batter-vs-pitcher edge when no opp rank', () => {
|
||||
expect(eng.buildIntelFields({ bvp_advantage: 0.1 }).matchup_grade).toBe('A');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Session 47 — Phase 1: complete name normalization (accents, nicknames, parens,
|
||||
// display de-dotting).
|
||||
|
||||
const be = require('../../src/utils/playerName');
|
||||
const fe = require('../../web/src/lib/playerName');
|
||||
const slate = require('../../web/src/lib/slateAdapter');
|
||||
|
||||
describe('accent folding', () => {
|
||||
it('"Iván Herrera" and "Ivan Herrera" share a key', () => {
|
||||
expect(be.nameKey('Iván Herrera')).toBe(be.nameKey('Ivan Herrera'));
|
||||
expect(be.nameKey('Iván Herrera')).toBe('ivan herrera');
|
||||
});
|
||||
it('"José Caballero" and "Jose Caballero" share a key', () => {
|
||||
expect(be.nameKey('José Caballero')).toBe(be.nameKey('Jose Caballero'));
|
||||
});
|
||||
it('keeps the accent in display', () => {
|
||||
expect(be.normalizeName('Iván Herrera').display).toBe('Iván Herrera');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nickname resolution', () => {
|
||||
it('"Matt Liberatore" === "Matthew Liberatore"', () => {
|
||||
expect(be.nameKey('Matt Liberatore')).toBe(be.nameKey('Matthew Liberatore'));
|
||||
expect(be.nameKey('Matt Liberatore')).toBe('matthew liberatore');
|
||||
});
|
||||
it('"Mike Massey" === "Michael Massey"', () => {
|
||||
expect(be.nameKey('Mike Massey')).toBe(be.nameKey('Michael Massey'));
|
||||
});
|
||||
it('does not touch the last name', () => {
|
||||
// "Matt" first name resolves; a player whose LAST name is Matt-like is unaffected
|
||||
expect(be.nameKey('John Matthews')).toBe('john matthews');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parenthetical team tags', () => {
|
||||
it('strips "(STL)" from display and key', () => {
|
||||
expect(be.normalizeName('Jose Fermin (STL)').display).toBe('Jose Fermin');
|
||||
expect(be.nameKey('Jose Fermin (STL)')).toBe(be.nameKey('Jose Fermin'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('display de-dotting', () => {
|
||||
it('"A.J. Ewing" display shows "AJ Ewing"', () => {
|
||||
expect(be.normalizeName('A.J. Ewing').display).toBe('AJ Ewing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontend + backend agree', () => {
|
||||
it.each(['Iván Herrera', 'Matt Liberatore', 'Jose Fermin (STL)', 'A.J. Ewing', 'Ronald Acuña Jr.', 'Mike Massey'])('%s', (n) => {
|
||||
expect(fe.nameKey(n)).toBe(be.nameKey(n));
|
||||
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlayerStripsFromProps merges variants + de-dots display', () => {
|
||||
it('merges nickname + accent + period variants into one strip', () => {
|
||||
const strips = slate.buildPlayerStripsFromProps([
|
||||
{ player: 'Matt Liberatore', stat_type: 'strikeouts', line: 5.5 },
|
||||
{ player: 'Matthew Liberatore', stat_type: 'hits_allowed', line: 5.5 },
|
||||
{ player: 'Iván Herrera', stat_type: 'hits', line: 1.5 },
|
||||
{ player: 'Ivan Herrera', stat_type: 'total_bases', line: 1.5 },
|
||||
], {}, {});
|
||||
expect(strips).toHaveLength(2);
|
||||
expect(strips[0].props).toHaveLength(2);
|
||||
});
|
||||
it('display name is de-dotted', () => {
|
||||
const strips = slate.buildPlayerStripsFromProps([
|
||||
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5 },
|
||||
], {}, {});
|
||||
expect(strips[0].player).toBe('AJ Ewing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Session 47 — Phase 3: ticker keeps only the latest SCAN per sport.
|
||||
|
||||
const svc = require('../../src/services/snapshotService');
|
||||
|
||||
function memCache(initial) {
|
||||
const store = { 'ticker:items': initial || null };
|
||||
return { store, cacheGet: async (k) => store[k] ?? null, cacheSet: async (k, v) => { store[k] = v; } };
|
||||
}
|
||||
|
||||
describe('pushTickerItems — SCAN dedup', () => {
|
||||
it('replaces a prior SCAN for the same sport (only one MLB SCAN)', async () => {
|
||||
const cache = memCache([
|
||||
{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 20 props graded' },
|
||||
{ tag: 'MOVE', text: 'Judge o2.5 → o3.5 ▲+1' },
|
||||
]);
|
||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
|
||||
const items = cache.store['ticker:items'];
|
||||
expect(items.filter((e) => e.tag === 'SCAN' && e.sport === 'mlb')).toHaveLength(1);
|
||||
expect(items.find((e) => e.tag === 'SCAN').text).toContain('25 props');
|
||||
});
|
||||
|
||||
it('preserves MOVE/GRADE events and other sports', async () => {
|
||||
const cache = memCache([
|
||||
{ tag: 'MOVE', text: 'move 1' },
|
||||
{ tag: 'A+', text: 'BOMBER graded A+' },
|
||||
{ tag: 'SCAN', sport: 'nba', text: 'NBA slate scanned · 10 props graded' },
|
||||
]);
|
||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
|
||||
const items = cache.store['ticker:items'];
|
||||
expect(items.find((e) => e.tag === 'MOVE')).toBeTruthy();
|
||||
expect(items.find((e) => e.tag === 'A+')).toBeTruthy();
|
||||
expect(items.find((e) => e.tag === 'SCAN' && e.sport === 'nba')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dedupes legacy SCAN items that lack a sport field (parse from text)', async () => {
|
||||
const cache = memCache([{ tag: 'SCAN', text: 'MLB slate scanned · 20 props graded' }]);
|
||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
|
||||
expect(cache.store['ticker:items'].filter((e) => e.tag === 'SCAN')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stays capped at 50 items', async () => {
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({ tag: 'MOVE', text: `m${i}` }));
|
||||
const cache = memCache(many);
|
||||
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'scanned' }], cache);
|
||||
expect(cache.store['ticker:items'].length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,12 +1,28 @@
|
||||
/* 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
|
||||
/* Player-name normalization (Session 46, completed Session 47) — frontend copy
|
||||
of src/utils/playerName.js (the Next bundle can't import from src/). Keep the
|
||||
two IDENTICAL; a test cross-checks they agree. CommonJS so .tsx imports it AND
|
||||
Jest requires it directly. */
|
||||
|
||||
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
|
||||
|
||||
const NICKNAMES = {
|
||||
matt: 'matthew', mike: 'michael', chris: 'christopher', jake: 'jacob',
|
||||
josh: 'joshua', nick: 'nicholas', nate: 'nathaniel', dan: 'daniel',
|
||||
danny: 'daniel', dave: 'david', rob: 'robert', bob: 'robert', joe: 'joseph',
|
||||
joey: 'joseph', jon: 'jonathan', johnny: 'john', tony: 'anthony',
|
||||
alex: 'alexander', andy: 'andrew', drew: 'andrew', ben: 'benjamin',
|
||||
will: 'william', bill: 'william', billy: 'william', willy: 'william',
|
||||
zach: 'zachary', zack: 'zachary', tom: 'thomas', tommy: 'thomas',
|
||||
ty: 'tyler', ed: 'edward', eddie: 'edward', teddy: 'theodore',
|
||||
charlie: 'charles', chuck: 'charles', rick: 'richard', dick: 'richard',
|
||||
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
|
||||
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis',
|
||||
};
|
||||
|
||||
function normalizeName(raw) {
|
||||
const display = String(raw == null ? '' : raw)
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ')
|
||||
.replace(/\./g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
@@ -16,7 +32,10 @@ function normalizeName(raw) {
|
||||
}
|
||||
|
||||
function nameKey(raw) {
|
||||
return normalizeName(raw).key;
|
||||
const { key } = normalizeName(raw);
|
||||
const parts = key.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2 && NICKNAMES[parts[0]]) parts[0] = NICKNAMES[parts[0]];
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
module.exports = { normalizeName, nameKey, SUFFIXES };
|
||||
module.exports = { normalizeName, nameKey, SUFFIXES, NICKNAMES };
|
||||
|
||||
@@ -193,8 +193,10 @@ function isRelevantGame(game, now = Date.now()) {
|
||||
}
|
||||
|
||||
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
|
||||
// Session 46 — key by the normalized name so "A.J. Ewing"/"AJ Ewing" merge.
|
||||
const { nameKey } = require('./playerName');
|
||||
// Session 46/47 — key by the normalized name (nicknames/accents/periods/parens)
|
||||
// so variants merge; DISPLAY the de-dotted, paren-stripped form.
|
||||
const { nameKey, normalizeName } = require('./playerName');
|
||||
const displayName = (raw) => normalizeName(raw).display || String(raw || '');
|
||||
const gradeKey = (player, stat) => `${nameKey(player)}|${String(stat || '').toLowerCase()}`;
|
||||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
|
||||
|
||||
@@ -257,7 +259,7 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
|
||||
if (!byPlayer[pk]) {
|
||||
byPlayer[pk] = {
|
||||
player: p.player,
|
||||
player: displayName(p.player),
|
||||
team: p.team || '',
|
||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||
stats: [],
|
||||
@@ -265,7 +267,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
};
|
||||
order.push(pk);
|
||||
} else {
|
||||
if (String(p.player).length > String(byPlayer[pk].player).length) byPlayer[pk].player = p.player;
|
||||
// Display the longest (most complete) de-dotted variant seen.
|
||||
const cand = displayName(p.player);
|
||||
if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand;
|
||||
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
|
||||
}
|
||||
if (rec) {
|
||||
|
||||
Reference in New Issue
Block a user