All files / web/src/components/toys/number-line/talkToNumber assembleChildProfile.ts

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

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                                                                                                                                                                                                                                                                                                 
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
import { getPlayer } from '@/lib/arcade/player-manager'
import { getRecentSessionResults } from '@/lib/curriculum/session-planner'
import { getPlayerCurriculum } from '@/lib/curriculum/progress-manager'
import { computeBktFromHistory } from '@/lib/curriculum/bkt/compute-bkt'
import { getPhaseDisplayInfo } from '@/lib/curriculum/definitions'
import { getSkillDisplayName } from '@/lib/curriculum/skill-tutorial-config'
import type { ChildProfile, SkillSnapshot, GameSnapshot } from './childProfile'
import type { GameStatsBreakdown } from '@/db/schema/player-stats'
import { getAgeFromBirthday } from '@/lib/playerAge'

/** Static map — avoids importing game manifests which pull in React. */
const GAME_DISPLAY_NAMES: Record<string, string> = {
  matching: 'Matching Pairs',
  'complement-race': 'Complement Race',
  'card-sorting': 'Card Sorting',
  'memory-quiz': 'Memory Quiz',
  rithmomachia: 'Rithmomachia',
  'know-your-world': 'Know Your World',
}

function getGameDisplayName(gameType: string): string {
  return GAME_DISPLAY_NAMES[gameType] ?? gameType
}

function formatRecency(date: Date): string {
  const now = new Date()
  const diffMs = now.getTime() - date.getTime()
  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))

  if (diffDays === 0) return 'today'
  if (diffDays === 1) return 'yesterday'
  if (diffDays < 7) return `${diffDays} days ago`
  if (diffDays < 14) return 'about a week ago'
  if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
  return 'a while ago'
}

function toSkillSnapshot(skill: {
  skillId: string
  masteryClassification: 'strong' | 'developing' | 'weak'
}): SkillSnapshot {
  return {
    displayName: getSkillDisplayName(skill.skillId),
    mastery: skill.masteryClassification,
  }
}

/**
 * Assemble an ephemeral child profile from DB data.
 *
 * Runs parallel queries (~25ms) + sync BKT computation (~5-10ms).
 * Total budget: <100ms (runs during ring time).
 *
 * Returns:
 * - `ChildProfile` on success
 * - `{ failed: true }` if playerId was provided but assembly failed
 * - `undefined` if no playerId was provided (handled by caller)
 */
export async function assembleChildProfile(
  playerId: string
): Promise<ChildProfile | { failed: true }> {
  try {
    const [player, results, curriculum, stats] = await Promise.all([
      getPlayer(playerId),
      getRecentSessionResults(playerId, 50),
      getPlayerCurriculum(playerId),
      db.query.playerStats.findFirst({
        where: eq(schema.playerStats.playerId, playerId),
      }),
    ])

    if (!player) {
      console.warn('[assembleChildProfile] player not found: %s', playerId)
      return { failed: true }
    }

    const profile: ChildProfile = {
      name: player.name,
      age: getAgeFromBirthday(player.birthday) ?? undefined,
      emoji: player.emoji || undefined,
    }

    // Curriculum position
    if (curriculum?.currentPhaseId) {
      const phaseInfo = getPhaseDisplayInfo(curriculum.currentPhaseId)
      profile.currentFocus = phaseInfo.phaseName
    }

    // BKT skill analysis
    if (results.length > 0) {
      const bkt = computeBktFromHistory(results)

      profile.strengths = bkt.strengths.slice(0, 3).map(toSkillSnapshot)
      profile.struggles = bkt.interventionNeeded.slice(0, 3).map(toSkillSnapshot)
      profile.developing = bkt.skills
        .filter((s) => s.masteryClassification === 'developing')
        .slice(0, 3)
        .map(toSkillSnapshot)

      // Count unique sessions
      const sessionIds = new Set(results.map((r) => r.sessionId))
      profile.totalSessions = sessionIds.size

      // Last practiced
      profile.lastPracticed = formatRecency(new Date(results[0].timestamp))
    }

    // Game stats
    if (stats) {
      profile.gamesPlayed = stats.gamesPlayed
      profile.totalWins = stats.totalWins

      if (stats.favoriteGameType) {
        profile.favoriteGame = getGameDisplayName(stats.favoriteGameType)
      }

      // Build game highlights from per-game breakdown
      const gameStats = stats.gameStats as Record<string, GameStatsBreakdown> | null
      if (gameStats && typeof gameStats === 'object') {
        const highlights: GameSnapshot[] = Object.entries(gameStats)
          .filter(([, gs]) => gs.gamesPlayed > 0)
          .sort((a, b) => b[1].gamesPlayed - a[1].gamesPlayed)
          .slice(0, 3)
          .map(([gameType, gs]) => ({
            displayName: getGameDisplayName(gameType),
            gamesPlayed: gs.gamesPlayed,
            wins: gs.wins,
            highestAccuracy: gs.highestAccuracy,
          }))

        if (highlights.length > 0) {
          profile.gameHighlights = highlights
        }
      }
    }

    return profile
  } catch (err) {
    console.error('[assembleChildProfile] failed for player %s:', playerId, err)
    return { failed: true }
  }
}