From a5c62e5d96a4e6e668a9e922efbc2718553feadc Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Tue, 22 Sep 2026 01:53:57 -0400 Subject: [PATCH] feat(clubs): pick the club shots are filed under Adds a club picker to the Live screen, wired to the server's set_club / club_changed events and to the club session_state restores on connect. The picker shows the club the server reports, not the last tap: the server ignores an unknown club without replying, so only its club_changed confirmation updates the store. set_club is sent only over a live connection, because Socket.IO buffers emits during a transient drop and would replay a stale pick on reconnect. The picker is disabled unless connected, and the club is cleared on a deliberate disconnect or a switch to a different server. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/ClubPicker.test.tsx | 110 +++++++++++++++++ __tests__/LiveScreen.test.tsx | 3 +- __tests__/socket.test.ts | 141 +++++++++++++++++++++- app/(tabs)/index.tsx | 2 + components/ClubPicker.tsx | 220 ++++++++++++++++++++++++++++++++++ services/socket.ts | 34 +++++- stores/useSessionStore.ts | 7 ++ types.ts | 3 + 8 files changed, 515 insertions(+), 5 deletions(-) create mode 100644 __tests__/ClubPicker.test.tsx create mode 100644 components/ClubPicker.tsx diff --git a/__tests__/ClubPicker.test.tsx b/__tests__/ClubPicker.test.tsx new file mode 100644 index 0000000..b84358d --- /dev/null +++ b/__tests__/ClubPicker.test.tsx @@ -0,0 +1,110 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react-native'; +import { ClubPicker } from '../components/ClubPicker'; +import { socketService } from '../services/socket'; +import { useSessionStore } from '../stores/useSessionStore'; +import type { ConnectionState } from '../types'; + +jest.mock( + 'react-native-safe-area-context', + () => require('react-native-safe-area-context/jest/mock').default, +); + +// The socket service is exercised directly in socket.test.ts; here we only care +// that the picker wires the user's choice to it. +jest.mock('../services/socket', () => ({ + socketService: { setClub: jest.fn() }, +})); + +const mockedSocket = socketService as jest.Mocked; + +// render() and fireEvent are asynchronous in React Native Testing Library 14; +// every call is awaited so state is committed before the next assertion. +async function renderPicker(connectionState: ConnectionState, club: string | null) { + useSessionStore.setState({ connectionState, club }); + await render(); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('ClubPicker', () => { + it('shows the club the server is filing shots under', async () => { + await renderPicker('connected', '7-iron'); + + expect(screen.getByLabelText('Club: 7 Iron. Change club')).toBeTruthy(); + }); + + it('says so when the server has not reported a club', async () => { + await renderPicker('connected', null); + + expect(screen.getByLabelText('Club: not set. Change club')).toBeTruthy(); + }); + + it('lists every club by type and marks the current one', async () => { + await renderPicker('connected', '7-iron'); + + await fireEvent.press(screen.getByLabelText('Club: 7 Iron. Change club')); + + expect(screen.getByText('Irons')).toBeTruthy(); + expect(screen.getByText('Hybrids')).toBeTruthy(); + expect(screen.getByText('Woods')).toBeTruthy(); + expect(screen.getByRole('button', { name: '7 Iron', selected: true })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Driver', selected: false })).toBeTruthy(); + }); + + it('asks the server for the picked club and closes', async () => { + await renderPicker('connected', 'driver'); + await fireEvent.press(screen.getByLabelText('Club: Driver. Change club')); + + await fireEvent.press(screen.getByRole('button', { name: 'Pitching Wedge' })); + + expect(mockedSocket.setClub).toHaveBeenCalledWith('pw'); + expect(screen.queryByText('Irons')).toBeNull(); + }); + + it('keeps showing the current club until the server confirms the change', async () => { + await renderPicker('connected', 'driver'); + await fireEvent.press(screen.getByLabelText('Club: Driver. Change club')); + + await fireEvent.press(screen.getByRole('button', { name: 'Pitching Wedge' })); + + expect(screen.getByLabelText('Club: Driver. Change club')).toBeTruthy(); + }); + + it('closes without a change', async () => { + await renderPicker('connected', 'driver'); + await fireEvent.press(screen.getByLabelText('Club: Driver. Change club')); + + await fireEvent.press(screen.getByRole('button', { name: 'Close club list' })); + + expect(mockedSocket.setClub).not.toHaveBeenCalled(); + expect(screen.queryByText('Irons')).toBeNull(); + }); + + it.each(['disconnected', 'connecting', 'error'])( + 'cannot be changed while %s', + async (connectionState) => { + // A pick that cannot reach the server must not look as though it did. + await renderPicker(connectionState, 'driver'); + const trigger = screen.getByLabelText('Club: Driver. Change club'); + + expect(trigger).toBeDisabled(); + await fireEvent.press(trigger); + expect(screen.queryByText('Irons')).toBeNull(); + }, + ); + + it('closes the list if the connection drops while it is open', async () => { + await renderPicker('connected', 'driver'); + await fireEvent.press(screen.getByLabelText('Club: Driver. Change club')); + + await act(() => useSessionStore.setState({ connectionState: 'disconnected' })); + + expect(screen.queryByText('Irons')).toBeNull(); + }); +}); diff --git a/__tests__/LiveScreen.test.tsx b/__tests__/LiveScreen.test.tsx index dd7f3a6..07f840a 100644 --- a/__tests__/LiveScreen.test.tsx +++ b/__tests__/LiveScreen.test.tsx @@ -60,11 +60,12 @@ describe.each(['dark', 'light'])('Live screen in %s mode', (scheme) => { }); it('shows the latest shot once connected', async () => { - useSessionStore.setState({ connectionState: 'connected', shots: [shot] }); + useSessionStore.setState({ connectionState: 'connected', shots: [shot], club: '7-iron' }); await render(); expect(screen.getByText('Simulate Shot')).toBeTruthy(); + expect(screen.getByLabelText('Club: 7 Iron. Change club')).toBeTruthy(); expect(screen.getByText('152.4')).toBeTruthy(); expect(screen.getByText('241')).toBeTruthy(); expect(screen.getByText('2,650')).toBeTruthy(); diff --git a/__tests__/socket.test.ts b/__tests__/socket.test.ts index 119c038..48bf9d8 100644 --- a/__tests__/socket.test.ts +++ b/__tests__/socket.test.ts @@ -11,14 +11,20 @@ jest.mock('socket.io-client', () => { const handlers: Record void> = {}; const emit = jest.fn(); const close = jest.fn(); + // Mirrors Socket.IO's `socket.connected`; `trigger` flips it alongside the + // connect/disconnect events it fires. + const status = { connected: false }; const io = jest.fn((_url: string, _opts?: unknown) => ({ on: (event: string, cb: (...args: unknown[]) => void) => { handlers[event] = cb; }, emit, close, + get connected() { + return status.connected; + }, })); - return { io, __mock: { handlers, emit, close } }; + return { io, __mock: { handlers, emit, close, status } }; }); jest.mock('@react-native-async-storage/async-storage', () => @@ -52,12 +58,20 @@ const socketMock = jest.requireMock('socket.io-client') as { handlers: Record void>; emit: jest.Mock; close: jest.Mock; + status: { connected: boolean }; }; }; const { io: mockIo } = socketMock; -const { emit: mockEmit, close: mockClose, handlers: mockHandlers } = socketMock.__mock; +const { + emit: mockEmit, + close: mockClose, + handlers: mockHandlers, + status: mockStatus, +} = socketMock.__mock; function trigger(event: string, ...args: unknown[]) { + if (event === 'connect') mockStatus.connected = true; + if (event === 'disconnect' || event === 'connect_error') mockStatus.connected = false; mockHandlers[event]?.(...args); } @@ -89,8 +103,14 @@ function makeShot(timestamp: string, overrides: Partial = {}): Shot { } beforeEach(() => { - useSessionStore.setState({ connectionState: 'disconnected', sessionId: null, shots: [] }); + useSessionStore.setState({ + connectionState: 'disconnected', + sessionId: null, + shots: [], + club: null, + }); for (const key of Object.keys(mockHandlers)) delete mockHandlers[key]; + mockStatus.connected = false; mockIo.mockClear(); mockEmit.mockClear(); mockClose.mockClear(); @@ -336,3 +356,118 @@ describe('a shot the server enriches after publishing it', () => { expect(useSessionStore.getState().shots[0].spin_rpm).toBe(2680); }); }); + +describe('the selected club', () => { + it('takes the club the server restores on connect', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('session_state', { shots: [], club: '7-iron' }); + + expect(useSessionStore.getState().club).toBe('7-iron'); + }); + + it('keeps the club when a session snapshot does not carry one', () => { + // An older server's session_state has no club key; that is not a reset. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('session_state', { shots: [], club: '7-iron' }); + + trigger('session_state', { shots: [] }); + + expect(useSessionStore.getState().club).toBe('7-iron'); + }); + + it('follows a change made on another client', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('club_changed', { club: 'pw' }); + + expect(useSessionStore.getState().club).toBe('pw'); + }); + + it('ignores a malformed club change', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('club_changed', { club: 'pw' }); + + trigger('club_changed', {}); + trigger('club_changed', { club: 7 }); + trigger('club_changed', null); + + expect(useSessionStore.getState().club).toBe('pw'); + }); + + it('asks the server to change club while connected', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + socketService.setClub('5-wood'); + + expect(mockEmit).toHaveBeenCalledWith('set_club', { club: '5-wood' }); + }); + + it('waits for the server to confirm before showing the new club', () => { + // The server ignores a club it does not recognise without replying, so a + // local change would show a club that shots are not filed under. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('session_state', { shots: [], club: 'driver' }); + + socketService.setClub('5-wood'); + + expect(useSessionStore.getState().club).toBe('driver'); + }); + + it('sends nothing before a connection is established', () => { + socketService.connect('http://host:8080'); + + socketService.setClub('5-wood'); + + expect(mockEmit).not.toHaveBeenCalledWith('set_club', expect.anything()); + }); + + it('sends nothing during a transient drop, even once reconnected', () => { + // Socket.IO keeps the socket through a wifi drop and would buffer the emit + // for replay on reconnect, filing later shots under a club picked earlier. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('disconnect'); + + socketService.setClub('5-wood'); + trigger('connect'); + + expect(mockEmit).not.toHaveBeenCalledWith('set_club', expect.anything()); + }); + + it('keeps the club through a transient drop', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('club_changed', { club: 'pw' }); + + trigger('disconnect'); + + expect(useSessionStore.getState().club).toBe('pw'); + }); + + it('forgets the club when the user disconnects deliberately', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('club_changed', { club: 'pw' }); + + socketService.disconnect(); + + expect(useSessionStore.getState().club).toBeNull(); + }); + + it('forgets the club when switching to a different server', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('club_changed', { club: 'pw' }); + + socketService.connect('http://other:8080'); + + expect(useSessionStore.getState().club).toBeNull(); + }); +}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f86c10c..13be645 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,7 @@ import { Keyboard, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSessionStore } from '../../stores/useSessionStore'; +import { ClubPicker } from '../../components/ClubPicker'; import { ConnectionBar } from '../../components/ConnectionBar'; import { CurrentShotView } from '../../components/CurrentShotView'; import { spacing, type Palette } from '../../components/theme/tokens'; @@ -20,6 +21,7 @@ export default function LiveScreen() { + diff --git a/components/ClubPicker.tsx b/components/ClubPicker.tsx new file mode 100644 index 0000000..14da1b5 --- /dev/null +++ b/components/ClubPicker.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react'; +import { + Modal, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { CLUBS_BY_TYPE, getClubName } from '../data/clubs'; +import { socketService } from '../services/socket'; +import { useSessionStore } from '../stores/useSessionStore'; +import { fontFamily } from './theme/fonts'; +import { radius, spacing, type Palette } from './theme/tokens'; +import { useThemedStyles } from './theme/useTheme'; + +// On iOS the page sheet already sits below the status bar, so only the home +// indicator needs clearing; Android's modal is full-screen and needs both. +const SHEET_EDGES = Platform.OS === 'ios' ? (['bottom'] as const) : (['top', 'bottom'] as const); + +// The club shots are being filed under, and the list to change it. Shows what +// the server last reported rather than the last tap: the server ignores a club +// it does not recognise without replying, so only its confirmation is truth. +export function ClubPicker() { + const club = useSessionStore((s) => s.club); + const isConnected = useSessionStore((s) => s.connectionState === 'connected'); + const styles = useThemedStyles(createStyles); + const [open, setOpen] = useState(false); + + // A pick made while disconnected is never sent, so the list closes with the + // connection rather than offering choices that would silently go nowhere — + // and stays closed when the connection returns. + if (open && !isConnected) setOpen(false); + + const clubName = club === null ? null : getClubName(club); + + const pick = (clubId: string) => { + socketService.setClub(clubId); + setOpen(false); + }; + + return ( + <> + setOpen(true)} + disabled={!isConnected} + accessibilityRole="button" + accessibilityLabel={`Club: ${clubName ?? 'not set'}. Change club`} + accessibilityState={{ disabled: !isConnected }} + > + Club + + {clubName ?? 'Not set'} + + Change + + + setOpen(false)} + > + + + + Select club + + setOpen(false)} + accessibilityRole="button" + accessibilityLabel="Close club list" + > + Done + + + + + {Object.entries(CLUBS_BY_TYPE).map(([type, clubs]) => ( + + + {type} + + + {clubs.map((option) => { + const selected = option.id === club; + return ( + pick(option.id)} + accessibilityRole="button" + accessibilityLabel={option.name} + accessibilityState={{ selected }} + > + + {option.label} + + + ); + })} + + + ))} + + + + + ); +} + +const createStyles = (c: Palette) => + StyleSheet.create({ + trigger: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + marginTop: spacing.sm, + paddingHorizontal: spacing.md, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.control, + backgroundColor: c.surface, + }, + triggerDisabled: { + opacity: 0.5, + }, + triggerLabel: { + color: c.textMuted, + fontFamily: fontFamily.medium, + fontSize: 13, + }, + triggerValue: { + flex: 1, + color: c.text, + fontFamily: fontFamily.semibold, + fontSize: 15, + }, + triggerAction: { + color: c.accentText, + fontFamily: fontFamily.semibold, + fontSize: 13, + }, + sheet: { + flex: 1, + backgroundColor: c.bg, + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: c.borderSoft, + }, + sheetTitle: { + color: c.text, + fontFamily: fontFamily.bold, + fontSize: 20, + }, + closeButton: { + minHeight: 44, + minWidth: 44, + justifyContent: 'center', + alignItems: 'flex-end', + }, + closeButtonText: { + color: c.accentText, + fontFamily: fontFamily.semibold, + fontSize: 16, + }, + sheetBody: { + padding: spacing.lg, + gap: spacing.xl, + }, + section: { + gap: spacing.sm, + }, + sectionTitle: { + color: c.textMuted, + fontFamily: fontFamily.semibold, + fontSize: 13, + textTransform: 'uppercase', + letterSpacing: 0.8, + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + tile: { + minWidth: 64, + minHeight: 48, + paddingHorizontal: spacing.md, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.control, + backgroundColor: c.surface, + }, + tileSelected: { + backgroundColor: c.accentBlock, + borderColor: c.accentBlock, + }, + tileText: { + color: c.text, + fontFamily: fontFamily.semibold, + fontSize: 16, + }, + tileTextSelected: { + color: c.accentFg, + }, + }); diff --git a/services/socket.ts b/services/socket.ts index 086589a..731eeeb 100644 --- a/services/socket.ts +++ b/services/socket.ts @@ -2,7 +2,7 @@ import { io, type Socket } from 'socket.io-client'; import { useSessionStore } from '../stores/useSessionStore'; import { saveServerUrl } from '../storage/connection'; import { getShotRepository } from '../storage/db'; -import type { SessionStatePayload, Shot, ShotEnvelope } from '../types'; +import type { ClubChangedPayload, SessionStatePayload, Shot, ShotEnvelope } from '../types'; // Singleton Socket.IO client, mirroring the web app's socketService shape: one // place that maps every server event onto a store mutation. Kept out of the @@ -41,6 +41,12 @@ class SocketService { this.socket = null; } + // The selected club belongs to the server that reported it; a different + // server restores its own through session_state once connected. + if (this.url !== null && this.url !== url) { + store.setClub(null); + } + store.setConnectionState('connecting'); this.url = url; @@ -59,12 +65,30 @@ class SocketService { this.socket = null; this.url = null; useSessionStore.getState().setConnectionState('disconnected'); + // Only the deliberate disconnect forgets the club; a transient drop keeps + // showing it, since reconnecting restores the same server's selection. + useSessionStore.getState().setClub(null); } simulateShot(): void { this.socket?.emit('simulate_shot'); } + // Fire-and-forget: the server confirms with a `club_changed` broadcast, which + // is what updates the store. It ignores an unknown club without replying, so + // nothing is changed locally ahead of that confirmation. + setClub(club: string): void { + this.emitWhileConnected('set_club', { club }); + } + + // Socket.IO keeps the socket through a transient drop and buffers anything + // emitted meanwhile, replaying it on reconnect. A selection made before the + // drop could then land after the user moved on and decide how the next + // shots are filed, so a selection is sent only over a live connection. + private emitWhileConnected(event: string, payload: object): void { + if (this.socket?.connected) this.socket.emit(event, payload); + } + // Files one shot under the current visit. Callers fire and forget, so this // absorbs every failure itself rather than leaving a rejected promise loose. private async persistShot(shot: Shot): Promise { @@ -108,6 +132,14 @@ class SocketService { socket.on('session_state', (data: SessionStatePayload) => { store().setShots(data.shots); + // Older servers omit the club; keep what is shown rather than blank it. + if (typeof data.club === 'string') store().setClub(data.club); + }); + + // Broadcast to every client after a set_club — this phone's or another's, + // such as the kiosk or a connected simulator. + socket.on('club_changed', (data: ClubChangedPayload | null) => { + if (typeof data?.club === 'string') store().setClub(data.club); }); socket.on('shot', (data: ShotEnvelope) => { diff --git a/stores/useSessionStore.ts b/stores/useSessionStore.ts index d59e54c..f5bb1b7 100644 --- a/stores/useSessionStore.ts +++ b/stores/useSessionStore.ts @@ -21,6 +21,10 @@ interface SessionState { // every screen wants. The server sends them oldest-first, so `setShots` // inverts on the way in. shots: Shot[]; + // The club the server is filing shots under, as last reported by it. Null + // until a connection reports one; never set from a local pick, because the + // server ignores a club it does not recognise without replying. + club: string | null; setConnectionState: (state: ConnectionState) => void; // Begin a new visit. Called once a connection is established, so every shot @@ -37,12 +41,14 @@ interface SessionState { // instead, so an enriched shot is never silently dropped. replaceShot: (shot: Shot) => void; clearShots: () => void; + setClub: (club: string | null) => void; } export const useSessionStore = create((set) => ({ connectionState: 'disconnected', sessionId: null, shots: [], + club: null, setConnectionState: (state) => set({ connectionState: state }), // Date.now() alone collides when two connections land within the same @@ -66,4 +72,5 @@ export const useSessionStore = create((set) => ({ return { shots }; }), clearShots: () => set({ shots: [] }), + setClub: (club) => set({ club }), })); diff --git a/types.ts b/types.ts index 6188a71..cb72061 100644 --- a/types.ts +++ b/types.ts @@ -63,6 +63,9 @@ export interface ShotEnvelope { // shots oldest-first; the store inverts this to its newest-first invariant. export interface SessionStatePayload { shots: Shot[]; + // The club the server is attributing shots to, so a reconnecting client + // restores the selection instead of assuming one. Absent on older servers. + club?: string; mock_mode?: boolean; debug_mode?: boolean; player_name?: string;