import type { Game, Team } from './apiResponseTypes' import type { Player } from './playersService' import { MODERN_STAT_ERA_START, SITE_URL, isNotUndefined } from './utilities' // TODO make a stats object that has properties for current regular season, current post season, // last 4 games, historical seasons, career totals // could split into batting/pitching/fielding so only necessary ones are called to save time export interface FieldingStat { game: Game | 'TOT' player: Player team: Team pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT' xCheckCount: number hit: number error: number stolenBaseCheckCount: number stolenBaseCount: number caughtStealingCount: number passedBallCount: number winProbabilityAdded: number weightedFieldingPercent: number caughtStealingPercent: number } interface FieldingStatRaw { game: Game | 'TOT' player: Player team: Team pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT' "x-ch": number hit: number error: number "sb-ch": number sb: number cs: number pb: number wpa: number "wf%": number "cs%": number | '' } interface LegacyFieldingStat { player: Player team: Team pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT' | 'PH' xch: number xhit: number error: number pb: number sbc: number csc: number } export async function fetchFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise { // different endpoint for pre-modern stats (/plays) era if (seasonNumber < MODERN_STAT_ERA_START) { return await fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber, playerId, isRegularSeason) } const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&player_id=${playerId}&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`) const fieldingStatsResponse: { count: number stats: FieldingStatRaw[] } = await response.json() if (fieldingStatsResponse.count === 0) return [] return fieldingStatsResponse.stats.map(normalizeFieldingStat) } async function fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise { const response = await fetch(`${SITE_URL}/api/v3/fieldingstats/totals?season=${seasonNumber}&player_id=${playerId}&s_type=${isRegularSeason ? 'regular' : 'post'}`) const legacyFieldingStatsResponse: { count: number stats: LegacyFieldingStat[] } = await response.json() if (legacyFieldingStatsResponse.count === 0) return [] return legacyFieldingStatsResponse.stats .map(makeModernFieldingStatFromLegacy) .filter(isNotUndefined) } export async function fetchFieldingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise { const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&player_id=${playerId}&group_by=playerpositiongame&limit=4&s_type=regular&sort=newest`) const fieldingStatsResponse: { count: number stats: FieldingStatRaw[] } = await response.json() if (fieldingStatsResponse.count > 4) { throw new Error(`fieldingStatsService.fetchFieldingStatsForLastFourBySeasonAndPlayerId - Expected at most 4 games, return contained ${fieldingStatsResponse.count}`) } return fieldingStatsResponse.stats.map(normalizeFieldingStat) } export function aggregateFieldingStats(fieldingStats: FieldingStat[]): FieldingStat[] { const fieldingStatsByPosition = fieldingStats.reduce((statsByPos: { [key: string]: FieldingStat }, stat: FieldingStat) => { if (!statsByPos[stat.pos]) { statsByPos[stat.pos] = { game: 'TOT', player: stat.player, team: stat.team, pos: stat.pos, xCheckCount: stat.xCheckCount, hit: stat.hit, error: stat.error, stolenBaseCheckCount: stat.stolenBaseCheckCount, stolenBaseCount: stat.stolenBaseCount, caughtStealingCount: stat.caughtStealingCount, passedBallCount: stat.passedBallCount, winProbabilityAdded: stat.winProbabilityAdded, weightedFieldingPercent: stat.weightedFieldingPercent, caughtStealingPercent: stat.caughtStealingPercent } } else { statsByPos[stat.pos].xCheckCount += stat.xCheckCount statsByPos[stat.pos].hit += stat.hit statsByPos[stat.pos].error += stat.error statsByPos[stat.pos].stolenBaseCheckCount += stat.stolenBaseCheckCount statsByPos[stat.pos].stolenBaseCount += stat.stolenBaseCount statsByPos[stat.pos].caughtStealingCount += stat.caughtStealingCount statsByPos[stat.pos].passedBallCount += stat.passedBallCount statsByPos[stat.pos].winProbabilityAdded += stat.winProbabilityAdded statsByPos[stat.pos].weightedFieldingPercent = weightedFieldingPercentage({ xChecks: statsByPos[stat.pos].xCheckCount, hits: statsByPos[stat.pos].hit, errors: statsByPos[stat.pos].error }) statsByPos[stat.pos].caughtStealingPercent = caughtStealingPercentage({ stolenBaseAttempts: statsByPos[stat.pos].stolenBaseCheckCount, caughtStealingCount: statsByPos[stat.pos].caughtStealingCount }) } return statsByPos }, {}) return Object.values(fieldingStatsByPosition) } function makeModernFieldingStatFromLegacy(legacyStat: LegacyFieldingStat): FieldingStat | undefined { // filter out "PH" fielding stats here which used to occur when position/xchecks were loosely checked/observed if (legacyStat.pos === 'PH') return undefined return { game: 'TOT', player: legacyStat.player, team: legacyStat.team, pos: legacyStat.pos, xCheckCount: legacyStat.xch, hit: legacyStat.xhit, error: legacyStat.error, stolenBaseCheckCount: legacyStat.sbc, stolenBaseCount: legacyStat.sbc - legacyStat.csc, caughtStealingCount: legacyStat.csc, passedBallCount: legacyStat.pb, winProbabilityAdded: 0, weightedFieldingPercent: weightedFieldingPercentage({ xChecks: legacyStat.xch, hits: legacyStat.xhit, errors: legacyStat.error }), caughtStealingPercent: caughtStealingPercentage({ stolenBaseAttempts: legacyStat.sbc, caughtStealingCount: legacyStat.csc }) } } function normalizeFieldingStat(rawStat: FieldingStatRaw): FieldingStat { return { game: rawStat.game, player: rawStat.player, team: rawStat.team, pos: rawStat.pos, xCheckCount: rawStat['x-ch'], hit: rawStat.hit, error: rawStat.error, stolenBaseCheckCount: rawStat['sb-ch'], stolenBaseCount: rawStat.sb, caughtStealingCount: rawStat.cs, passedBallCount: rawStat.pb, winProbabilityAdded: rawStat.wpa, weightedFieldingPercent: rawStat['wf%'], caughtStealingPercent: rawStat['cs%'] === '' ? NaN : rawStat['cs%'] } } export function weightedFieldingPercentage(stat: { xChecks: number, hits: number, errors: number }): number { if (stat.xChecks === 0) return NaN return (stat.xChecks - stat.errors * 0.5 - stat.hits * 0.75) / stat.xChecks } export function caughtStealingPercentage(stat: { stolenBaseAttempts: number, caughtStealingCount: number }): number { if (stat.stolenBaseAttempts === 0) return NaN return stat.caughtStealingCount / stat.stolenBaseAttempts }