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:
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* MLB probable-pitchers proxy (Session 46) — forwards
|
||||
* GET /api/schedule/:sport/pitchers to Express (statsapi.mlb.com starters).
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
|
||||
const { sport } = await params;
|
||||
const sportLc = String(sport || '').toLowerCase();
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(sportLc)}/pitchers${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ games: [] }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ sport: sportLc, games: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation';
|
||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
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';
|
||||
// Session 23 — all-day intelligence layer. The stat filter is 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 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
|
||||
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
|
||||
type GradeIndex = ReturnType<typeof indexGrades>;
|
||||
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 {
|
||||
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
||||
sport: g.sport,
|
||||
@@ -181,6 +187,8 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
|
||||
venue: g.venue,
|
||||
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
||||
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 || '' })),
|
||||
};
|
||||
}
|
||||
@@ -352,6 +360,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
// Session 45 — merged pre-graded snapshot across the loaded sports.
|
||||
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
|
||||
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
|
||||
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
// 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(
|
||||
sportsToFetch.map(async (sport) => {
|
||||
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))),
|
||||
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<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
|
||||
// Session 45 — pre-graded snapshot (locked grades + line deltas).
|
||||
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);
|
||||
@@ -425,19 +436,21 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
const oddsGames = groupByGame(oddsProps, sport);
|
||||
const scheduleGames = schedule?.games || [];
|
||||
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 allSnapGrades: SnapshotGrade[] = [];
|
||||
const allSnapDeltas: SnapshotDelta[] = [];
|
||||
const allPitcherGames: PitcherGame[] = [];
|
||||
let anyOddsOk = false;
|
||||
let anyScheduleShown = false;
|
||||
for (const s of perSport) {
|
||||
allGames.push(...s.merged);
|
||||
allSnapGrades.push(...s.snapGrades);
|
||||
allSnapDeltas.push(...s.snapDeltas);
|
||||
allPitcherGames.push(...s.pitcherGames);
|
||||
if (s.oddsOk) anyOddsOk = true;
|
||||
if (s.hadSchedule) anyScheduleShown = true;
|
||||
}
|
||||
@@ -445,6 +458,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
setGames(allGames);
|
||||
setSnapGrades(allSnapGrades);
|
||||
setSnapDeltas(allSnapDeltas);
|
||||
setPitcherGames(allPitcherGames);
|
||||
|
||||
// Odds down but schedule carried the slate → soft notice, not a wall.
|
||||
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.
|
||||
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
|
||||
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
|
||||
const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]);
|
||||
|
||||
const filteredGames = useMemo(() => {
|
||||
// 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) => (
|
||||
<VyndrGameCard
|
||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||
game={slateGameToCardData(g, gradeIndex, deltaIndex)}
|
||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
|
||||
onOpen={() => router.push('/scan')}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Player-name normalization (Session 46) — frontend copy of
|
||||
src/utils/playerName.js (the Next bundle can't import from src/). Keep the two
|
||||
in sync; tests/unit cross-checks they agree. CommonJS so .tsx imports it AND
|
||||
Jest requires it directly. */
|
||||
|
||||
const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']);
|
||||
|
||||
function normalizeName(raw) {
|
||||
const display = String(raw == null ? '' : raw)
|
||||
.replace(/\./g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
|
||||
return { display, key };
|
||||
}
|
||||
|
||||
function nameKey(raw) {
|
||||
return normalizeName(raw).key;
|
||||
}
|
||||
|
||||
module.exports = { normalizeName, nameKey, SUFFIXES };
|
||||
+47
-10
@@ -193,8 +193,9 @@ function isRelevantGame(game, now = Date.now()) {
|
||||
}
|
||||
|
||||
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
|
||||
const snorm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const gradeKey = (player, stat) => `${snorm(player)}|${String(stat || '').toLowerCase()}`;
|
||||
// Session 46 — key by the normalized name so "A.J. Ewing"/"AJ Ewing" merge.
|
||||
const { nameKey } = require('./playerName');
|
||||
const gradeKey = (player, stat) => `${nameKey(player)}|${String(stat || '').toLowerCase()}`;
|
||||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
|
||||
|
||||
/** Index snapshot grades by player|stat → the locked grade record. */
|
||||
@@ -250,23 +251,27 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
const order = [];
|
||||
for (const p of gameProps || []) {
|
||||
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)];
|
||||
if (!byPlayer[p.player]) {
|
||||
byPlayer[p.player] = {
|
||||
if (!byPlayer[pk]) {
|
||||
byPlayer[pk] = {
|
||||
player: p.player,
|
||||
team: p.team || '',
|
||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||
stats: [],
|
||||
props: [],
|
||||
};
|
||||
order.push(p.player);
|
||||
} else if (!byPlayer[p.player].archetype && rec && rec.archetype) {
|
||||
byPlayer[p.player].archetype = { primary: rec.archetype };
|
||||
order.push(pk);
|
||||
} else {
|
||||
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) {
|
||||
const side = sideCh(rec.direction);
|
||||
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),
|
||||
line: rec.line,
|
||||
side,
|
||||
@@ -277,12 +282,42 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
@@ -299,4 +334,6 @@ module.exports = {
|
||||
statShort,
|
||||
gradedAgo,
|
||||
buildPlayerStripsFromProps,
|
||||
buildPitcherMap,
|
||||
pitchersForGameTeams,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user