From 481ed003476722b8a9c9e1adf11ce4b529636d07 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 12:54:26 -0400 Subject: [PATCH] fix(baseball): persist career OBP/SLG/OPS in aggregate recalc (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recalculatePlayerAggregates only wrote career_avg, but My Stats StatsOverviewCards reads career_obp/career_slg/career_ops — columns that never existed on baseball_player_aggregates, so OBP/SLG/OPS permanently showed "---" despite sufficient counting stats. - Add idempotent migration adding career_obp/career_slg/career_ops (numeric) to baseball_player_aggregates (ADD COLUMN IF NOT EXISTS). - Extract a pure computeCareerSlashLine helper (standard formulas, null on zero denominator) and call it from recalculatePlayerAggregates, upserting the derived slash line. - Unit test covers a multi-session aggregate with walks/SF/HBP plus the zero-denominator honesty cases. Migration is committed only, not applied. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/app/baseball/actions/stats.ts | 8 +++ .../__tests__/career-slash-line.test.ts | 61 +++++++++++++++++++ .../baseball/aggregates/career-slash-line.ts | 61 +++++++++++++++++++ ..._baseball_player_aggregates_slash_line.sql | 20 ++++++ 4 files changed, 150 insertions(+) create mode 100644 src/lib/baseball/__tests__/career-slash-line.test.ts create mode 100644 src/lib/baseball/aggregates/career-slash-line.ts create mode 100644 supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql diff --git a/src/app/baseball/actions/stats.ts b/src/app/baseball/actions/stats.ts index 188c2f972..724853798 100644 --- a/src/app/baseball/actions/stats.ts +++ b/src/app/baseball/actions/stats.ts @@ -19,6 +19,7 @@ import { BaseballActionError, } from '@/lib/baseball/with-baseball-action'; import { BaseballCapabilityError, requireBaseballCapability } from '@/lib/baseball/capabilities'; +import { computeCareerSlashLine } from '@/lib/baseball/aggregates/career-slash-line'; /** * Shared error-message mapping for every action in this file. All exported @@ -551,6 +552,10 @@ const recalculatePlayerAggregatesAction = withBaseballAction( const totalAtBats = stats.reduce((sum, s) => sum + (s.at_bats || 0), 0); const totalHits = stats.reduce((sum, s) => sum + (s.hits || 0), 0); + // Slash line — OBP/SLG/OPS from summed counting stats (null on zero + // denominators). Pure helper is unit-tested; see #436. + const { obp: careerObp, slg: careerSlg, ops: careerOps } = computeCareerSlashLine(stats); + // Last 5 and 10 sessions const last5 = stats.slice(0, 5); const last10 = stats.slice(0, 10); @@ -584,6 +589,9 @@ const recalculatePlayerAggregatesAction = withBaseballAction( team_id: teamId, total_sessions: stats.length, career_avg: careerAvg, + career_obp: careerObp, + career_slg: careerSlg, + career_ops: careerOps, practice_avg: practiceAvg, game_avg: gameAvg, pressure_gap: pressureGap, diff --git a/src/lib/baseball/__tests__/career-slash-line.test.ts b/src/lib/baseball/__tests__/career-slash-line.test.ts new file mode 100644 index 000000000..219e08a0f --- /dev/null +++ b/src/lib/baseball/__tests__/career-slash-line.test.ts @@ -0,0 +1,61 @@ +// ============================================================================= +// career-slash-line.test.ts +// +// Guards the career OBP/SLG/OPS derivation used by +// `recalculatePlayerAggregates` (BaseballHelm #436). Pure function; no DB. +// The headline case is a multi-session aggregate that exercises walks, sac +// flies, and hit-by-pitch — the fields that pull OBP away from AVG. +// ============================================================================= + +import { describe, it, expect } from 'vitest'; +import { + computeCareerSlashLine, + type SlashLineStatRow, +} from '@/lib/baseball/aggregates/career-slash-line'; + +const row = (partial: Partial): SlashLineStatRow => ({ + at_bats: 0, + hits: 0, + doubles: 0, + triples: 0, + home_runs: 0, + walks: 0, + hit_by_pitch: 0, + sacrifice_flies: 0, + ...partial, +}); + +describe('computeCareerSlashLine', () => { + it('aggregates OBP/SLG/OPS across multiple sessions with walks, SF, and HBP', () => { + // Session 1: 4 AB, 2 H (1 double), 1 BB, 1 HBP + // Session 2: 6 AB, 2 H (1 HR), 1 BB, 1 SF + // Totals: AB 10, H 4, 2B 1, HR 1, BB 2, HBP 1, SF 1 + const stats = [ + row({ at_bats: 4, hits: 2, doubles: 1, walks: 1, hit_by_pitch: 1 }), + row({ at_bats: 6, hits: 2, home_runs: 1, walks: 1, sacrifice_flies: 1 }), + ]; + + const { obp, slg, ops } = computeCareerSlashLine(stats); + + // OBP = (H + BB + HBP) / (AB + BB + HBP + SF) = (4 + 2 + 1) / (10 + 2 + 1 + 1) = 7/14 = .500 + expect(obp).toBeCloseTo(0.5, 6); + // TB = H + 2B + 2·3B + 3·HR = 4 + 1 + 0 + 3 = 8; SLG = 8/10 = .800 + expect(slg).toBeCloseTo(0.8, 6); + // OPS = .500 + .800 = 1.300 + expect(ops).toBeCloseTo(1.3, 6); + }); + + it('returns null rates when every denominator is zero (no fabricated .000)', () => { + expect(computeCareerSlashLine([])).toEqual({ obp: null, slg: null, ops: null }); + expect(computeCareerSlashLine([row({})])).toEqual({ obp: null, slg: null, ops: null }); + }); + + it('yields a non-null OBP but null SLG/OPS when a player only walks (zero AB)', () => { + // A plate appearance that is all walks: OBP denominator is non-zero, AB is zero. + const { obp, slg, ops } = computeCareerSlashLine([row({ walks: 2, hit_by_pitch: 1 })]); + // OBP = (0 + 2 + 1) / (0 + 2 + 1 + 0) = 3/3 = 1.000 + expect(obp).toBeCloseTo(1, 6); + expect(slg).toBeNull(); // zero AB → null, not 0 + expect(ops).toBeNull(); // null when either component is null + }); +}); diff --git a/src/lib/baseball/aggregates/career-slash-line.ts b/src/lib/baseball/aggregates/career-slash-line.ts new file mode 100644 index 000000000..48c8c2c7c --- /dev/null +++ b/src/lib/baseball/aggregates/career-slash-line.ts @@ -0,0 +1,61 @@ +// ============================================================================= +// career-slash-line.ts +// +// Pure derivation of a player's career OBP / SLG / OPS from summed counting +// stats. Extracted from `recalculatePlayerAggregates` (src/app/baseball/ +// actions/stats.ts) so it can be unit-tested without a database. +// +// Honesty contract: every rate is null on a zero denominator — never a +// fabricated .000. See BaseballHelm #436. +// ============================================================================= + +import type { BaseballPlayerStats } from '@/lib/types'; + +/** The counting-stat fields the slash line is derived from. */ +export type SlashLineStatRow = Pick< + BaseballPlayerStats, + 'at_bats' | 'hits' | 'doubles' | 'triples' | 'home_runs' | 'walks' | 'hit_by_pitch' | 'sacrifice_flies' +>; + +export interface CareerSlashLine { + /** On-base percentage: (H + BB + HBP) / (AB + BB + HBP + SF); null on zero PA. */ + obp: number | null; + /** Slugging: total bases / AB; null on zero AB. */ + slg: number | null; + /** OPS = OBP + SLG; null when either component is null. */ + ops: number | null; +} + +/** + * Derive career OBP/SLG/OPS from a set of per-session counting stats. + * Sums across every row, then applies the standard formulas. Missing + * counting values are treated as 0; denominators of 0 yield null. + */ +export function computeCareerSlashLine( + stats: ReadonlyArray, +): CareerSlashLine { + const sum = (pick: (s: SlashLineStatRow) => number | null | undefined): number => + stats.reduce((acc, s) => acc + (pick(s) || 0), 0); + + const atBats = sum((s) => s.at_bats); + const hits = sum((s) => s.hits); + const doubles = sum((s) => s.doubles); + const triples = sum((s) => s.triples); + const homeRuns = sum((s) => s.home_runs); + const walks = sum((s) => s.walks); + const hitByPitch = sum((s) => s.hit_by_pitch); + const sacFlies = sum((s) => s.sacrifice_flies); + + // OBP = (H + BB + HBP) / (AB + BB + HBP + SF) + const obpDenominator = atBats + walks + hitByPitch + sacFlies; + const obp = obpDenominator === 0 ? null : (hits + walks + hitByPitch) / obpDenominator; + + // SLG = total bases / AB, where total bases = singles + 2·2B + 3·3B + 4·HR + // = H + 2B + 2·3B + 3·HR + const totalBases = hits + doubles + 2 * triples + 3 * homeRuns; + const slg = atBats === 0 ? null : totalBases / atBats; + + const ops = obp != null && slg != null ? obp + slg : null; + + return { obp, slg, ops }; +} diff --git a/supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql b/supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql new file mode 100644 index 000000000..06a50f3c0 --- /dev/null +++ b/supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql @@ -0,0 +1,20 @@ +-- BaseballHelm #436 — persist the slash line on player aggregates. +-- +-- `recalculatePlayerAggregates` (src/app/baseball/actions/stats.ts) previously +-- only wrote `career_avg`, but My Stats `StatsOverviewCards` reads +-- `career_obp`, `career_slg`, and `career_ops`. Those columns never existed on +-- the live `baseball_player_aggregates` table, so OBP/SLG/OPS permanently +-- showed "---". Add them here (numeric, matching `career_avg`) so the recalc +-- can compute and upsert the full slash line. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS. No data backfill — the columns fill in +-- on the next aggregate recalculation for each player. + +ALTER TABLE "public"."baseball_player_aggregates" + ADD COLUMN IF NOT EXISTS "career_obp" numeric; + +ALTER TABLE "public"."baseball_player_aggregates" + ADD COLUMN IF NOT EXISTS "career_slg" numeric; + +ALTER TABLE "public"."baseball_player_aggregates" + ADD COLUMN IF NOT EXISTS "career_ops" numeric;