feat: add optimistic updates and remove dead code

Phase 4 & 5: Cleanup + Optimistic Updates
- Removed dead localStorage types from player.ts
- Added optimistic updates to all player mutations
- Added optimistic updates to stats mutations
- Instant UI feedback with automatic rollback on error

Breaking changes: None

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-05 18:21:33 -05:00
parent fe01a1fe18
commit b62cf26fb6
3 changed files with 120 additions and 91 deletions

View File

@ -88,8 +88,34 @@ export function useCreatePlayer() {
return useMutation({
mutationFn: createPlayer,
onSuccess: () => {
// Invalidate and refetch players list
onMutate: async (newPlayer) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: playerKeys.lists() })
// Snapshot previous value
const previousPlayers = queryClient.getQueryData<Player[]>(playerKeys.list())
// Optimistically update to new value
if (previousPlayers) {
const optimisticPlayer: Player = {
id: `temp-${Date.now()}`, // Temporary ID
...newPlayer,
createdAt: new Date(),
isActive: newPlayer.isActive ?? false,
}
queryClient.setQueryData<Player[]>(playerKeys.list(), [...previousPlayers, optimisticPlayer])
}
return { previousPlayers }
},
onError: (_err, _newPlayer, context) => {
// Rollback on error
if (context?.previousPlayers) {
queryClient.setQueryData(playerKeys.list(), context.previousPlayers)
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: playerKeys.lists() })
},
})
@ -103,11 +129,35 @@ export function useUpdatePlayer() {
return useMutation({
mutationFn: updatePlayer,
onSuccess: (updatedPlayer) => {
// Invalidate players list
onMutate: async ({ id, updates }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: playerKeys.lists() })
// Snapshot previous value
const previousPlayers = queryClient.getQueryData<Player[]>(playerKeys.list())
// Optimistically update
if (previousPlayers) {
const optimisticPlayers = previousPlayers.map(player =>
player.id === id ? { ...player, ...updates } : player
)
queryClient.setQueryData<Player[]>(playerKeys.list(), optimisticPlayers)
}
return { previousPlayers }
},
onError: (_err, _variables, context) => {
// Rollback on error
if (context?.previousPlayers) {
queryClient.setQueryData(playerKeys.list(), context.previousPlayers)
}
},
onSettled: (_data, _error, { id }) => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: playerKeys.lists() })
// Update detail cache if it exists
queryClient.setQueryData(playerKeys.detail(updatedPlayer.id), updatedPlayer)
if (_data) {
queryClient.setQueryData(playerKeys.detail(id), _data)
}
},
})
}
@ -120,8 +170,29 @@ export function useDeletePlayer() {
return useMutation({
mutationFn: deletePlayer,
onSuccess: () => {
// Invalidate players list
onMutate: async (id) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: playerKeys.lists() })
// Snapshot previous value
const previousPlayers = queryClient.getQueryData<Player[]>(playerKeys.list())
// Optimistically remove from list
if (previousPlayers) {
const optimisticPlayers = previousPlayers.filter(player => player.id !== id)
queryClient.setQueryData<Player[]>(playerKeys.list(), optimisticPlayers)
}
return { previousPlayers }
},
onError: (_err, _id, context) => {
// Rollback on error
if (context?.previousPlayers) {
queryClient.setQueryData(playerKeys.list(), context.previousPlayers)
}
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: playerKeys.lists() })
},
})

View File

@ -56,9 +56,32 @@ export function useUpdateUserStats() {
return useMutation({
mutationFn: updateUserStats,
onSuccess: (updatedStats) => {
// Update cache with new stats
queryClient.setQueryData(statsKeys.detail(), updatedStats)
onMutate: async (updates) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: statsKeys.detail() })
// Snapshot previous value
const previousStats = queryClient.getQueryData<UserStats>(statsKeys.detail())
// Optimistically update
if (previousStats) {
const optimisticStats = { ...previousStats, ...updates }
queryClient.setQueryData<UserStats>(statsKeys.detail(), optimisticStats)
}
return { previousStats }
},
onError: (_err, _updates, context) => {
// Rollback on error
if (context?.previousStats) {
queryClient.setQueryData(statsKeys.detail(), context.previousStats)
}
},
onSettled: (updatedStats) => {
// Update with server data on success
if (updatedStats) {
queryClient.setQueryData(statsKeys.detail(), updatedStats)
}
},
})
}

View File

@ -1,84 +1,5 @@
/**
* Player types for the new UUID-based player system
* This replaces the old index-based (1-4) player system
*/
export interface Player {
/** Globally unique identifier (nanoid) */
id: string
/** Display name */
name: string
/** Player emoji/avatar */
emoji: string
/** Player color (for UI theming) */
color: string
/** Creation timestamp */
createdAt: number
/** Optional: Device ID for multi-device sync */
deviceId?: string
/** Optional: Last sync timestamp */
syncedAt?: number
/** Optional: Whether this is a local or remote player */
isLocal?: boolean
}
/**
* Storage format V2
* Replaces the old UserProfile format with indexed players
*/
export interface PlayerStorageV2 {
version: 2
/** All known players, keyed by ID */
players: Record<string, Player>
/** IDs of currently active players (order matters) */
activePlayerIds: string[]
/** Activation order mapping for sorting */
activationOrder: Record<string, number>
}
/**
* Legacy storage format (V1)
* Used for migration from old system
*/
export interface UserProfileV1 {
player1Emoji: string
player2Emoji: string
player3Emoji: string
player4Emoji: string
player1Name: string
player2Name: string
player3Name: string
player4Name: string
gamesPlayed: number
totalWins: number
favoriteGameType: 'abacus-numeral' | 'complement-pairs' | null
bestTime: number | null
highestAccuracy: number
}
/**
* New stats-only profile (player data moved to PlayerStorageV2)
*/
export interface UserStatsProfile {
gamesPlayed: number
totalWins: number
favoriteGameType: 'abacus-numeral' | 'complement-pairs' | null
bestTime: number | null
highestAccuracy: number
}
/**
* Default player colors (used during migration and player creation)
* Default player colors (used during player creation)
*/
export const DEFAULT_PLAYER_COLORS = [
'#3b82f6', // Blue
@ -93,6 +14,20 @@ export const DEFAULT_PLAYER_COLORS = [
'#84cc16', // Lime
]
/**
* Client-side Player type (for contexts/components)
* Matches database Player type but with flexible createdAt
*/
export interface Player {
id: string
name: string
emoji: string
color: string
createdAt: Date | number
isActive?: boolean
isLocal?: boolean
}
/**
* Get a color for a new player (cycles through defaults)
*/