revert: undo performance changes that broke intervention badges

Reverts the following commits that traded functionality for
marginal (or negative) performance gains:

- Skip intervention computation during SSR (broke badges)
- Defer MiniAbacus rendering (caused visual flash)
- Batch DB queries with altered return type
- Eliminate redundant getViewerId calls

The intervention badges are critical for parents/teachers to
identify students who need help. Performance should not
compromise core functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-24 06:51:17 -06:00
parent 8cdcb9f292
commit aa6506957c
4 changed files with 142 additions and 98 deletions

View File

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

View File

@ -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 (
<div
className={css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<div
className={css({
width: '60px',
height: '80px',
bg: 'bg.muted',
rounded: 'md',
animation: 'pulse 2s ease-in-out infinite',
})}
/>
</div>
)
}
return (
<div
className={css({

View File

@ -1,6 +1,25 @@
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import { getPlayersWithSkillData } from '@/lib/curriculum/server'
import { getViewerId } from '@/lib/viewer'
import { PracticeClient } from './PracticeClient'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
/**
* Practice page - Server Component
*
@ -11,8 +30,13 @@ import { PracticeClient } from './PracticeClient'
*/
export default async function PracticePage() {
// Fetch players with skill data directly on server - no HTTP round-trip
// Returns players, viewerId, and userId in a single call to avoid redundant lookups
const { players, viewerId, userId } = await getPlayersWithSkillData()
const players = await getPlayersWithSkillData()
return <PracticeClient initialPlayers={players} viewerId={viewerId} userId={userId} />
// Get viewer ID for session observation
const viewerId = await getViewerId()
// Get database user ID for parent socket notifications
const user = await getOrCreateUser(viewerId)
return <PracticeClient initialPlayers={players} viewerId={viewerId} userId={user.id} />
}

View File

@ -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<Player[]> {
}
/**
* 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<SkillDistribution> {
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<PlayersWithSkillDataResult> {
export async function getPlayersWithSkillData(): Promise<StudentWithSkillData[]> {
const viewerId = await getViewerId()
// Get or create user record
@ -125,8 +180,6 @@ export async function getPlayersWithSkillData(): Promise<PlayersWithSkillDataRes
user = newUser
}
const userId = user.id
// Get player IDs linked via parent_child table
const linkedPlayerIds = await db.query.parentChild.findMany({
where: eq(parentChild.parentUserId, user.id),
@ -147,28 +200,13 @@ export async function getPlayersWithSkillData(): Promise<PlayersWithSkillDataRes
})
}
if (players.length === 0) {
return { players: [], viewerId, userId }
}
// 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),
// Fetch skill mastery for all players in parallel
const playersWithSkills = await Promise.all(
players.map(async (player) => {
const skills = await db.query.playerSkillMastery.findMany({
where: eq(schema.playerSkillMastery.playerId, player.id),
})
// Group skill mastery by player ID for O(1) lookups
const skillsByPlayerId = new Map<string, typeof allSkillMastery>()
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
@ -177,6 +215,7 @@ export async function getPlayersWithSkillData(): Promise<PlayersWithSkillDataRes
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
@ -184,22 +223,35 @@ export async function getPlayersWithSkillData(): Promise<PlayersWithSkillDataRes
}
}
// Compute skill category
const skillCategory = computeSkillCategory(practicingSkills)
// 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
intervention = computeIntervention(
distribution,
daysSinceLastPractice,
practicingSkills.length > 0
)
}
return {
...player,
practicingSkills,
lastPracticedAt,
skillCategory,
intervention: null as ReturnType<typeof computeIntervention>,
intervention,
}
})
)
// 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 }
return playersWithSkills
}
// Re-export the individual functions for granular prefetching