diff --git a/apps/web/src/app/api/players/with-skill-data/route.ts b/apps/web/src/app/api/players/with-skill-data/route.ts index b00cdfab..75565ffc 100644 --- a/apps/web/src/app/api/players/with-skill-data/route.ts +++ b/apps/web/src/app/api/players/with-skill-data/route.ts @@ -8,8 +8,8 @@ import { getPlayersWithSkillData } from '@/lib/curriculum/server' */ export async function GET() { try { - const result = await getPlayersWithSkillData() - return NextResponse.json({ players: result.players }) + const players = await getPlayersWithSkillData() + return NextResponse.json({ players }) } catch (error) { console.error('Failed to fetch players with skill data:', error) return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 }) diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 33bac778..67d7f7e6 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -204,7 +204,6 @@ function HeroSection() { } // Mini abacus that cycles through a sequence of values -// PERF: Defers rendering until after hydration to avoid SSR overhead function MiniAbacus({ values, columns = 3, @@ -215,14 +214,8 @@ function MiniAbacus({ interval?: number }) { const [currentIndex, setCurrentIndex] = useState(0) - const [mounted, setMounted] = useState(false) const appConfig = useAbacusConfig() - // Defer rendering until after hydration - useEffect(() => { - setMounted(true) - }, []) - useEffect(() => { if (values.length === 0) return @@ -247,31 +240,6 @@ function MiniAbacus({ }, } - // Render placeholder during SSR and initial hydration - if (!mounted) { - return ( -
-
-
- ) - } - return (
+ // Get viewer ID for session observation + const viewerId = await getViewerId() + + // Get database user ID for parent socket notifications + const user = await getOrCreateUser(viewerId) + + return } diff --git a/apps/web/src/lib/curriculum/server.ts b/apps/web/src/lib/curriculum/server.ts index eef161cb..d950840c 100644 --- a/apps/web/src/lib/curriculum/server.ts +++ b/apps/web/src/lib/curriculum/server.ts @@ -15,7 +15,13 @@ import { parentChild } from '@/db/schema' import type { Player } from '@/db/schema/players' import { getPlayer } from '@/lib/arcade/player-manager' import { getViewerId } from '@/lib/viewer' -import { computeIntervention, computeSkillCategory, type StudentWithSkillData } from '@/utils/studentGrouping' +import { + computeIntervention, + computeSkillCategory, + type SkillDistribution, + type StudentWithSkillData, +} from '@/utils/studentGrouping' +import { computeBktFromHistory, getStalenessWarning } from './bkt' import { getAllSkillMastery, getPaginatedSessions, @@ -89,12 +95,67 @@ export async function getPlayersForViewer(): Promise { } /** - * Result from getPlayersWithSkillData including context for page rendering + * Compute skill distribution for a player from their problem history. + * Uses BKT to determine mastery levels and staleness. */ -export interface PlayersWithSkillDataResult { - players: StudentWithSkillData[] - viewerId: string - userId: string +async function computePlayerSkillDistribution( + playerId: string, + practicingSkillIds: string[] +): Promise { + const distribution: SkillDistribution = { + strong: 0, + stale: 0, + developing: 0, + weak: 0, + unassessed: 0, + total: practicingSkillIds.length, + } + + if (practicingSkillIds.length === 0) return distribution + + // Fetch recent problem history (last 100 problems is enough for BKT) + const problemHistory = await getRecentSessionResults(playerId, 100) + + if (problemHistory.length === 0) { + // No history = all unassessed + distribution.unassessed = practicingSkillIds.length + return distribution + } + + // Compute BKT + const now = new Date() + const bktResult = computeBktFromHistory(problemHistory, {}) + const bktMap = new Map(bktResult.skills.map((s) => [s.skillId, s])) + + for (const skillId of practicingSkillIds) { + const bkt = bktMap.get(skillId) + + if (!bkt || bkt.opportunities === 0) { + distribution.unassessed++ + continue + } + + const classification = bkt.masteryClassification ?? 'developing' + + if (classification === 'strong') { + // Check staleness + const lastPracticed = bkt.lastPracticedAt + if (lastPracticed) { + const daysSince = (now.getTime() - lastPracticed.getTime()) / (1000 * 60 * 60 * 24) + if (getStalenessWarning(daysSince)) { + distribution.stale++ + } else { + distribution.strong++ + } + } else { + distribution.strong++ + } + } else { + distribution[classification]++ + } + } + + return distribution } /** @@ -105,14 +166,8 @@ export interface PlayersWithSkillDataResult { * - lastPracticedAt: Most recent practice timestamp (max of all skill lastPracticedAt) * - skillCategory: Computed highest-level skill category * - intervention: Intervention data if student needs attention - * - * Also returns viewerId and userId to avoid redundant calls in page components. - * - * Performance: Uses batched queries to avoid N+1 query patterns. - * - Single query for all skill mastery records across all players - * - Returns viewerId/userId to avoid redundant getViewerId() calls */ -export async function getPlayersWithSkillData(): Promise { +export async function getPlayersWithSkillData(): Promise { const viewerId = await getViewerId() // Get or create user record @@ -125,8 +180,6 @@ export async function getPlayersWithSkillData(): Promise { + const skills = await db.query.playerSkillMastery.findMany({ + where: eq(schema.playerSkillMastery.playerId, player.id), + }) - // OPTIMIZATION: Batch query all skill mastery records for all players at once - const playerIds = players.map((p) => p.id) - const allSkillMastery = await db.query.playerSkillMastery.findMany({ - where: inArray(schema.playerSkillMastery.playerId, playerIds), - }) + // Get practicing skills and compute lastPracticedAt + const practicingSkills: string[] = [] + let lastPracticedAt: Date | null = null - // Group skill mastery by player ID for O(1) lookups - const skillsByPlayerId = new Map() - for (const skill of allSkillMastery) { - const existing = skillsByPlayerId.get(skill.playerId) || [] - existing.push(skill) - skillsByPlayerId.set(skill.playerId, existing) - } - - // First pass: compute basic skill data without intervention (no additional DB queries) - const playersWithBasicSkills = players.map((player) => { - const skills = skillsByPlayerId.get(player.id) || [] - - // Get practicing skills and compute lastPracticedAt - const practicingSkills: string[] = [] - let lastPracticedAt: Date | null = null - - for (const skill of skills) { - if (skill.isPracticing) { - practicingSkills.push(skill.skillId) - } - if (skill.lastPracticedAt) { - if (!lastPracticedAt || skill.lastPracticedAt > lastPracticedAt) { - lastPracticedAt = skill.lastPracticedAt + for (const skill of skills) { + if (skill.isPracticing) { + practicingSkills.push(skill.skillId) + } + // Track the most recent practice date across all skills + if (skill.lastPracticedAt) { + if (!lastPracticedAt || skill.lastPracticedAt > lastPracticedAt) { + lastPracticedAt = skill.lastPracticedAt + } } } - } - const skillCategory = computeSkillCategory(practicingSkills) + // Compute skill category + const skillCategory = computeSkillCategory(practicingSkills) - return { - ...player, - practicingSkills, - lastPracticedAt, - skillCategory, - intervention: null as ReturnType, - } - }) + // Compute intervention data (only for non-archived students with skills) + let intervention = null + if (!player.isArchived && practicingSkills.length > 0) { + const distribution = await computePlayerSkillDistribution(player.id, practicingSkills) + const daysSinceLastPractice = lastPracticedAt + ? (Date.now() - lastPracticedAt.getTime()) / (1000 * 60 * 60 * 24) + : Infinity - // PERFORMANCE: Skip expensive intervention computation during SSR - // Intervention badges are helpful but not critical for initial render. - // They can be computed lazily on the client if needed. - // This avoids N additional database queries for session history. - return { players: playersWithBasicSkills, viewerId, userId } + intervention = computeIntervention( + distribution, + daysSinceLastPractice, + practicingSkills.length > 0 + ) + } + + return { + ...player, + practicingSkills, + lastPracticedAt, + skillCategory, + intervention, + } + }) + ) + + return playersWithSkills } // Re-export the individual functions for granular prefetching