diff --git a/redux/selectors.test.ts b/redux/selectors.test.ts index 956bf408..3a3245ff 100644 --- a/redux/selectors.test.ts +++ b/redux/selectors.test.ts @@ -1,6 +1,6 @@ import { InteractionType } from '../src/components/Interactions/InteractionType'; -import { selectInteractionType, selectCurrentGame, selectLastStoreReviewPrompt } from './selectors'; +import { selectCurrentGame, selectGamePlayersByScore, selectInteractionType, selectLastStoreReviewPrompt } from './selectors'; import { RootState } from './store'; // Mock data for testing @@ -195,4 +195,43 @@ describe('Redux selectors', () => { expect(result).toBe(9876543210); }); }); + + describe('selectGamePlayersByScore', () => { + it('should return game players sorted by total score descending', () => { + const state = { + ...mockState, + games: { + entities: { + 'game-1': { + ...mockState.games!.entities['game-1'], + playerIds: ['player-1', 'player-2', 'player-3'], + }, + }, + ids: ['game-1'], + }, + players: { + entities: { + 'player-1': { id: 'player-1', playerName: 'Alex', scores: [2, 3] }, + 'player-2': { id: 'player-2', playerName: 'Blair', scores: [7, 1] }, + 'player-3': { id: 'player-3', playerName: 'Casey', scores: [4] }, + }, + ids: ['player-1', 'player-2', 'player-3'], + }, + } as RootState; + + expect(selectGamePlayersByScore(state, 'game-1')).toEqual([ + { id: 'player-2', name: 'Blair', totalScore: 8 }, + { id: 'player-1', name: 'Alex', totalScore: 5 }, + { id: 'player-3', name: 'Casey', totalScore: 4 }, + ]); + }); + + it('should preserve missing player ids with empty summary values', () => { + const state = mockState as RootState; + + expect(selectGamePlayersByScore(state, 'game-1')).toEqual([ + { id: 'player-1', name: '', totalScore: 0 }, + ]); + }); + }); }); diff --git a/redux/selectors.ts b/redux/selectors.ts index 13928e09..2c8417ca 100644 --- a/redux/selectors.ts +++ b/redux/selectors.ts @@ -1,8 +1,18 @@ +import { createSelector } from '@reduxjs/toolkit'; + import { InteractionType } from '../src/components/Interactions/InteractionType'; import { selectGameById } from './GamesSlice'; import { RootState } from './store'; +export interface PlayerScoreSummary { + id: string; + name: string; + totalScore: number; +} + +const EMPTY_PLAYER_IDS: string[] = []; + export const selectInteractionType = (state: RootState, gameId?: string): InteractionType => { // Prefer the game-level gesture; fall back to the global default for new games. const gameLevel = gameId ? selectGameById(state, gameId)?.interactionType : undefined; @@ -23,4 +33,22 @@ export const selectCurrentGame = (state: RootState) => { return selectGameById(state, currentGameId); }; + +export const selectGamePlayersByScore = createSelector( + [ + (state: RootState, gameId: string | undefined) => gameId ? state.games.entities[gameId]?.playerIds ?? EMPTY_PLAYER_IDS : EMPTY_PLAYER_IDS, + (state: RootState) => state.players.entities, + ], + (playerIds, players): PlayerScoreSummary[] => playerIds + .map((id) => { + const player = players[id]; + return { + id, + name: player?.playerName || '', + totalScore: (player?.scores || []).reduce((total, score) => total + (score || 0), 0), + }; + }) + .sort((a, b) => b.totalScore - a.totalScore) +); + export const selectLastStoreReviewPrompt = (state: RootState) => state.settings.lastStoreReviewPrompt; diff --git a/src/components/Sheets/ChooseWinnersSheet.tsx b/src/components/Sheets/ChooseWinnersSheet.tsx index cc2e23a0..d25c51ed 100644 --- a/src/components/Sheets/ChooseWinnersSheet.tsx +++ b/src/components/Sheets/ChooseWinnersSheet.tsx @@ -9,10 +9,10 @@ import { import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Icon } from 'react-native-elements'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { shallowEqual } from 'react-redux'; -import { selectGameById, updateGame } from '../../../redux/GamesSlice'; +import { updateGame } from '../../../redux/GamesSlice'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { selectGamePlayersByScore } from '../../../redux/selectors'; import { logEvent } from '../../Analytics'; import { useTheme } from '../../theme'; @@ -26,37 +26,7 @@ const ChooseWinnersSheet: React.FunctionComponent = () => { const topInset = insets.top + 50; const currentGameId = useAppSelector(state => state.settings.currentGameId); - const playerIds = useAppSelector(state => selectGameById(state, currentGameId || '')?.playerIds); - const allPlayers = useAppSelector((state) => - (playerIds || []).map((id) => state.players.entities[id]), - shallowEqual - ); - const sortedPlayerIds = useMemo(() => { - const withScores = (playerIds || []).map((id) => { - const p = allPlayers.find((ap) => ap?.id === id); - - return { - id, - totalScore: (p?.scores || []).reduce((a, b) => a + b, 0), - }; - }); - - withScores.sort((a, b) => b.totalScore - a.totalScore); - - return withScores.map((p) => p.id); - }, [allPlayers, playerIds]); - const playerInfo = useMemo( - () => - Object.fromEntries( - allPlayers.map((p) => [ - p?.id, - { - name: p?.playerName || '', - totalScore: (p?.scores || []).reduce((a, b) => a + b, 0), - }, - ])), - [allPlayers] - ); + const playersByScore = useAppSelector(state => selectGamePlayersByScore(state, currentGameId)); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -162,16 +132,15 @@ const ChooseWinnersSheet: React.FunctionComponent = () => { - {sortedPlayerIds.map((playerId, index) => { - const info = playerInfo[playerId]; - const isSelected = selectedIds.has(playerId); - const isLast = index === sortedPlayerIds.length - 1; + {playersByScore.map((player, index) => { + const isSelected = selectedIds.has(player.id); + const isLast = index === playersByScore.length - 1; return ( - + togglePlayer(playerId)} + onPress={() => togglePlayer(player.id)} activeOpacity={0.6} testID={`winner-player-row-${index}`} > @@ -182,10 +151,10 @@ const ChooseWinnersSheet: React.FunctionComponent = () => { size={22} /> - {info?.name} + {player.name} - {info?.totalScore} + {player.totalScore} {!isLast && (