All files / web/src/hooks useLinearReadiness.ts

37.27% Statements 41/110
100% Branches 0/0
0% Functions 0/4
37.27% Lines 41/110

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 1111x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x       1x                               1x 1x 1x             1x 1x 1x                                                                                            
/**
 * Hook for linear-readiness (L3): which skill categories have "aged out" onto
 * number sentences for a student, and the per-category teacher veto.
 *
 * Reads/writes `/api/curriculum/[playerId]/linear-veto`. All server state via React
 * Query (no `fetch()` in components, no `useState` for server data).
 */
 
'use client'
 
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type {
  LinearCategoryStatus,
  LinearReadinessFrontier,
  LinearReadinessSkillState,
  LinearReadinessState,
  LinearReadyCategory,
} from '@/lib/curriculum/linear-readiness-service'
import { api } from '@/lib/queryClient'
import { curriculumKeys, sessionPlanKeys } from '@/lib/queryKeys'
 
export type {
  LinearCategoryStatus,
  LinearReadinessFrontier,
  LinearReadinessSkillState,
  LinearReadinessState,
  LinearReadyCategory,
}
 
/**
 * True when the readiness flag is on and no category currently feeds number
 * sentences. Client-safe (the service module is server-only).
 */
export function isLinearLocked(state: LinearReadinessState | undefined | null): boolean {
  if (!state?.enabled) return false
  return !state.categories.some((c) => c.status === 'ready')
}
 
async function fetchLinearReadiness(playerId: string): Promise<LinearReadinessState> {
  const response = await api(`curriculum/${playerId}/linear-veto`)
  if (!response.ok) {
    throw new Error(`Failed to load linear readiness: ${response.statusText}`)
  }
  const state = (await response.json()) as LinearReadinessState
  // Old replicas (rolling deploy) may still send categories without `status`.
  return {
    ...state,
    categories: state.categories.map((c) => ({
      ...c,
      status: c.status ?? (c.vetoed ? 'vetoed' : 'ready'),
    })),
  }
}
 
/** Fetch the derived linear-ready categories + veto state for a student. */
export function useLinearReadiness(playerId: string | null) {
  return useQuery({
    queryKey: curriculumKeys.linearReadiness(playerId ?? ''),
    queryFn: () => fetchLinearReadiness(playerId!),
    enabled: !!playerId,
  })
}
 
/** Mutations to veto / un-veto a skill category off number sentences. */
export function useLinearReadinessVeto(playerId: string | null) {
  const queryClient = useQueryClient()

  const invalidate = () => {
    if (!playerId) return
    queryClient.invalidateQueries({ queryKey: curriculumKeys.linearReadiness(playerId) })
    // Re-planning depends on vetoes; refresh any session-plan views too.
    queryClient.invalidateQueries({ queryKey: sessionPlanKeys.list(playerId) })
  }

  const setVeto = useMutation({
    mutationFn: async ({ category, reason }: { category: string; reason?: string }) => {
      if (!playerId) throw new Error('No player selected')
      const response = await api(`curriculum/${playerId}/linear-veto`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ category, reason }),
      })
      if (!response.ok) {
        const error = await response.json().catch(() => ({}))
        throw new Error(error.error || 'Failed to veto category')
      }
      return response.json()
    },
    onSuccess: invalidate,
  })

  const clearVeto = useMutation({
    mutationFn: async ({ category }: { category: string }) => {
      if (!playerId) throw new Error('No player selected')
      const response = await api(`curriculum/${playerId}/linear-veto`, {
        method: 'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ category }),
      })
      if (!response.ok) {
        const error = await response.json().catch(() => ({}))
        throw new Error(error.error || 'Failed to lift veto')
      }
      return response.json()
    },
    onSuccess: invalidate,
  })

  return { setVeto, clearVeto }
}