All files / web/src/db/schema player-skill-mastery.ts

91.05% Statements 112/123
100% Branches 3/3
50% Functions 2/4
91.05% Lines 112/123

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 1242x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 8x 8x 2x 2x 2x     2x 2x 2x                   2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 39x 39x 39x 39x 39x 39x 39x 39x 39x 2x 2x 2x 2x  
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { players } from './players'
 
// ============================================================================
// Practice Level Types & Helpers
// ============================================================================
 
/**
 * Practice level for a skill:
 * - 'none': Skill is inactive (not in practice rotation)
 * - 'abacus': Skill appears in "Use Abacus" parts only
 * - 'visual': Skill appears in all parts (abacus + visualization + linear)
 *
 * Progression: none → abacus → visual
 */
export type PracticeLevel = 'none' | 'abacus' | 'visual'
 
/** Check if a skill is active at any level (not 'none') */
export function isActive(level: PracticeLevel): boolean {
  return level !== 'none'
}
 
/** Check if a skill is ready for visualization/linear parts */
export function isVisualReady(level: PracticeLevel): boolean {
  return level === 'visual'
}
 
/** Cycle to the next practice level: none → abacus → visual → none */
export function nextPracticeLevel(level: PracticeLevel): PracticeLevel {
  switch (level) {
    case 'none':
      return 'abacus'
    case 'abacus':
      return 'visual'
    case 'visual':
      return 'none'
  }
}
 
/**
 * Player skill mastery table - tracks per-skill progress for each player
 *
 * Each row represents a player's progress with a specific abacus skill.
 * Skills are identified by their path (e.g., "fiveComplements.4=5-1").
 */
export const playerSkillMastery = sqliteTable(
  'player_skill_mastery',
  {
    /** Primary key */
    id: text('id')
      .primaryKey()
      .$defaultFn(() => createId()),
 
    /** Foreign key to players table */
    playerId: text('player_id')
      .notNull()
      .references(() => players.id, { onDelete: 'cascade' }),
 
    /**
     * Skill identifier - matches the skill paths from SkillSet type
     * Examples:
     * - "basic.directAddition"
     * - "fiveComplements.4=5-1"
     * - "tenComplements.9=10-1"
     * - "fiveComplementsSub.-4=-5+1"
     * - "tenComplementsSub.-9=+1-10"
     */
    skillId: text('skill_id').notNull(),
 
    // NOTE: attempts, correct, consecutiveCorrect columns REMOVED
    // These are now computed on-the-fly from session results (single source of truth)
    // See: getRecentSessionResults() in session-planner.ts
 
    /**
     * @deprecated Use practiceLevel instead. Kept in DB for migration safety.
     * Whether this skill is in the student's active practice rotation.
     */
    isPracticing: integer('is_practicing', { mode: 'boolean' }).notNull().default(false),
 
    /**
     * Practice level controlling which session parts this skill appears in:
     * - 'none': Skill is inactive
     * - 'abacus': Skill appears in abacus parts only
     * - 'visual': Skill appears in all parts (abacus + visualization + linear)
     *
     * Set by teacher via ManualSkillSelector 3-state toggle.
     * Mastery is tracked via BKT (Bayesian Knowledge Tracing) using session history.
     */
    practiceLevel: text('practice_level').$type<PracticeLevel>().notNull().default('none'),
 
    /** When this skill was last practiced */
    lastPracticedAt: integer('last_practiced_at', { mode: 'timestamp' }),
 
    /** When this record was created */
    createdAt: integer('created_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
 
    /** When this record was last updated */
    updatedAt: integer('updated_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
 
    /**
     * Whether help was used the last time this skill was practiced
     */
    lastHadHelp: integer('last_had_help', { mode: 'boolean' }).notNull().default(false),
  },
  (table) => ({
    /** Index for fast lookups by playerId */
    playerIdIdx: index('player_skill_mastery_player_id_idx').on(table.playerId),
 
    /** Unique constraint: one record per player per skill */
    playerSkillUnique: uniqueIndex('player_skill_mastery_player_skill_unique').on(
      table.playerId,
      table.skillId
    ),
  })
)
 
export type PlayerSkillMastery = typeof playerSkillMastery.$inferSelect
export type NewPlayerSkillMastery = typeof playerSkillMastery.$inferInsert