diff --git a/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java b/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java index 50df951..ab2229d 100644 --- a/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java +++ b/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java @@ -229,4 +229,20 @@ public ResponseEntity comparePlayers( return ResponseEntity.ok(playerApiService.comparePlayerStats(playerIds, seasonList, careerMode)); } + + @GetMapping("/{id}/batting-splits") + @Operation(summary = "Get player batting splits", description = "Returns batting splits for a player (home/away, vs LHP/RHP, etc.)") + public ResponseEntity> getPlayerBattingSplits( + @PathVariable Long id, + @RequestParam(required = false) Integer season) { + return ResponseEntity.ok(playerApiService.getPlayerBattingSplits(id, season)); + } + + @GetMapping("/{id}/pitching-splits") + @Operation(summary = "Get player pitching splits", description = "Returns pitching splits for a player (home/away, vs LHB/RHB, etc.)") + public ResponseEntity> getPlayerPitchingSplits( + @PathVariable Long id, + @RequestParam(required = false) Integer season) { + return ResponseEntity.ok(playerApiService.getPlayerPitchingSplits(id, season)); + } } diff --git a/backend/src/main/java/com/mlbstats/api/dto/BattingSplitDto.java b/backend/src/main/java/com/mlbstats/api/dto/BattingSplitDto.java new file mode 100644 index 0000000..cf9ca1d --- /dev/null +++ b/backend/src/main/java/com/mlbstats/api/dto/BattingSplitDto.java @@ -0,0 +1,84 @@ +package com.mlbstats.api.dto; + +import com.mlbstats.domain.stats.PlayerBattingSplit; +import com.mlbstats.domain.stats.SplitType; + +import java.math.BigDecimal; + +public record BattingSplitDto( + Long id, + Long playerId, + Long teamId, + Integer season, + SplitType splitType, + String splitTypeDisplay, + Integer gamesPlayed, + Integer plateAppearances, + Integer atBats, + Integer runs, + Integer hits, + Integer doubles, + Integer triples, + Integer homeRuns, + Integer rbi, + Integer walks, + Integer strikeouts, + Integer stolenBases, + BigDecimal battingAvg, + BigDecimal obp, + BigDecimal slg, + BigDecimal ops +) { + public static BattingSplitDto fromEntity(PlayerBattingSplit split) { + return new BattingSplitDto( + split.getId(), + split.getPlayer() != null ? split.getPlayer().getId() : null, + split.getTeam() != null ? split.getTeam().getId() : null, + split.getSeason(), + split.getSplitType(), + formatSplitType(split.getSplitType()), + split.getGamesPlayed(), + split.getPlateAppearances(), + split.getAtBats(), + split.getRuns(), + split.getHits(), + split.getDoubles(), + split.getTriples(), + split.getHomeRuns(), + split.getRbi(), + split.getWalks(), + split.getStrikeouts(), + split.getStolenBases(), + split.getBattingAvg(), + split.getObp(), + split.getSlg(), + split.getOps() + ); + } + + private static String formatSplitType(SplitType type) { + return switch (type) { + case HOME -> "Home"; + case AWAY -> "Away"; + case VS_LHP -> "vs LHP"; + case VS_RHP -> "vs RHP"; + case VS_LHB -> "vs LHB"; + case VS_RHB -> "vs RHB"; + case FIRST_HALF -> "First Half"; + case SECOND_HALF -> "Second Half"; + case MONTH_MAR -> "March"; + case MONTH_APR -> "April"; + case MONTH_MAY -> "May"; + case MONTH_JUN -> "June"; + case MONTH_JUL -> "July"; + case MONTH_AUG -> "August"; + case MONTH_SEP -> "September"; + case MONTH_OCT -> "October"; + case DAY -> "Day"; + case NIGHT -> "Night"; + case RUNNERS_ON -> "Runners On"; + case RISP -> "RISP"; + case BASES_EMPTY -> "Bases Empty"; + }; + } +} diff --git a/backend/src/main/java/com/mlbstats/api/dto/PitchingSplitDto.java b/backend/src/main/java/com/mlbstats/api/dto/PitchingSplitDto.java new file mode 100644 index 0000000..2426a2e --- /dev/null +++ b/backend/src/main/java/com/mlbstats/api/dto/PitchingSplitDto.java @@ -0,0 +1,82 @@ +package com.mlbstats.api.dto; + +import com.mlbstats.domain.stats.PlayerPitchingSplit; +import com.mlbstats.domain.stats.SplitType; + +import java.math.BigDecimal; + +public record PitchingSplitDto( + Long id, + Long playerId, + Long teamId, + Integer season, + SplitType splitType, + String splitTypeDisplay, + Integer gamesPlayed, + Integer gamesStarted, + BigDecimal inningsPitched, + Integer wins, + Integer losses, + Integer saves, + Integer holds, + Integer hitsAllowed, + Integer earnedRuns, + Integer walks, + Integer strikeouts, + BigDecimal era, + BigDecimal whip, + BigDecimal kPer9, + BigDecimal bbPer9 +) { + public static PitchingSplitDto fromEntity(PlayerPitchingSplit split) { + return new PitchingSplitDto( + split.getId(), + split.getPlayer() != null ? split.getPlayer().getId() : null, + split.getTeam() != null ? split.getTeam().getId() : null, + split.getSeason(), + split.getSplitType(), + formatSplitType(split.getSplitType()), + split.getGamesPlayed(), + split.getGamesStarted(), + split.getInningsPitched(), + split.getWins(), + split.getLosses(), + split.getSaves(), + split.getHolds(), + split.getHitsAllowed(), + split.getEarnedRuns(), + split.getWalks(), + split.getStrikeouts(), + split.getEra(), + split.getWhip(), + split.getKPer9(), + split.getBbPer9() + ); + } + + private static String formatSplitType(SplitType type) { + return switch (type) { + case HOME -> "Home"; + case AWAY -> "Away"; + case VS_LHP -> "vs LHP"; + case VS_RHP -> "vs RHP"; + case VS_LHB -> "vs LHB"; + case VS_RHB -> "vs RHB"; + case FIRST_HALF -> "First Half"; + case SECOND_HALF -> "Second Half"; + case MONTH_MAR -> "March"; + case MONTH_APR -> "April"; + case MONTH_MAY -> "May"; + case MONTH_JUN -> "June"; + case MONTH_JUL -> "July"; + case MONTH_AUG -> "August"; + case MONTH_SEP -> "September"; + case MONTH_OCT -> "October"; + case DAY -> "Day"; + case NIGHT -> "Night"; + case RUNNERS_ON -> "Runners On"; + case RISP -> "RISP"; + case BASES_EMPTY -> "Bases Empty"; + }; + } +} diff --git a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java index 44c0a01..a43f200 100644 --- a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java +++ b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java @@ -8,10 +8,14 @@ import com.mlbstats.domain.player.PlayerRepository; import com.mlbstats.domain.player.PlayerSearchCriteria; import com.mlbstats.domain.player.PlayerSpecification; +import com.mlbstats.domain.stats.PlayerBattingSplit; +import com.mlbstats.domain.stats.PlayerBattingSplitRepository; import com.mlbstats.domain.stats.PlayerBattingStats; import com.mlbstats.domain.stats.PlayerBattingStatsRepository; import com.mlbstats.domain.stats.PlayerGameBattingRepository; import com.mlbstats.domain.stats.PlayerGamePitchingRepository; +import com.mlbstats.domain.stats.PlayerPitchingSplit; +import com.mlbstats.domain.stats.PlayerPitchingSplitRepository; import com.mlbstats.domain.stats.PlayerPitchingStats; import com.mlbstats.domain.stats.PlayerPitchingStatsRepository; import lombok.RequiredArgsConstructor; @@ -37,6 +41,8 @@ public class PlayerApiService { private final PlayerPitchingStatsRepository pitchingStatsRepository; private final PlayerGameBattingRepository gameBattingRepository; private final PlayerGamePitchingRepository gamePitchingRepository; + private final PlayerBattingSplitRepository battingSplitRepository; + private final PlayerPitchingSplitRepository pitchingSplitRepository; public PageDto getAllPlayers(Pageable pageable) { Page page = playerRepository.findByActiveTrue(pageable); @@ -479,4 +485,24 @@ private java.math.BigDecimal getPitchingStatValue(PlayerComparisonDto.Comparison default -> null; }; } + + // Player Splits + + public List getPlayerBattingSplits(Long playerId, Integer season) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingSplitRepository.findByPlayerIdAndSeason(playerId, season).stream() + .map(BattingSplitDto::fromEntity) + .toList(); + } + + public List getPlayerPitchingSplits(Long playerId, Integer season) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingSplitRepository.findByPlayerIdAndSeason(playerId, season).stream() + .map(PitchingSplitDto::fromEntity) + .toList(); + } } diff --git a/frontend/src/components/player/PlayerSplits.tsx b/frontend/src/components/player/PlayerSplits.tsx new file mode 100644 index 0000000..3399fab --- /dev/null +++ b/frontend/src/components/player/PlayerSplits.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect } from 'react'; +import { BattingSplit, PitchingSplit } from '../../types/stats'; +import { getPlayerBattingSplits, getPlayerPitchingSplits } from '../../services/api'; +import { getDefaultSeason } from '../../utils/season'; + +interface PlayerSplitsProps { + playerId: number; + positionType: string | null; +} + +type SplitCategory = 'location' | 'handedness' | 'half' | 'situation'; + +function formatAvg(value: number | null): string { + if (value === null || value === undefined) return '.---'; + return value.toFixed(3).replace(/^0/, ''); +} + +function formatEra(value: number | null): string { + if (value === null || value === undefined) return '--.--'; + return value.toFixed(2); +} + +function PlayerSplits({ playerId, positionType }: PlayerSplitsProps) { + const [battingSplits, setBattingSplits] = useState([]); + const [pitchingSplits, setPitchingSplits] = useState([]); + const [loading, setLoading] = useState(true); + const [category, setCategory] = useState('location'); + const [season, setSeason] = useState(getDefaultSeason()); + + const isPitcher = positionType === 'Pitcher'; + + useEffect(() => { + async function fetchSplits() { + setLoading(true); + try { + if (isPitcher) { + const splits = await getPlayerPitchingSplits(playerId, season); + setPitchingSplits(splits); + } else { + const splits = await getPlayerBattingSplits(playerId, season); + setBattingSplits(splits); + } + } catch { + // Silently handle - splits may not exist + } finally { + setLoading(false); + } + } + fetchSplits(); + }, [playerId, season, isPitcher]); + + const splitTypesByCategory: Record = { + location: ['HOME', 'AWAY'], + handedness: isPitcher ? ['VS_LHB', 'VS_RHB'] : ['VS_LHP', 'VS_RHP'], + half: ['FIRST_HALF', 'SECOND_HALF'], + situation: ['RUNNERS_ON', 'RISP', 'BASES_EMPTY'], + }; + + const categoryLabels: Record = { + location: 'Home/Away', + handedness: isPitcher ? 'vs Batter Hand' : 'vs Pitcher Hand', + half: 'Season Half', + situation: 'Situation', + }; + + const filteredBattingSplits = battingSplits.filter((s) => + splitTypesByCategory[category].includes(s.splitType) + ); + + const filteredPitchingSplits = pitchingSplits.filter((s) => + splitTypesByCategory[category].includes(s.splitType) + ); + + const hasSplits = isPitcher + ? filteredPitchingSplits.length > 0 + : filteredBattingSplits.length > 0; + + if (loading) { + return ( +
+

Splits

+

Loading splits...

+
+ ); + } + + if (!hasSplits && battingSplits.length === 0 && pitchingSplits.length === 0) { + return null; // No splits data available + } + + return ( +
+
+

Splits

+ +
+ +
+ {(Object.keys(categoryLabels) as SplitCategory[]).map((cat) => ( + + ))} +
+ + {!hasSplits ? ( +

No {categoryLabels[category]} split data available.

+ ) : isPitcher ? ( + + + + + + + + + + + + + + + + + {filteredPitchingSplits.map((split) => ( + + + + + + + + + + + + + ))} + +
SplitGIPWLERAWHIPKBBK/9
{split.splitTypeDisplay}{split.gamesPlayed}{split.inningsPitched?.toFixed(1) ?? '--'}{split.wins}{split.losses}{formatEra(split.era)}{formatAvg(split.whip)}{split.strikeouts}{split.walks}{split.kPer9?.toFixed(1) ?? '--'}
+ ) : ( + + + + + + + + + + + + + + + + + + {filteredBattingSplits.map((split) => ( + + + + + + + + + + + + + + ))} + +
SplitGPAABHHRRBIAVGOBPSLGOPS
{split.splitTypeDisplay}{split.gamesPlayed}{split.plateAppearances}{split.atBats}{split.hits}{split.homeRuns}{split.rbi}{formatAvg(split.battingAvg)}{formatAvg(split.obp)}{formatAvg(split.slg)}{formatAvg(split.ops)}
+ )} +
+ ); +} + +export default PlayerSplits; diff --git a/frontend/src/pages/PlayerDetailPage.tsx b/frontend/src/pages/PlayerDetailPage.tsx index 17a3212..63cd179 100644 --- a/frontend/src/pages/PlayerDetailPage.tsx +++ b/frontend/src/pages/PlayerDetailPage.tsx @@ -5,6 +5,7 @@ import { BattingStats, PitchingStats } from '../types/stats'; import { getPlayer, getPlayerBattingStats, getPlayerPitchingStats } from '../services/api'; import { usePlayerFavorite } from '../hooks/useFavorite'; import PlayerStats from '../components/player/PlayerStats'; +import PlayerSplits from '../components/player/PlayerSplits'; import PlayerGameLog from '../components/player/PlayerGameLog'; import CareerStats from '../components/player/CareerStats'; import FavoriteButton from '../components/common/FavoriteButton'; @@ -132,6 +133,10 @@ function PlayerDetailPage() { + {playerId && ( + + )} + {playerId && ( )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 994e7b1..d6a2489 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,7 +1,7 @@ import { Team, RosterEntry, TeamStanding, TeamAggregateStats } from '../types/team'; import { Player } from '../types/player'; import { Game, BoxScore, Linescore, CalendarGame, GameCount } from '../types/game'; -import { BattingStats, PitchingStats, BattingGameLog, PitchingGameLog, PageResponse } from '../types/stats'; +import { BattingStats, PitchingStats, BattingGameLog, PitchingGameLog, PageResponse, BattingSplit, PitchingSplit } from '../types/stats'; import { PlayerComparisonResponse, PlayerSelection } from '../types/comparison'; import { FavoritesDashboard } from '../types/dashboard'; @@ -143,6 +143,16 @@ export async function getPlayerPitchingStats(id: number, season?: number): Promi return fetchJson(`${API_BASE}/players/${id}/pitching-stats${params}`); } +export async function getPlayerBattingSplits(id: number, season?: number): Promise { + const params = season ? `?season=${season}` : ''; + return fetchJson(`${API_BASE}/players/${id}/batting-splits${params}`); +} + +export async function getPlayerPitchingSplits(id: number, season?: number): Promise { + const params = season ? `?season=${season}` : ''; + return fetchJson(`${API_BASE}/players/${id}/pitching-splits${params}`); +} + export async function getPlayerBattingGameLog(id: number, season?: number): Promise { const params = season ? `?season=${season}` : ''; return fetchJson(`${API_BASE}/players/${id}/batting-game-log${params}`); diff --git a/frontend/src/types/stats.ts b/frontend/src/types/stats.ts index 37bc9ea..85ba13a 100644 --- a/frontend/src/types/stats.ts +++ b/frontend/src/types/stats.ts @@ -114,3 +114,62 @@ export interface PitchingGameLog { strikes: number | null; isStarter: boolean; } + +export type SplitType = + | 'HOME' | 'AWAY' + | 'VS_LHP' | 'VS_RHP' + | 'VS_LHB' | 'VS_RHB' + | 'FIRST_HALF' | 'SECOND_HALF' + | 'MONTH_MAR' | 'MONTH_APR' | 'MONTH_MAY' | 'MONTH_JUN' + | 'MONTH_JUL' | 'MONTH_AUG' | 'MONTH_SEP' | 'MONTH_OCT' + | 'DAY' | 'NIGHT' + | 'RUNNERS_ON' | 'RISP' | 'BASES_EMPTY'; + +export interface BattingSplit { + id: number; + playerId: number; + teamId: number | null; + season: number; + splitType: SplitType; + splitTypeDisplay: string; + gamesPlayed: number; + plateAppearances: number; + atBats: number; + runs: number; + hits: number; + doubles: number; + triples: number; + homeRuns: number; + rbi: number; + walks: number; + strikeouts: number; + stolenBases: number; + battingAvg: number | null; + obp: number | null; + slg: number | null; + ops: number | null; +} + +export interface PitchingSplit { + id: number; + playerId: number; + teamId: number | null; + season: number; + splitType: SplitType; + splitTypeDisplay: string; + gamesPlayed: number; + gamesStarted: number; + inningsPitched: number | null; + wins: number; + losses: number; + saves: number; + holds: number; + hitsAllowed: number; + earnedRuns: number; + walks: number; + strikeouts: number; + era: number | null; + whip: number | null; + kPer9: number | null; + bbPer9: number | null; +}