feat(practice): implement retry wrong problems system

When students get problems wrong, they are now re-queued to retry:
- Problems retry in epochs (max 3 attempts per problem)
- Mastery weight decays: 100% → 50% → 25% → 0%
- Transition screen shows encouraging message between epochs
- Progress indicator shows retry attempt badges on slots
- BKT calculations respect mastery weight from retries

Also fixes entry prompts UNIQUE constraint error by marking
expired prompts before inserting new ones.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-29 21:56:48 -06:00
parent a6584143eb
commit 474c4da05a
17 changed files with 2013 additions and 171 deletions

View File

@@ -0,0 +1,2 @@
-- Add retry_state column to session_plans for tracking retry epochs
ALTER TABLE `session_plans` ADD `retry_state` text;

File diff suppressed because it is too large Load Diff

View File

@@ -344,6 +344,13 @@
"when": 1767044481301,
"tag": "0048_ambitious_firedrake",
"breakpoints": true
},
{
"idx": 49,
"version": "6",
"when": 1767060697736,
"tag": "0049_flowery_jean_grey",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq, inArray, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
@@ -90,8 +90,21 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
// Get currently present students
const presentPlayerIds = new Set(await getPresentPlayerIds(classroomId))
// Get existing pending prompts to avoid duplicates (only non-expired ones)
// Mark any expired pending prompts as 'expired' so unique constraint allows new ones
const now = new Date()
await db
.update(schema.entryPrompts)
.set({ status: 'expired' })
.where(
and(
eq(schema.entryPrompts.classroomId, classroomId),
eq(schema.entryPrompts.status, 'pending'),
inArray(schema.entryPrompts.playerId, body.playerIds),
lt(schema.entryPrompts.expiresAt, now) // Only mark actually expired prompts
)
)
// Now query for any truly active (non-expired) pending prompts
const existingPrompts = await db.query.entryPrompts.findMany({
where: and(
eq(schema.entryPrompts.classroomId, classroomId),
@@ -99,7 +112,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
inArray(schema.entryPrompts.playerId, body.playerIds)
),
})
// Filter out expired prompts - they shouldn't block creating new prompts
// Filter to only active prompts (not expired)
const activeExistingPrompts = existingPrompts.filter((p) => p.expiresAt > now)
const existingPromptPlayerIds = new Set(activeExistingPrompts.map((p) => p.playerId))

View File

@@ -184,14 +184,11 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
...category,
students: category.students.filter((s) => !attentionStudentIds.has(s.id)),
}))
.filter(
(category) =>
category.students.length > 0 ||
(attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0) > 0
),
// Only show categories that have visible students (not moved to attention)
.filter((category) => category.students.length > 0),
}))
.filter((bucket) => bucket.categories.length > 0)
}, [groupedStudents, attentionStudentIds, attentionCountsByBucket])
}, [groupedStudents, attentionStudentIds])
// Handle student selection - navigate to student's dashboard page
const handleSelectStudent = useCallback(

View File

@@ -210,6 +210,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
isBrowseMode,
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
onBrowseNavigate: setBrowseIndex,
plan: currentPlan,
}
: undefined

View File

@@ -217,6 +217,7 @@ function createMockSessionPlanWithProblems(config: {
pausedAt: null,
pausedBy: null,
pauseReason: null,
retryState: null,
}
}

View File

@@ -5,19 +5,23 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { flushSync } from 'react-dom'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import type {
ProblemSlot,
SessionHealth,
SessionPart,
SessionPartType,
SessionPlan,
SlotResult,
import {
getCurrentProblemInfo,
isInRetryEpoch,
needsRetryTransition,
type ProblemSlot,
type SessionHealth,
type SessionPart,
type SessionPartType,
type SessionPlan,
type SlotResult,
} from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { type AutoPauseStats, calculateAutoPauseInfo, type PauseInfo } from './autoPauseCalculator'
import { BrowseModeView, getLinearIndex } from './BrowseModeView'
import { PartTransitionScreen, TRANSITION_COUNTDOWN_MS } from './PartTransitionScreen'
import { RetryTransitionScreen } from './RetryTransitionScreen'
import { SessionPausedModal } from './SessionPausedModal'
// Re-export types for consumers
@@ -631,18 +635,19 @@ export function ActiveSession({
const { playSound } = usePracticeSoundEffects()
// Compute initial problem from plan for SSR hydration (must be before useInteractionPhase)
// Uses getCurrentProblemInfo to account for retry epochs
const currentProblemInfo = useMemo(() => getCurrentProblemInfo(plan), [plan])
const initialProblem = useMemo(() => {
const currentPart = plan.parts[plan.currentPartIndex]
const currentSlot = currentPart?.slots[plan.currentSlotIndex]
if (currentPart && currentSlot?.problem) {
if (currentProblemInfo) {
return {
problem: currentSlot.problem,
slotIndex: plan.currentSlotIndex,
problem: currentProblemInfo.problem,
slotIndex: currentProblemInfo.originalSlotIndex,
partIndex: plan.currentPartIndex,
}
}
return undefined
}, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex])
}, [currentProblemInfo, plan.currentPartIndex])
// Interaction state machine - single source of truth for UI state
const {
@@ -975,6 +980,15 @@ export function ActiveSession({
// Track the previous part index to detect part changes
const prevPartIndexRef = useRef<number>(plan.currentPartIndex)
// Retry transition state - for showing transition screen between retry epochs
const [isInRetryTransition, setIsInRetryTransition] = useState(false)
const [retryTransitionData, setRetryTransitionData] = useState<{
epochNumber: number
problemCount: number
} | null>(null)
// Track previous epoch to detect epoch changes
const prevEpochRef = useRef<number>(0)
// Browse mode state - isBrowseMode is controlled via props
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
@@ -1140,14 +1154,26 @@ export function ActiveSession({
}
const showOnScreenKeypad = hasPhysicalKeyboard === false || keypadWasShownRef.current
// Get current part and slot from plan
// Get current part and slot from plan (accounting for retry epochs)
const parts = plan.parts
const currentPartIndex = plan.currentPartIndex
const currentSlotIndex = plan.currentSlotIndex
const currentPart = parts[currentPartIndex] as SessionPart | undefined
const currentSlot = currentPart?.slots[currentSlotIndex] as ProblemSlot | undefined
// Use getCurrentProblemInfo which handles both original slots and retry epochs
const currentSlot = currentProblemInfo
? {
index: currentProblemInfo.originalSlotIndex,
purpose: currentProblemInfo.purpose,
problem: currentProblemInfo.problem,
constraints: {},
}
: (currentPart?.slots[currentSlotIndex] as ProblemSlot | undefined)
const sessionHealth = plan.sessionHealth as SessionHealth | null
// Retry epoch tracking
const inRetryEpoch = currentProblemInfo?.isRetry ?? false
const retryEpochNumber = currentProblemInfo?.epochNumber ?? 0
// Check for session completion
useEffect(() => {
if (currentPartIndex >= parts.length) {
@@ -1189,18 +1215,46 @@ export function ActiveSession({
onPartTransitionComplete?.()
}, [onPartTransitionComplete])
// Initialize problem when slot changes and in loading phase
// Detect retry epoch transitions and show retry transition screen
useEffect(() => {
if (currentPart && currentSlot && phase.phase === 'loading') {
if (!currentSlot.problem) {
throw new Error(
`Problem not pre-generated for slot ${currentSlotIndex} in part ${currentPartIndex}. ` +
'This indicates a bug in session planning - problems should be generated at plan creation time.'
)
const currentEpoch = retryEpochNumber
const prevEpoch = prevEpochRef.current
// If we just entered a new retry epoch (epoch increased from 0 to 1, or 1 to 2)
if (currentEpoch > prevEpoch && currentEpoch > 0 && currentProblemInfo) {
// Get the count of problems in this retry epoch
const retryState = plan.retryState?.[plan.currentPartIndex]
const problemCount = retryState?.currentEpochItems?.length ?? 0
if (problemCount > 0) {
setRetryTransitionData({
epochNumber: currentEpoch,
problemCount,
})
setIsInRetryTransition(true)
}
loadProblem(currentSlot.problem, currentSlotIndex, currentPartIndex)
}
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, phase.phase, loadProblem])
prevEpochRef.current = currentEpoch
}, [retryEpochNumber, currentProblemInfo, plan.retryState, plan.currentPartIndex])
// Handle retry transition screen completion
const handleRetryTransitionComplete = useCallback(() => {
setIsInRetryTransition(false)
setRetryTransitionData(null)
}, [])
// Initialize problem when slot changes and in loading phase
// Uses currentProblemInfo to handle both original slots and retry epochs
useEffect(() => {
if (currentPart && currentProblemInfo && phase.phase === 'loading') {
loadProblem(
currentProblemInfo.problem,
currentProblemInfo.originalSlotIndex,
currentPartIndex
)
}
}, [currentPart, currentProblemInfo, currentPartIndex, phase.phase, loadProblem])
// Auto-trigger help when an unambiguous prefix sum is detected
// The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires
@@ -1584,68 +1638,93 @@ export function ActiveSession({
overflow: 'hidden', // Prevent overflow
})}
>
{/* Purpose badge with tooltip */}
{/* Purpose badge with tooltip - shows retry indicator when in retry epoch */}
{currentSlot && (
<TooltipProvider>
<Tooltip
content={<PurposeTooltipContent slot={currentSlot} />}
side="bottom"
delayDuration={300}
>
<div
data-element="purpose-retry-container"
className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}
>
{/* Retry indicator badge */}
{inRetryEpoch && (
<div
data-element="problem-purpose"
data-purpose={currentSlot.purpose}
data-element="retry-indicator"
data-epoch={retryEpochNumber}
className={css({
position: 'relative',
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
cursor: 'help',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
_hover: {
transform: 'scale(1.05)',
boxShadow: 'sm',
},
backgroundColor:
currentSlot.purpose === 'focus'
? isDark
? 'blue.900'
: 'blue.100'
: currentSlot.purpose === 'reinforce'
? isDark
? 'orange.900'
: 'orange.100'
: currentSlot.purpose === 'review'
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'purple.900'
: 'purple.100',
color:
currentSlot.purpose === 'focus'
? isDark
? 'blue.200'
: 'blue.700'
: currentSlot.purpose === 'reinforce'
? isDark
? 'orange.200'
: 'orange.700'
: currentSlot.purpose === 'review'
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'purple.200'
: 'purple.700',
backgroundColor: isDark ? 'red.900' : 'red.100',
color: isDark ? 'red.200' : 'red.700',
border: '1px solid',
borderColor: isDark ? 'red.700' : 'red.300',
})}
>
{currentSlot.purpose}
Retry {retryEpochNumber}/2
</div>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip
content={<PurposeTooltipContent slot={currentSlot} />}
side="bottom"
delayDuration={300}
>
<div
data-element="problem-purpose"
data-purpose={currentSlot.purpose}
className={css({
position: 'relative',
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
cursor: 'help',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
_hover: {
transform: 'scale(1.05)',
boxShadow: 'sm',
},
backgroundColor:
currentSlot.purpose === 'focus'
? isDark
? 'blue.900'
: 'blue.100'
: currentSlot.purpose === 'reinforce'
? isDark
? 'orange.900'
: 'orange.100'
: currentSlot.purpose === 'review'
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'purple.900'
: 'purple.100',
color:
currentSlot.purpose === 'focus'
? isDark
? 'blue.200'
: 'blue.700'
: currentSlot.purpose === 'reinforce'
? isDark
? 'orange.200'
: 'orange.700'
: currentSlot.purpose === 'review'
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'purple.200'
: 'purple.700',
})}
>
{currentSlot.purpose}
</div>
</Tooltip>
</TooltipProvider>
</div>
)}
{/* Problem display - centered, with help panel positioned outside */}
@@ -2014,6 +2093,17 @@ export function ActiveSession({
onComplete={handleTransitionComplete}
/>
)}
{/* Retry Transition Screen - shown when entering a retry epoch */}
{retryTransitionData && (
<RetryTransitionScreen
isVisible={isInRetryTransition}
epochNumber={retryTransitionData.epochNumber}
problemCount={retryTransitionData.problemCount}
student={{ name: student.name, emoji: student.emoji }}
onComplete={handleRetryTransitionComplete}
/>
)}
</div>
)
}

View File

@@ -10,7 +10,7 @@ import { useTheme } from '@/contexts/ThemeContext'
import { useStudentStakeholders } from '@/hooks/useStudentStakeholders'
import { useActiveSessionPlan } from '@/hooks/useSessionPlan'
import { useStudentActions, type StudentActionData } from '@/hooks/useStudentActions'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import type { SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { EnrollChildModal } from '@/components/classroom'
import { FamilyCodeDisplay } from '@/components/family'
@@ -76,6 +76,8 @@ export interface SessionHudData {
onBrowseNavigate?: (linearIndex: number) => void
/** Whether the end session request is in flight */
isEndingSession?: boolean
/** Full session plan for retry status display */
plan?: SessionPlan
}
interface PracticeSubNavProps {
@@ -946,6 +948,7 @@ export function PracticeSubNav({
onNavigate={sessionHud.onBrowseNavigate}
isDark={isDark}
compact={true}
plan={sessionHud.plan}
/>
</div>

View File

@@ -0,0 +1,229 @@
'use client'
/**
* RetryTransitionScreen - Brief transition between epochs when retrying wrong problems
*
* Shows a kid-friendly message encouraging them to try the problems again,
* with info about how many problems need retrying and which attempt this is.
*/
import { useCallback, useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
// ============================================================================
// Constants
// ============================================================================
/** Countdown duration for retry transition */
export const RETRY_TRANSITION_COUNTDOWN_MS = 3000
// ============================================================================
// Types
// ============================================================================
export interface RetryTransitionScreenProps {
/** Whether the transition screen is visible */
isVisible: boolean
/** Which retry epoch we're starting (1 = first retry, 2 = second retry) */
epochNumber: number
/** Number of problems that need retrying */
problemCount: number
/** Student info for display */
student: {
name: string
emoji: string
}
/** Called when transition completes (countdown or skip) */
onComplete: () => void
}
// ============================================================================
// Component
// ============================================================================
export function RetryTransitionScreen({
isVisible,
epochNumber,
problemCount,
student,
onComplete,
}: RetryTransitionScreenProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [countdown, setCountdown] = useState(3)
// Reset countdown when screen becomes visible
useEffect(() => {
if (isVisible) {
setCountdown(3)
}
}, [isVisible])
// Countdown timer
useEffect(() => {
if (!isVisible) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
onComplete()
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [isVisible, onComplete])
const handleSkip = useCallback(() => {
onComplete()
}, [onComplete])
if (!isVisible) return null
// Get encouraging message based on epoch
const getMessage = () => {
if (epochNumber === 1) {
return "Let's practice those again!"
}
return 'One more try!'
}
const getSubMessage = () => {
const plural = problemCount === 1 ? 'problem' : 'problems'
if (epochNumber === 1) {
return `You have ${problemCount} ${plural} to practice again.`
}
return `${problemCount} ${plural} left. You can do it!`
}
return (
<div
data-component="retry-transition-screen"
data-epoch={epochNumber}
className={css({
position: 'fixed',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDark ? 'gray.900' : 'orange.50',
zIndex: 50,
padding: '2rem',
})}
>
{/* Main content */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '400px',
textAlign: 'center',
})}
>
{/* Student avatar */}
<div
className={css({
fontSize: '4rem',
marginBottom: '1.5rem',
})}
>
{student.emoji}
</div>
{/* Main message */}
<h2
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'orange.200' : 'orange.700',
marginBottom: '1rem',
})}
>
{getMessage()}
</h2>
{/* Sub message */}
<p
className={css({
fontSize: '1.25rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '2rem',
})}
>
{getSubMessage()}
</p>
{/* Attempt indicator */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 1rem',
backgroundColor: isDark ? 'orange.900' : 'orange.100',
borderRadius: '999px',
marginBottom: '2rem',
})}
>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'orange.200' : 'orange.700',
})}
>
Attempt {epochNumber + 1} of 3
</span>
</div>
{/* Countdown / Skip */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
})}
>
<div
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Starting in {countdown}...
</div>
<button
type="button"
onClick={handleSkip}
className={css({
padding: '0.75rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: isDark ? 'orange.600' : 'orange.500',
borderRadius: '12px',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'orange.500' : 'orange.600',
transform: 'scale(1.05)',
},
})}
>
Let's Go!
</button>
</div>
</div>
</div>
)
}

View File

@@ -136,6 +136,7 @@ function createMockSessionPlan(config: {
pausedAt: null,
pausedBy: null,
pauseReason: null,
retryState: null,
}
}

View File

@@ -17,7 +17,8 @@
'use client'
import { useMemo } from 'react'
import type { SessionPart, SlotResult } from '@/db/schema/session-plans'
import type { SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { getSlotRetryStatus } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
export interface SessionProgressIndicatorProps {
@@ -37,6 +38,8 @@ export interface SessionProgressIndicatorProps {
isDark: boolean
/** Compact mode for smaller screens */
compact?: boolean
/** Optional session plan for retry status display */
plan?: SessionPlan
}
function getPartEmoji(type: SessionPart['type']): string {
@@ -79,20 +82,24 @@ function getSlotResult(
*/
function CollapsedSection({
part,
partIndex,
results,
linearOffset,
isDark,
isBrowseMode,
onNavigate,
isCompleted,
plan,
}: {
part: SessionPart
partIndex: number
results: SlotResult[]
linearOffset: number
isDark: boolean
isBrowseMode: boolean
onNavigate?: (linearIndex: number) => void
isCompleted: boolean
plan?: SessionPlan
}) {
const completedCount = part.slots.filter((_, i) =>
getSlotResult(results, part.partNumber, i)
@@ -109,12 +116,14 @@ function CollapsedSection({
return (
<ExpandedSection
part={part}
partIndex={partIndex}
results={results}
linearOffset={linearOffset}
currentLinearIndex={-1}
isDark={isDark}
isBrowseMode={true}
onNavigate={onNavigate}
plan={plan}
/>
)
}
@@ -174,20 +183,24 @@ function CollapsedSection({
*/
function ExpandedSection({
part,
partIndex,
results,
linearOffset,
currentLinearIndex,
isDark,
isBrowseMode,
onNavigate,
plan,
}: {
part: SessionPart
partIndex: number
results: SlotResult[]
linearOffset: number
currentLinearIndex: number
isDark: boolean
isBrowseMode: boolean
onNavigate?: (linearIndex: number) => void
plan?: SessionPlan
}) {
return (
<div
@@ -223,79 +236,125 @@ function ExpandedSection({
const isCompleted = result !== undefined
const isCorrect = result?.isCorrect
// Get retry status if plan is available
const retryStatus = plan ? getSlotRetryStatus(plan, partIndex, slotIndex) : null
const attemptCount = retryStatus?.attemptCount ?? (isCompleted ? 1 : 0)
const hasRetried = attemptCount > 1
const isClickable = isBrowseMode && onNavigate
return (
<button
<div
key={slotIndex}
type="button"
data-slot-index={slotIndex}
data-linear-index={linearIndex}
data-status={
isCurrent
? 'current'
: isCompleted
? isCorrect
? 'correct'
: 'incorrect'
: 'pending'
}
onClick={isClickable ? () => onNavigate(linearIndex) : undefined}
disabled={!isClickable}
className={css({
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.625rem',
fontWeight: isCurrent ? 'bold' : 'normal',
borderRadius: '4px',
border: '1px solid',
cursor: isClickable ? 'pointer' : 'default',
transition: 'all 0.15s ease',
// Current problem
...(isCurrent && {
backgroundColor: isDark ? 'yellow.600' : 'yellow.400',
borderColor: isDark ? 'yellow.500' : 'yellow.500',
color: isDark ? 'yellow.100' : 'yellow.900',
boxShadow: `0 0 0 2px ${isDark ? 'rgba(234, 179, 8, 0.3)' : 'rgba(234, 179, 8, 0.4)'}`,
}),
// Completed correct
...(!isCurrent &&
isCompleted &&
isCorrect && {
backgroundColor: isDark ? 'green.900' : 'green.100',
borderColor: isDark ? 'green.700' : 'green.300',
color: isDark ? 'green.300' : 'green.700',
}),
// Completed incorrect
...(!isCurrent &&
isCompleted &&
!isCorrect && {
backgroundColor: isDark ? 'red.900' : 'red.100',
borderColor: isDark ? 'red.700' : 'red.300',
color: isDark ? 'red.300' : 'red.700',
}),
// Pending
...(!isCurrent &&
!isCompleted && {
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.400' : 'gray.500',
}),
// Hover effect in browse mode
...(isClickable && {
_hover: {
transform: 'scale(1.15)',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
},
}),
position: 'relative',
display: 'inline-block',
})}
title={isBrowseMode ? `Go to problem ${linearIndex + 1}` : undefined}
>
{isBrowseMode ? linearIndex + 1 : isCompleted ? (isCorrect ? '✓' : '✗') : '○'}
</button>
<button
type="button"
data-slot-index={slotIndex}
data-linear-index={linearIndex}
data-attempt-count={attemptCount}
data-status={
isCurrent
? 'current'
: isCompleted
? isCorrect
? 'correct'
: 'incorrect'
: 'pending'
}
onClick={isClickable ? () => onNavigate(linearIndex) : undefined}
disabled={!isClickable}
className={css({
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.625rem',
fontWeight: isCurrent ? 'bold' : 'normal',
borderRadius: '4px',
border: '1px solid',
cursor: isClickable ? 'pointer' : 'default',
transition: 'all 0.15s ease',
// Current problem
...(isCurrent && {
backgroundColor: isDark ? 'yellow.600' : 'yellow.400',
borderColor: isDark ? 'yellow.500' : 'yellow.500',
color: isDark ? 'yellow.100' : 'yellow.900',
boxShadow: `0 0 0 2px ${isDark ? 'rgba(234, 179, 8, 0.3)' : 'rgba(234, 179, 8, 0.4)'}`,
}),
// Completed correct
...(!isCurrent &&
isCompleted &&
isCorrect && {
backgroundColor: isDark ? 'green.900' : 'green.100',
borderColor: isDark ? 'green.700' : 'green.300',
color: isDark ? 'green.300' : 'green.700',
}),
// Completed incorrect
...(!isCurrent &&
isCompleted &&
!isCorrect && {
backgroundColor: isDark ? 'red.900' : 'red.100',
borderColor: isDark ? 'red.700' : 'red.300',
color: isDark ? 'red.300' : 'red.700',
}),
// Pending
...(!isCurrent &&
!isCompleted && {
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.400' : 'gray.500',
}),
// Hover effect in browse mode
...(isClickable && {
_hover: {
transform: 'scale(1.15)',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
},
}),
})}
title={
isBrowseMode
? `Go to problem ${linearIndex + 1}`
: hasRetried
? `Attempt ${attemptCount} of 3`
: undefined
}
>
{isBrowseMode ? linearIndex + 1 : isCompleted ? (isCorrect ? '✓' : '✗') : '○'}
</button>
{/* Retry attempt badge */}
{hasRetried && (
<span
data-element="retry-badge"
className={css({
position: 'absolute',
top: '-4px',
right: '-4px',
width: '12px',
height: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.5rem',
fontWeight: 'bold',
borderRadius: '50%',
backgroundColor: isDark ? 'orange.600' : 'orange.500',
color: 'white',
border: '1px solid',
borderColor: isDark ? 'orange.400' : 'orange.600',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
})}
title={`Attempt ${attemptCount} of 3`}
>
{attemptCount}
</span>
)}
</div>
)
})}
</div>
@@ -311,6 +370,7 @@ export function SessionProgressIndicator({
onNavigate,
isDark,
compact = false,
plan,
}: SessionProgressIndicatorProps) {
// Calculate linear index for current position
const currentLinearIndex = useMemo(() => {
@@ -395,22 +455,26 @@ export function SessionProgressIndicator({
{shouldCollapse ? (
<CollapsedSection
part={part}
partIndex={partIndex}
results={results}
linearOffset={partLinearOffset}
isDark={isDark}
isBrowseMode={isBrowseMode}
onNavigate={onNavigate}
isCompleted={isCompletedPart}
plan={plan}
/>
) : (
<ExpandedSection
part={part}
partIndex={partIndex}
results={results}
linearOffset={partLinearOffset}
currentLinearIndex={currentLinearIndex}
isDark={isDark}
isBrowseMode={isBrowseMode}
onNavigate={onNavigate}
plan={plan}
/>
)}
</div>

View File

@@ -182,6 +182,11 @@ export const WithExistingPlan: Story = {
approvedAt: new Date(),
startedAt: null,
completedAt: null,
isPaused: false,
pausedAt: null,
pausedBy: null,
pauseReason: null,
retryState: null,
}}
/>
</StoryWrapper>

View File

@@ -260,6 +260,30 @@ export interface SlotResult {
* lastPracticedAt but skips them for pKnown calculation (zero-weight).
*/
source?: SlotResultSource
// ---- Retry Tracking ----
/** Whether this was a retry attempt (not the original) */
isRetry?: boolean
/**
* Which retry epoch this result belongs to.
* 0 = original attempt, 1 = first retry, 2 = second retry
*/
epochNumber?: number
/**
* Weight applied to mastery/BKT calculation.
* Formula: 1.0 / (2 ^ epochNumber) if correct, 0 if wrong
* - Epoch 0 correct: 1.0 (100%)
* - Epoch 1 correct: 0.5 (50%)
* - Epoch 2 correct: 0.25 (25%)
* - Any wrong: 0
*/
masteryWeight?: number
/** Original slot index (for retries, tracks which slot is being retried) */
originalSlotIndex?: number
}
export type SessionStatus =
@@ -270,6 +294,69 @@ export type SessionStatus =
| 'abandoned'
| 'recency-refresh'
// ============================================================================
// Retry System Types
// ============================================================================
/**
* Maximum number of retry epochs (original + 2 retries = 3 total attempts)
*/
export const MAX_RETRY_EPOCHS = 2
/**
* A single problem queued for retry
*/
export interface RetryItem {
/** Original slot index within the part */
originalSlotIndex: number
/** The exact same problem to retry (never regenerated) */
problem: GeneratedProblem
/** Which epoch this retry is for (1 = first retry, 2 = second retry) */
epochNumber: number
/** Purpose from the original slot (for display) */
originalPurpose: 'focus' | 'reinforce' | 'review' | 'challenge'
}
/**
* Retry state for a single session part
*/
export interface PartRetryState {
/**
* Current epoch number within this part.
* 0 = still working original slots
* 1 = first retry epoch
* 2 = second retry epoch (final)
*/
currentEpoch: number
/**
* Problems queued for the next epoch (accumulated during current epoch).
* When a problem is wrong, it gets added here for the next retry round.
*/
pendingRetries: RetryItem[]
/**
* Problems being worked through in the current retry epoch.
* Set when starting a new epoch by moving pendingRetries here.
*/
currentEpochItems: RetryItem[]
/**
* Index into currentEpochItems (which retry we're on within this epoch).
*/
currentRetryIndex: number
}
/**
* Retry state across all parts of a session
*/
export type SessionRetryState = {
[partIndex: number]: PartRetryState
}
// ============================================================================
// Database Table
// ============================================================================
@@ -340,6 +427,11 @@ export const sessionPlans = sqliteTable(
/** Results for each completed slot */
results: text('results', { mode: 'json' }).notNull().default('[]').$type<SlotResult[]>(),
// ---- Retry State ----
/** Retry state per part - tracks problems that need retrying */
retryState: text('retry_state', { mode: 'json' }).$type<SessionRetryState>(),
// ---- Pause State (for teacher observation control) ----
/** Whether the session is currently paused by a teacher */
@@ -528,3 +620,194 @@ export const DEFAULT_PLAN_CONFIG = {
}
export type PlanGenerationConfig = typeof DEFAULT_PLAN_CONFIG
// ============================================================================
// Retry System Helper Functions
// ============================================================================
/**
* Calculate mastery weight for a result based on epoch and correctness.
*
* Formula: 1.0 / (2 ^ epochNumber) if correct, 0 if wrong
* - Epoch 0 correct: 1.0 (100%)
* - Epoch 1 correct: 0.5 (50%)
* - Epoch 2 correct: 0.25 (25%)
* - Any wrong: 0
*/
export function calculateMasteryWeight(isCorrect: boolean, epochNumber: number): number {
if (!isCorrect) return 0
return 1.0 / 2 ** epochNumber
}
/**
* Check if we're currently in a retry epoch for the given part
*/
export function isInRetryEpoch(plan: SessionPlan, partIndex: number): boolean {
const retryState = plan.retryState?.[partIndex]
if (!retryState) return false
return retryState.currentEpochItems.length > 0 && retryState.currentEpoch > 0
}
/**
* Get the current problem to display (either from original slots or retry queue)
*/
export function getCurrentProblemInfo(plan: SessionPlan): {
problem: GeneratedProblem
isRetry: boolean
epochNumber: number
originalSlotIndex: number
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
partNumber: 1 | 2 | 3
} | null {
const partIndex = plan.currentPartIndex
if (partIndex >= plan.parts.length) return null
const part = plan.parts[partIndex]
const retryState = plan.retryState?.[partIndex]
// Check if we're in a retry epoch
if (retryState && retryState.currentEpochItems.length > 0 && retryState.currentEpoch > 0) {
if (retryState.currentRetryIndex >= retryState.currentEpochItems.length) {
// Edge case: all retries in this epoch done, should have transitioned
return null
}
const item = retryState.currentEpochItems[retryState.currentRetryIndex]
return {
problem: item.problem,
isRetry: true,
epochNumber: item.epochNumber,
originalSlotIndex: item.originalSlotIndex,
purpose: item.originalPurpose,
partNumber: part.partNumber,
}
}
// Working original slots
if (plan.currentSlotIndex >= part.slots.length) {
// Finished original slots, check if there are pending retries
return null
}
const slot = part.slots[plan.currentSlotIndex]
if (!slot.problem) return null
return {
problem: slot.problem,
isRetry: false,
epochNumber: 0,
originalSlotIndex: plan.currentSlotIndex,
purpose: slot.purpose,
partNumber: part.partNumber,
}
}
/**
* Initialize retry state for a part if not already present
*/
export function initRetryState(plan: SessionPlan, partIndex: number): PartRetryState {
if (!plan.retryState) {
plan.retryState = {}
}
if (!plan.retryState[partIndex]) {
plan.retryState[partIndex] = {
currentEpoch: 0,
pendingRetries: [],
currentEpochItems: [],
currentRetryIndex: 0,
}
}
return plan.retryState[partIndex]
}
/**
* Get retry status for a specific slot (for UI display)
*/
export function getSlotRetryStatus(
plan: SessionPlan,
partIndex: number,
slotIndex: number
): {
hasBeenAttempted: boolean
isCorrect: boolean | null
attemptCount: number
finalMasteryWeight: number | null
} {
// Find all results for this slot
const partNumber = plan.parts[partIndex]?.partNumber
if (!partNumber) {
return { hasBeenAttempted: false, isCorrect: null, attemptCount: 0, finalMasteryWeight: null }
}
const slotResults = plan.results.filter(
(r) => r.partNumber === partNumber && (r.originalSlotIndex ?? r.slotIndex) === slotIndex
)
if (slotResults.length === 0) {
return { hasBeenAttempted: false, isCorrect: null, attemptCount: 0, finalMasteryWeight: null }
}
// Get the latest result for this slot
const latestResult = slotResults[slotResults.length - 1]
return {
hasBeenAttempted: true,
isCorrect: latestResult.isCorrect,
attemptCount: slotResults.length,
finalMasteryWeight: latestResult.masteryWeight ?? null,
}
}
/**
* Calculate total problems including pending retries for progress display
*/
export function calculateTotalProblemsWithRetries(plan: SessionPlan): number {
let total = 0
for (let partIndex = 0; partIndex < plan.parts.length; partIndex++) {
const part = plan.parts[partIndex]
total += part.slots.length
const retryState = plan.retryState?.[partIndex]
if (retryState) {
// Add current epoch items (retries being worked through)
total += retryState.currentEpochItems.length
// Add pending retries (queued for next epoch)
total += retryState.pendingRetries.length
}
}
return total
}
/**
* Check if the current part needs retry transition
* (original slots done but there are pending retries)
*/
export function needsRetryTransition(plan: SessionPlan): boolean {
const partIndex = plan.currentPartIndex
if (partIndex >= plan.parts.length) return false
const part = plan.parts[partIndex]
const retryState = plan.retryState?.[partIndex]
// Check if we finished original slots
if (plan.currentSlotIndex < part.slots.length) return false
// Check if there are pending retries and we haven't started retry epoch yet
if (retryState && retryState.pendingRetries.length > 0 && retryState.currentEpoch === 0) {
return true
}
// Check if we finished current retry epoch but have more pending
if (
retryState &&
retryState.currentEpochItems.length > 0 &&
retryState.currentRetryIndex >= retryState.currentEpochItems.length &&
retryState.pendingRetries.length > 0 &&
retryState.currentEpoch < MAX_RETRY_EPOCHS
) {
return true
}
return false
}

View File

@@ -449,10 +449,7 @@ export async function getEnrolledClassrooms(playerId: string): Promise<Classroom
*
* @returns true if enrolled, false if already enrolled
*/
export async function directEnrollStudent(
classroomId: string,
playerId: string
): Promise<boolean> {
export async function directEnrollStudent(classroomId: string, playerId: string): Promise<boolean> {
// Check if already enrolled
const existing = await db.query.classroomEnrollments.findFirst({
where: and(

View File

@@ -143,10 +143,13 @@ export function computeBktFromHistory(
}
})
// Calculate evidence weight based on help usage and response time
// Calculate evidence weight based on help usage, response time, and retry status
const helpW = helpWeight(result.hadHelp)
const rtWeight = responseTimeWeight(result.responseTimeMs, result.isCorrect)
const evidenceWeight = helpW * rtWeight
// Apply mastery weight from retry system (1.0 for first attempt, 0.5 for first retry, 0.25 for second retry)
// If masteryWeight is undefined (old data), default to 1.0
const retryWeight = result.masteryWeight ?? 1.0
const evidenceWeight = helpW * rtWeight * retryWeight
// Compute BKT updates (conjunctive model)
const blameMethod = opts.blameMethod ?? 'heuristic'

View File

@@ -18,8 +18,12 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'
import { db, schema } from '@/db'
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import {
calculateMasteryWeight,
calculateSessionHealth,
DEFAULT_PLAN_CONFIG,
initRetryState,
isInRetryEpoch,
MAX_RETRY_EPOCHS,
type NewSessionPlan,
type PartSummary,
type PlanGenerationConfig,
@@ -28,6 +32,7 @@ import {
type SessionPart,
type SessionPartType,
type SessionPlan,
type SessionRetryState,
type SessionSummary,
type SlotResult,
} from '@/db/schema/session-plans'
@@ -695,7 +700,10 @@ export async function startSessionPlan(planId: string): Promise<SessionPlan> {
*/
export async function recordSlotResult(
planId: string,
result: Omit<SlotResult, 'timestamp' | 'partNumber'>
result: Omit<
SlotResult,
'timestamp' | 'partNumber' | 'epochNumber' | 'masteryWeight' | 'isRetry' | 'originalSlotIndex'
>
): Promise<SessionPlan> {
let plan: SessionPlan | null
try {
@@ -741,26 +749,125 @@ export async function recordSlotResult(
throw new Error(`Plan ${planId} has invalid results: ${typeof plan.results} (expected array)`)
}
// Initialize mutable copy of retry state
const updatedRetryState: SessionRetryState = plan.retryState ? { ...plan.retryState } : {}
const partIndex = plan.currentPartIndex
// Determine if we're in a retry epoch or working original slots
const inRetryEpoch = isInRetryEpoch(plan, partIndex)
let epochNumber = 0
let originalSlotIndex = result.slotIndex
if (inRetryEpoch) {
// In retry epoch
const retryState = updatedRetryState[partIndex]!
const currentRetryItem = retryState.currentEpochItems[retryState.currentRetryIndex]
epochNumber = currentRetryItem.epochNumber
originalSlotIndex = currentRetryItem.originalSlotIndex
}
// Calculate mastery weight based on epoch and correctness
const masteryWeight = calculateMasteryWeight(result.isCorrect, epochNumber)
const newResult: SlotResult = {
...result,
partNumber: currentPart.partNumber,
timestamp: new Date(),
epochNumber,
masteryWeight,
isRetry: epochNumber > 0,
originalSlotIndex,
}
const updatedResults = [...plan.results, newResult]
// Advance to next slot, possibly moving to next part
let nextPartIndex = plan.currentPartIndex
let nextSlotIndex = plan.currentSlotIndex + 1
// Check if we've finished the current part
if (nextSlotIndex >= currentPart.slots.length) {
nextPartIndex += 1
nextSlotIndex = 0
// Handle wrong answers: queue for retry
if (!result.isCorrect) {
if (inRetryEpoch) {
// In retry epoch - queue for next epoch if under max
const retryState = updatedRetryState[partIndex]!
if (retryState.currentEpoch < MAX_RETRY_EPOCHS) {
const currentRetryItem = retryState.currentEpochItems[retryState.currentRetryIndex]
retryState.pendingRetries.push({
originalSlotIndex: currentRetryItem.originalSlotIndex,
problem: currentRetryItem.problem,
epochNumber: retryState.currentEpoch + 1,
originalPurpose: currentRetryItem.originalPurpose,
})
}
// If at max epoch, don't re-queue (counted as definitively wrong)
} else {
// Original attempt wrong - queue for first retry epoch
const retryState = initRetryState({ ...plan, retryState: updatedRetryState }, partIndex)
updatedRetryState[partIndex] = retryState
const slot = currentPart.slots[plan.currentSlotIndex]
retryState.pendingRetries.push({
originalSlotIndex: plan.currentSlotIndex,
problem: slot.problem!,
epochNumber: 1,
originalPurpose: slot.purpose,
})
}
}
// Check if the entire session is complete
const isComplete = nextPartIndex >= plan.parts.length
// Advance to next problem
let nextPartIndex = plan.currentPartIndex
let nextSlotIndex = plan.currentSlotIndex
let isComplete = false
if (inRetryEpoch) {
// Advance within retry epoch
const retryState = updatedRetryState[partIndex]!
retryState.currentRetryIndex += 1
if (retryState.currentRetryIndex >= retryState.currentEpochItems.length) {
// Finished current retry epoch
if (retryState.pendingRetries.length > 0 && retryState.currentEpoch < MAX_RETRY_EPOCHS) {
// Start next retry epoch
retryState.currentEpoch += 1
retryState.currentEpochItems = [...retryState.pendingRetries]
retryState.pendingRetries = []
retryState.currentRetryIndex = 0
} else {
// No more retries (either all correct or max epochs reached)
// Clear retry state and advance to next part
retryState.currentEpochItems = []
retryState.currentRetryIndex = 0
retryState.pendingRetries = []
nextPartIndex += 1
nextSlotIndex = 0
if (nextPartIndex >= plan.parts.length) {
isComplete = true
}
}
}
} else {
// Advance within original slots
nextSlotIndex += 1
if (nextSlotIndex >= currentPart.slots.length) {
// Finished original slots for this part
const retryState = updatedRetryState[partIndex]
if (retryState && retryState.pendingRetries.length > 0) {
// Start retry epoch 1
retryState.currentEpoch = 1
retryState.currentEpochItems = [...retryState.pendingRetries]
retryState.pendingRetries = []
retryState.currentRetryIndex = 0
// Stay on same part, slot index stays at end (indicates original slots done)
} else {
// No retries needed, advance to next part
nextPartIndex += 1
nextSlotIndex = 0
if (nextPartIndex >= plan.parts.length) {
isComplete = true
}
}
}
}
// Calculate elapsed time since start
const elapsedMs = plan.startedAt ? Date.now() - plan.startedAt.getTime() : 0
@@ -775,6 +882,7 @@ export async function recordSlotResult(
currentPartIndex: nextPartIndex,
currentSlotIndex: nextSlotIndex,
sessionHealth: updatedHealth,
retryState: updatedRetryState,
status: isComplete ? 'completed' : 'in_progress',
completedAt: isComplete ? new Date() : null,
})
@@ -794,8 +902,8 @@ export async function recordSlotResult(
)
}
// Update global skill mastery with response time data
// This builds the per-kid stats for identifying strengths/weaknesses
// Update global skill mastery timestamps with help tracking
// Note: masteryWeight is applied by BKT when it reads SlotResults (not here)
if (result.skillsExercised && result.skillsExercised.length > 0) {
const skillResults = result.skillsExercised.map((skillId) => ({
skillId,