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 } } } |