All files / web/src/app/create/worksheets masteryLogic.ts

0% Statements 0/286
0% Branches 0/1
0% Functions 0/1
0% Lines 0/286

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 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
/**
 * Mastery Mode Logic
 *
 * Functions for review selection, problem distribution, and mastery worksheet generation.
 * Used by the worksheet generator to mix current skill practice with review problems.
 */

import type { WorksheetMastery } from '@/db/schema'
import { SKILL_DEFINITIONS, type SkillDefinition, type SkillId, getSkillById } from './skills'
import type { WorksheetConfig } from '@/app/create/worksheets/types'

/**
 * Mastery state map: skill ID -> mastery record
 */
export type MasteryStateMap = Map<SkillId, WorksheetMastery>

/**
 * Review selection result
 */
export interface ReviewSelection {
  /** Skills to include in review */
  skills: SkillDefinition[]
  /** Number of problems per skill */
  problemsPerSkill: Map<SkillId, number>
  /** Total review problems */
  totalProblems: number
}

/**
 * Mastery worksheet mix
 */
export interface MasteryWorksheetMix {
  /** Current skill being practiced */
  currentSkill: SkillDefinition
  /** Number of current skill problems */
  currentSkillProblems: number
  /** Review selection */
  review: ReviewSelection
  /** Total problems in worksheet */
  totalProblems: number
  /** Mix ratio (0-1, where 0.25 = 25% review) */
  mixRatio: number
}

/**
 * Get review skills for a given current skill, filtered by mastery state
 *
 * @param currentSkill - The skill being practiced
 * @param masteryStates - Map of skill IDs to mastery records
 * @param selectedReviewSkills - Optional manual override of which review skills to include
 * @returns Array of review skills (only mastered skills from recommendedReview)
 */
export function getReviewSkills(
  currentSkill: SkillDefinition,
  masteryStates: MasteryStateMap,
  selectedReviewSkills?: SkillId[]
): SkillDefinition[] {
  // If user manually selected review skills, use those (filtered by mastery)
  if (selectedReviewSkills && selectedReviewSkills.length > 0) {
    return selectedReviewSkills
      .filter((skillId) => {
        const masteryState = masteryStates.get(skillId)
        return masteryState?.isMastered === true
      })
      .map((skillId) => getSkillById(skillId))
      .filter((skill): skill is SkillDefinition => skill !== undefined)
  }

  // Otherwise, use recommended review skills (filtered by mastery)
  return currentSkill.recommendedReview
    .filter((skillId) => {
      const masteryState = masteryStates.get(skillId)
      return masteryState?.isMastered === true
    })
    .map((skillId) => getSkillById(skillId))
    .filter((skill): skill is SkillDefinition => skill !== undefined)
}

/**
 * Distribute review problem count across review skills
 *
 * @param reviewSkills - Array of review skills
 * @param totalReviewProblems - Total number of review problems to distribute
 * @returns Map of skill ID to problem count
 */
export function distributeReviewProblems(
  reviewSkills: SkillDefinition[],
  totalReviewProblems: number
): Map<SkillId, number> {
  const distribution = new Map<SkillId, number>()

  if (reviewSkills.length === 0) {
    return distribution
  }

  // Simple strategy: distribute evenly, with remainder going to first skills
  const baseCount = Math.floor(totalReviewProblems / reviewSkills.length)
  const remainder = totalReviewProblems % reviewSkills.length

  reviewSkills.forEach((skill, index) => {
    const count = baseCount + (index < remainder ? 1 : 0)
    if (count > 0) {
      distribution.set(skill.id, count)
    }
  })

  return distribution
}

/**
 * Calculate mastery worksheet mix
 *
 * @param currentSkillId - The skill being practiced
 * @param masteryStates - Map of skill IDs to mastery records
 * @param totalProblems - Total problems in worksheet
 * @param mixRatio - Review ratio (0-1, where 0.25 = 25% review)
 * @param selectedReviewSkills - Optional manual override of which review skills to include
 * @returns Mastery worksheet mix breakdown
 */
export function calculateMasteryMix(
  currentSkillId: SkillId,
  masteryStates: MasteryStateMap,
  totalProblems: number,
  mixRatio: number = 0.25,
  selectedReviewSkills?: SkillId[]
): MasteryWorksheetMix {
  const currentSkill = getSkillById(currentSkillId)
  if (!currentSkill) {
    throw new Error(`Skill not found: ${currentSkillId}`)
  }

  // Clamp mix ratio to 0-1
  const clampedRatio = Math.max(0, Math.min(1, mixRatio))

  // Calculate problem counts
  const reviewProblemCount = Math.floor(totalProblems * clampedRatio)
  const currentProblemCount = totalProblems - reviewProblemCount

  // Get review skills
  const reviewSkills = getReviewSkills(currentSkill, masteryStates, selectedReviewSkills)

  // Distribute review problems
  const problemsPerSkill = distributeReviewProblems(reviewSkills, reviewProblemCount)

  return {
    currentSkill,
    currentSkillProblems: currentProblemCount,
    review: {
      skills: reviewSkills,
      problemsPerSkill,
      totalProblems: reviewProblemCount,
    },
    totalProblems,
    mixRatio: clampedRatio,
  }
}

/**
 * Convert skill definition to WorksheetConfig for problem generation
 *
 * This is the bridge between mastery mode and smart mode.
 * Each skill's configuration (digitRange, regrouping, scaffolding) maps directly
 * to a Custom Mode configuration.
 *
 * @param skill - Skill definition
 * @param problemCount - Number of problems to generate for this skill
 * @returns WorksheetConfig for problem generation
 */
export function skillToConfig(
  skill: SkillDefinition,
  problemCount: number
): Partial<WorksheetConfig> {
  return {
    version: 4,
    mode: 'custom',

    // Digit range from skill
    digitRange: skill.digitRange,

    // Regrouping configuration from skill
    pAnyStart: skill.regroupingConfig.pAnyStart,
    pAllStart: skill.regroupingConfig.pAllStart,

    // Scaffolding from skill
    displayRules: skill.recommendedScaffolding,

    // Problem count
    problemsPerPage: problemCount,

    // Operator from skill
    operator: skill.operator,
  }
}

/**
 * Generate worksheet configuration for mastery mode
 *
 * This function takes a mastery mix and converts it into a configuration
 * that can be used to generate problems. It's the final step before problem generation.
 *
 * @param mix - Mastery worksheet mix
 * @returns WorksheetConfig for the entire worksheet
 */
export function generateMasteryWorksheetConfig(
  mix: MasteryWorksheetMix
): Partial<WorksheetConfig> & {
  _masteryMix?: {
    currentSkillId: SkillId
    currentSkillProblems: number
    reviewProblems: number
    reviewSkills: SkillId[]
    reviewProblemCounts: Record<string, number>
    mixRatio: number
  }
} {
  // Start with current skill config
  const config = skillToConfig(mix.currentSkill, mix.totalProblems)

  // Add mastery-specific metadata for UI observability
  // (This is stored separately and not persisted to the schema)
  return {
    ...config,

    // Store review breakdown for observability
    // (This can be used by the UI to show what's in the mix)
    _masteryMix: {
      currentSkillId: mix.currentSkill.id,
      currentSkillProblems: mix.currentSkillProblems,
      reviewProblems: mix.review.totalProblems,
      reviewSkills: mix.review.skills.map((s) => s.id),
      reviewProblemCounts: Object.fromEntries(mix.review.problemsPerSkill),
      mixRatio: mix.mixRatio,
    },
  }
}

/**
 * Helper: Get mastery state for a skill (or create default if not exists)
 *
 * @param skillId - Skill ID
 * @param masteryStates - Map of skill IDs to mastery records
 * @returns Mastery state (or default state if not found)
 */
export function getMasteryState(
  skillId: SkillId,
  masteryStates: MasteryStateMap
): WorksheetMastery | { isMastered: false } {
  return masteryStates.get(skillId) || { isMastered: false }
}

/**
 * Helper: Check if a skill's prerequisites are met
 *
 * @param skill - Skill definition
 * @param masteryStates - Map of skill IDs to mastery records
 * @returns True if all prerequisites are mastered
 */
export function arePrerequisitesMet(
  skill: SkillDefinition,
  masteryStates: MasteryStateMap
): boolean {
  return skill.prerequisites.every((prereqId) => {
    const state = getMasteryState(prereqId, masteryStates)
    return state.isMastered === true
  })
}

/**
 * Helper: Get next available skill (first skill with unmet prerequisites or not mastered)
 *
 * @param operator - "addition" or "subtraction"
 * @param masteryStates - Map of skill IDs to mastery records
 * @returns Next skill to practice, or undefined if all mastered
 */
export function getNextAvailableSkill(
  operator: 'addition' | 'subtraction',
  masteryStates: MasteryStateMap
): SkillDefinition | undefined {
  const skills = SKILL_DEFINITIONS.filter((s) => s.operator === operator)

  // Find first skill that is not mastered
  return skills.find((skill) => {
    const state = getMasteryState(skill.id, masteryStates)
    return state.isMastered !== true
  })
}