From 0c96d1cd5038da5fe4a107d0eaeb579e94eeacc2 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Tue, 15 Sep 2026 06:06:10 -0400 Subject: [PATCH] refactor(stats): replace local aggregate with the kiosk's session stats The Shots screen summarised a session with its own hand-written `summarise()`, written independently of the kiosk's `computeStats` and drifted from it: it reported `null` where the kiosk reports 0 for an empty session, and it had no standard deviation at all. Two interfaces onto the same session described it differently. Port the kiosk's arithmetic into utils/sessionStats.ts, hand-mirrored from ui/src/types/shot.ts in open-flight/openflight, and summarise from that instead. The sample standard deviation (n-1, and 0 below two shots) comes across with it. Only `computeStats` is ported. The kiosk's swing-speed stats read `training_implement`, which this app's Shot does not carry, and its profile filters have no caller here; porting either would add surface nothing uses. What the user sees is unchanged. The aggregator returns numbers and nulls and the screen still decides how an absent measurement reads, so a missing club speed or an empty session stays an em dash rather than becoming the kiosk's 0. Standard deviation is computed and tested but not yet given a tile; adding a seventh tile is a UI change for its own PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QvMGAzMMWRuxRht8aGciAv --- __tests__/ShotsScreen.test.tsx | 15 ++++ __tests__/sessionStats.test.ts | 159 +++++++++++++++++++++++++++++++++ app/(tabs)/shots.tsx | 49 ++++------ utils/sessionStats.ts | 73 +++++++++++++++ 4 files changed, 263 insertions(+), 33 deletions(-) create mode 100644 __tests__/sessionStats.test.ts create mode 100644 utils/sessionStats.ts diff --git a/__tests__/ShotsScreen.test.tsx b/__tests__/ShotsScreen.test.tsx index 4630cdd..3bcc3d9 100644 --- a/__tests__/ShotsScreen.test.tsx +++ b/__tests__/ShotsScreen.test.tsx @@ -204,6 +204,21 @@ describe('Shots screen', () => { expect(await screen.findAllByText('—')).toHaveLength(2); }); + it('leaves the stat tiles blank for a session with nothing stored in it', async () => { + // The shared aggregator reports zeroes for an empty session, the way the + // kiosk does. Mobile still reads an absent measurement as a dash, so only + // the shot count shows a figure here. + mockLoadSessions.mockResolvedValue([SESSION]); + mockLoadShots.mockResolvedValue([]); + + await renderScreen(); + await fireEvent.press(await screen.findByText('2 shots')); + + const tiles = within(await screen.findByTestId('session-stats')); + expect(tiles.getByText('0')).toBeTruthy(); + expect(tiles.getAllByText('—')).toHaveLength(5); + }); + it('names the player on each row so a shared session can be told apart', async () => { // Two people hitting in one bay produce a single session; the row has to // say whose shot it was. diff --git a/__tests__/sessionStats.test.ts b/__tests__/sessionStats.test.ts new file mode 100644 index 0000000..c4d7653 --- /dev/null +++ b/__tests__/sessionStats.test.ts @@ -0,0 +1,159 @@ +import { computeStats } from '../utils/sessionStats'; +import type { Shot } from '../types'; + +function makeShot(overrides: Partial = {}): Shot { + return { + shot_number: 1, + ball_speed_mph: 148.2, + club_speed_mph: 104.1, + smash_factor: 1.42, + estimated_carry_yards: 266, + carry_spin_adjusted: null, + carry_range: [258, 274], + club: 'driver', + profile_id: null, + profile_name: null, + timestamp: '2026-09-14T10:00:00Z', + launch_angle_vertical: 12.4, + launch_angle_horizontal: null, + launch_angle_confidence: null, + angle_source: null, + club_angle_deg: null, + club_path_deg: null, + spin_axis_deg: null, + spin_rpm: 2680, + spin_source: 'measured', + spin_quality: 'medium', + ...overrides, + }; +} + +describe('computeStats', () => { + it('reports an empty session as zero rather than as no session', () => { + // The kiosk's contract: counters read zero and only the two genuinely + // optional measurements are null. The screen, not the maths, is what turns + // this back into a dash on mobile. + expect(computeStats([])).toEqual({ + shot_count: 0, + avg_ball_speed: 0, + max_ball_speed: 0, + min_ball_speed: 0, + avg_club_speed: null, + avg_smash_factor: null, + avg_carry_est: 0, + }); + }); + + it('describes a single shot by itself, with no spread', () => { + const stats = computeStats([ + makeShot({ + ball_speed_mph: 148.2, + estimated_carry_yards: 266, + club_speed_mph: 104.1, + smash_factor: 1.42, + }), + ]); + + expect(stats.shot_count).toBe(1); + expect(stats.avg_ball_speed).toBeCloseTo(148.2, 10); + expect(stats.max_ball_speed).toBeCloseTo(148.2, 10); + expect(stats.min_ball_speed).toBeCloseTo(148.2, 10); + expect(stats.avg_carry_est).toBe(266); + expect(stats.avg_club_speed).toBeCloseTo(104.1, 10); + expect(stats.avg_smash_factor).toBeCloseTo(1.42, 10); + // One shot has no spread to describe; dividing by n-1 would divide by zero. + expect(stats.std_dev).toBe(0); + }); + + it('averages, bounds and spreads a session of several shots', () => { + const stats = computeStats([ + makeShot({ ball_speed_mph: 100, estimated_carry_yards: 200 }), + makeShot({ ball_speed_mph: 110, estimated_carry_yards: 220 }), + makeShot({ ball_speed_mph: 120, estimated_carry_yards: 240 }), + ]); + + expect(stats.shot_count).toBe(3); + expect(stats.avg_ball_speed).toBe(110); + expect(stats.max_ball_speed).toBe(120); + expect(stats.min_ball_speed).toBe(100); + expect(stats.avg_carry_est).toBe(220); + // Sample standard deviation: sqrt((100 + 0 + 100) / 2) = 10. + expect(stats.std_dev).toBe(10); + }); + + it('spreads on the sample, not the population', () => { + // Two shots 10 apart: the population figure would be 5, the sample figure + // sqrt(50). Getting this wrong understates a session's consistency. + const stats = computeStats([ + makeShot({ ball_speed_mph: 140 }), + makeShot({ ball_speed_mph: 150 }), + ]); + + expect(stats.std_dev).toBeCloseTo(Math.sqrt(50), 10); + }); + + it('reports no club speed at all when no shot measured one', () => { + const stats = computeStats([ + makeShot({ club_speed_mph: null }), + makeShot({ club_speed_mph: null }), + ]); + + // Null, not zero: nothing was measured, which is not the same as a slow swing. + expect(stats.avg_club_speed).toBeNull(); + expect(stats.shot_count).toBe(2); + }); + + it('averages club speed over only the shots that measured one', () => { + const stats = computeStats([ + makeShot({ club_speed_mph: null }), + makeShot({ club_speed_mph: 100 }), + makeShot({ club_speed_mph: 110 }), + ]); + + // 105, not 70: the unmeasured shot must not be counted as a zero. + expect(stats.avg_club_speed).toBe(105); + }); + + it('averages smash factor over only the shots that have one', () => { + const stats = computeStats([ + makeShot({ smash_factor: 1.4 }), + makeShot({ smash_factor: null }), + makeShot({ smash_factor: 1.5 }), + ]); + + expect(stats.avg_smash_factor).toBeCloseTo(1.45, 10); + }); + + it('reports no smash factor at all when no shot has one', () => { + const stats = computeStats([ + makeShot({ smash_factor: null }), + makeShot({ smash_factor: null }), + ]); + + expect(stats.avg_smash_factor).toBeNull(); + }); + + it('keeps ball speed and carry whole even when the optional measurements are missing', () => { + // A shot that arrived before enrichment still counts toward the figures it + // does carry. + const stats = computeStats([ + makeShot({ + ball_speed_mph: 140, + estimated_carry_yards: 250, + club_speed_mph: null, + smash_factor: null, + }), + makeShot({ + ball_speed_mph: 150, + estimated_carry_yards: 260, + club_speed_mph: 104, + smash_factor: 1.44, + }), + ]); + + expect(stats.avg_ball_speed).toBe(145); + expect(stats.avg_carry_est).toBe(255); + expect(stats.avg_club_speed).toBe(104); + expect(stats.avg_smash_factor).toBeCloseTo(1.44, 10); + }); +}); diff --git a/app/(tabs)/shots.tsx b/app/(tabs)/shots.tsx index fd93cfc..c99f5bc 100644 --- a/app/(tabs)/shots.tsx +++ b/app/(tabs)/shots.tsx @@ -15,6 +15,7 @@ import { useThemedStyles } from '../../components/theme/useTheme'; import { getShotRepository } from '../../storage/db'; import type { SessionSummary } from '../../storage/shotRepository'; import type { Shot } from '../../types'; +import { computeStats, type SessionStats } from '../../utils/sessionStats'; // Shot history kept on the device, readable with no simulator in sight. The // columns and stat tiles mirror the kiosk's Shots and Stats panels so both @@ -58,32 +59,11 @@ function shotTime(timestamp: string): string { : at.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } -interface SessionStats { - count: number; - avgBall: number | null; - maxBall: number | null; - avgCarry: number | null; - avgClub: number | null; - avgSmash: number | null; -} - -// The kiosk's six-tile summary, computed from the stored shots rather than from -// the server's stats payload — that only ever describes the live session. -function summarise(shots: Shot[]): SessionStats { - const mean = (values: number[]) => - values.length === 0 ? null : values.reduce((total, value) => total + value, 0) / values.length; - const present = (values: (T | null)[]) => - values.filter((value): value is T => value !== null); - - const ballSpeeds = shots.map((shot) => shot.ball_speed_mph); - return { - count: shots.length, - avgBall: mean(ballSpeeds), - maxBall: ballSpeeds.length === 0 ? null : Math.max(...ballSpeeds), - avgCarry: mean(shots.map((shot) => shot.estimated_carry_yards)), - avgClub: mean(present(shots.map((shot) => shot.club_speed_mph))), - avgSmash: mean(present(shots.map((shot) => shot.smash_factor))), - }; +// The aggregator reports zeroed counters for a session with no shots, as the +// kiosk does. Mobile reads an absent measurement as an em dash instead, so the +// zeroes are turned back into nothing here rather than in the maths. +function measured(stats: SessionStats, value: number): number | null { + return stats.shot_count === 0 ? null : value; } function StatTile({ label, value }: { label: string; value: string }) { @@ -102,14 +82,14 @@ function SessionStatsGrid({ stats }: { stats: SessionStats }) { return ( - - - - - + + + + + ); @@ -203,7 +183,10 @@ export default function ShotsScreen() { keyExtractor={(shot, index) => `${shot.timestamp}-${index}`} ListHeaderComponent={ <> - + {/* Summarised from the stored shots rather than from the + server's stats payload — that only ever describes the live + session. */} + Shot Ball diff --git a/utils/sessionStats.ts b/utils/sessionStats.ts new file mode 100644 index 0000000..6ab4374 --- /dev/null +++ b/utils/sessionStats.ts @@ -0,0 +1,73 @@ +// Session aggregation, hand-mirrored from the kiosk's `computeStats` and +// `SessionStats` in ui/src/types/shot.ts in open-flight/openflight. +// +// The two are deliberately separate copies rather than a shared module: mobile +// does not import from the server or web-UI repositories at build time, so a +// change to the kiosk's aggregation has to be ported here by hand for both +// interfaces to keep describing a session the same way. +// +// The arithmetic is the kiosk's, zeroed counters for an empty session included. +// Rendering stays mobile's: this module returns numbers and nulls, and the +// screen decides how an absent measurement reads. + +import type { Shot } from '../types'; + +export interface SessionStats { + shot_count: number; + avg_ball_speed: number; + max_ball_speed: number; + min_ball_speed: number; + std_dev?: number; + avg_club_speed: number | null; + avg_smash_factor: number | null; + avg_carry_est: number; +} + +function mean(values: number[]): number { + return values.reduce((total, value) => total + value, 0) / values.length; +} + +// Sample standard deviation, dividing by n-1. One shot has no spread to +// describe, so the kiosk reports 0 rather than dividing by zero. +function stdDev(values: number[]): number { + if (values.length < 2) return 0; + const m = mean(values); + return Math.sqrt(values.reduce((sum, value) => sum + (value - m) ** 2, 0) / (values.length - 1)); +} + +export function computeStats(shots: Shot[]): SessionStats { + if (shots.length === 0) { + return { + shot_count: 0, + avg_ball_speed: 0, + max_ball_speed: 0, + min_ball_speed: 0, + avg_club_speed: null, + avg_smash_factor: null, + avg_carry_est: 0, + }; + } + + const ballSpeeds = shots.map((shot) => shot.ball_speed_mph); + // A shot can reach the device before the optional measurements are enriched, + // so club speed and smash factor are averaged over the shots that have them + // and report nothing at all when none do. + const clubSpeeds = shots + .map((shot) => shot.club_speed_mph) + .filter((value): value is number => value !== null); + const smashFactors = shots + .map((shot) => shot.smash_factor) + .filter((value): value is number => value !== null); + const carries = shots.map((shot) => shot.estimated_carry_yards); + + return { + shot_count: shots.length, + avg_ball_speed: mean(ballSpeeds), + max_ball_speed: Math.max(...ballSpeeds), + min_ball_speed: Math.min(...ballSpeeds), + std_dev: stdDev(ballSpeeds), + avg_club_speed: clubSpeeds.length > 0 ? mean(clubSpeeds) : null, + avg_smash_factor: smashFactors.length > 0 ? mean(smashFactors) : null, + avg_carry_est: mean(carries), + }; +}