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:
parent
8cdcb9f292
commit
aa6506957c
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue