soroban-abacus-flashcards/apps/web/src/hooks/useUserPlayers.ts

364 lines
10 KiB
TypeScript

'use client'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import type { Player } from '@/db/schema/players'
import { api } from '@/lib/queryClient'
import { playerKeys } from '@/lib/queryKeys'
import type { StudentWithSkillData } from '@/utils/studentGrouping'
// Re-export query keys for consumers
export { playerKeys } from '@/lib/queryKeys'
/**
* Fetch all players for the current user
*/
async function fetchPlayers(): Promise<Player[]> {
const res = await api('players')
if (!res.ok) throw new Error('Failed to fetch players')
const data = await res.json()
return data.players
}
/**
* Fetch all players with skill data for the current user
*/
async function fetchPlayersWithSkillData(): Promise<StudentWithSkillData[]> {
const res = await api('players/with-skill-data')
if (!res.ok) throw new Error('Failed to fetch players with skill data')
const data = await res.json()
return data.players
}
/**
* Create a new player
*/
async function createPlayer(
newPlayer: Pick<Player, 'name' | 'emoji' | 'color'> & { isActive?: boolean }
): Promise<Player> {
const res = await api('players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPlayer),
})
if (!res.ok) throw new Error('Failed to create player')
const data = await res.json()
return data.player
}
/**
* Update a player
*/
async function updatePlayer({
id,
updates,
}: {
id: string
updates: Partial<Pick<Player, 'name' | 'emoji' | 'color' | 'isActive' | 'isArchived' | 'notes'>>
}): Promise<Player> {
const res = await api(`players/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!res.ok) {
// Extract error message from response if available
try {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to update player')
} catch (_jsonError) {
throw new Error('Failed to update player')
}
}
const data = await res.json()
return data.player
}
/**
* Delete a player
*/
async function deletePlayer(id: string): Promise<void> {
const res = await api(`players/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Failed to delete player')
}
/**
* Hook: Fetch all players
*/
export function useUserPlayers() {
return useQuery({
queryKey: playerKeys.list(),
queryFn: fetchPlayers,
})
}
/**
* Hook: Fetch all players with Suspense (for SSR contexts)
*/
export function useUserPlayersSuspense() {
return useSuspenseQuery({
queryKey: playerKeys.list(),
queryFn: fetchPlayers,
})
}
/**
* Hook: Fetch all players with skill data
* Used by the practice page for grouping/filtering
*/
export function usePlayersWithSkillData(options?: { initialData?: StudentWithSkillData[] }) {
return useQuery({
queryKey: playerKeys.listWithSkillData(),
queryFn: fetchPlayersWithSkillData,
initialData: options?.initialData,
// Keep data fresh but don't refetch too aggressively
staleTime: 30_000, // 30 seconds
})
}
/**
* Hook: Fetch a single player with Suspense (for SSR contexts)
*/
export function usePlayerSuspense(playerId: string) {
return useSuspenseQuery({
queryKey: playerKeys.detail(playerId),
queryFn: async () => {
const res = await api(`players/${playerId}`)
if (!res.ok) throw new Error('Failed to fetch player')
const data = await res.json()
return data.player as Player
},
})
}
/**
* Hook: Create a new player
*/
export function useCreatePlayer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createPlayer,
onMutate: async (newPlayer) => {
// Cancel outgoing refetches for all player queries
await queryClient.cancelQueries({ queryKey: playerKeys.all })
// Snapshot previous values
const previousPlayers = queryClient.getQueryData<Player[]>(playerKeys.list())
const previousPlayersWithSkillData = queryClient.getQueryData<StudentWithSkillData[]>(
playerKeys.listWithSkillData()
)
// Create optimistic player
const optimisticPlayer: Player = {
id: `temp-${Date.now()}`, // Temporary ID
...newPlayer,
createdAt: new Date(),
isActive: newPlayer.isActive ?? false,
isArchived: false,
userId: 'temp-user', // Temporary userId, will be replaced by server response
helpSettings: null, // Will be set by server with default values
notes: null,
familyCode: null, // Will be generated by server
}
// Optimistically update player list
if (previousPlayers) {
queryClient.setQueryData<Player[]>(playerKeys.list(), [
...previousPlayers,
optimisticPlayer,
])
}
// Optimistically update players with skill data (used by practice page)
if (previousPlayersWithSkillData) {
const optimisticPlayerWithSkillData: StudentWithSkillData = {
...optimisticPlayer,
practicingSkills: [],
lastPracticedAt: null,
skillCategory: null,
intervention: null,
}
queryClient.setQueryData<StudentWithSkillData[]>(playerKeys.listWithSkillData(), [
...previousPlayersWithSkillData,
optimisticPlayerWithSkillData,
])
}
return { previousPlayers, previousPlayersWithSkillData }
},
onError: (_err, _newPlayer, context) => {
// Rollback on error
if (context?.previousPlayers) {
queryClient.setQueryData(playerKeys.list(), context.previousPlayers)
}
if (context?.previousPlayersWithSkillData) {
queryClient.setQueryData(
playerKeys.listWithSkillData(),
context.previousPlayersWithSkillData
)
}
},
onSettled: () => {
// Always refetch after error or success
// Invalidate ALL player queries (including listWithSkillData used by practice page)
queryClient.invalidateQueries({ queryKey: playerKeys.all })
},
})
}
/**
* Hook: Update a player
*/
export function useUpdatePlayer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updatePlayer,
onMutate: async ({ id, updates }) => {
// Cancel outgoing refetches for all player lists
await queryClient.cancelQueries({ queryKey: playerKeys.all })
// Snapshot previous values
const previousPlayers = queryClient.getQueryData<Player[]>(playerKeys.list())
const previousPlayersWithSkillData = queryClient.getQueryData<StudentWithSkillData[]>(
playerKeys.listWithSkillData()
)
// Optimistically update player list
if (previousPlayers) {
const optimisticPlayers = previousPlayers.map((player) =>
player.id === id ? { ...player, ...updates } : player
)
queryClient.setQueryData<Player[]>(playerKeys.list(), optimisticPlayers)
}
// Optimistically update players with skill data
if (previousPlayersWithSkillData) {
const optimisticPlayers = previousPlayersWithSkillData.map((player) =>
player.id === id ? { ...player, ...updates } : player
)
queryClient.setQueryData<StudentWithSkillData[]>(
playerKeys.listWithSkillData(),
optimisticPlayers
)
}
return { previousPlayers, previousPlayersWithSkillData }
},
onError: (err, _variables, context) => {
// Log error for debugging
console.error('Failed to update player:', err.message)
// Rollback on error
if (context?.previousPlayers) {
queryClient.setQueryData(playerKeys.list(), context.previousPlayers)
}
if (context?.previousPlayersWithSkillData) {
queryClient.setQueryData(
playerKeys.listWithSkillData(),
context.previousPlayersWithSkillData
)
}
},
onSettled: (_data, _error, { id }) => {
// Refetch after error or success - invalidate all player queries
queryClient.invalidateQueries({ queryKey: playerKeys.all })
if (_data) {
queryClient.setQueryData(playerKeys.detail(id), _data)
}
},
})
}
/**
* Hook: Delete a player
*/
export function useDeletePlayer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deletePlayer,
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
// Invalidate ALL player queries (including listWithSkillData used by practice page)
queryClient.invalidateQueries({ queryKey: playerKeys.all })
},
})
}
/**
* Hook: Set player active status
*/
export function useSetPlayerActive() {
const { mutate: updatePlayer } = useUpdatePlayer()
return {
setActive: (id: string, isActive: boolean) => {
updatePlayer({ id, updates: { isActive } })
},
}
}
/**
* Link to an existing child via family code
*/
interface LinkChildResult {
success: boolean
player?: Player
error?: string
}
async function linkChild(familyCode: string): Promise<LinkChildResult> {
const res = await api('family/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ familyCode }),
})
const data = await res.json()
if (!res.ok || !data.success) {
return { success: false, error: data.error || 'Failed to link child' }
}
return { success: true, player: data.player }
}
/**
* Hook: Link to an existing child via family code
*/
export function useLinkChild() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: linkChild,
onSuccess: (data) => {
if (data.success) {
// Invalidate ALL player queries to show the newly linked child
// This includes both playerKeys.list() and playerKeys.listWithSkillData()
queryClient.invalidateQueries({ queryKey: playerKeys.all })
}
},
})
}