Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions __tests__/ShotsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
159 changes: 159 additions & 0 deletions __tests__/sessionStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { computeStats } from '../utils/sessionStats';
import type { Shot } from '../types';

function makeShot(overrides: Partial<Shot> = {}): 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);
});
});
49 changes: 16 additions & 33 deletions app/(tabs)/shots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = <T,>(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 }) {
Expand All @@ -102,14 +82,14 @@ function SessionStatsGrid({ stats }: { stats: SessionStats }) {

return (
<View style={styles.tiles} testID="session-stats">
<StatTile label="Shots" value={String(stats.count)} />
<StatTile label="Avg ball" value={speed(stats.avgBall)} />
<StatTile label="Max ball" value={speed(stats.maxBall)} />
<StatTile label="Avg carry" value={distance(stats.avgCarry)} />
<StatTile label="Avg club" value={speed(stats.avgClub)} />
<StatTile label="Shots" value={String(stats.shot_count)} />
<StatTile label="Avg ball" value={speed(measured(stats, stats.avg_ball_speed))} />
<StatTile label="Max ball" value={speed(measured(stats, stats.max_ball_speed))} />
<StatTile label="Avg carry" value={distance(measured(stats, stats.avg_carry_est))} />
<StatTile label="Avg club" value={speed(stats.avg_club_speed)} />
<StatTile
label="Avg smash"
value={stats.avgSmash === null ? MISSING : stats.avgSmash.toFixed(2)}
value={stats.avg_smash_factor === null ? MISSING : stats.avg_smash_factor.toFixed(2)}
/>
</View>
);
Expand Down Expand Up @@ -203,7 +183,10 @@ export default function ShotsScreen() {
keyExtractor={(shot, index) => `${shot.timestamp}-${index}`}
ListHeaderComponent={
<>
<SessionStatsGrid stats={summarise(shots)} />
{/* Summarised from the stored shots rather than from the
server's stats payload — that only ever describes the live
session. */}
<SessionStatsGrid stats={computeStats(shots)} />
<View style={styles.columns}>
<Text style={[styles.columnLabel, styles.columnShot]}>Shot</Text>
<Text style={styles.columnLabel}>Ball</Text>
Expand Down
73 changes: 73 additions & 0 deletions utils/sessionStats.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
Loading