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:
2
apps/web/drizzle/0049_flowery_jean_grey.sql
Normal file
2
apps/web/drizzle/0049_flowery_jean_grey.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add retry_state column to session_plans for tracking retry epochs
|
||||
ALTER TABLE `session_plans` ADD `retry_state` text;
|
||||
1038
apps/web/drizzle/meta/0049_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0049_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -210,6 +210,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
isBrowseMode,
|
||||
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
|
||||
onBrowseNavigate: setBrowseIndex,
|
||||
plan: currentPlan,
|
||||
}
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ function createMockSessionPlanWithProblems(config: {
|
||||
pausedAt: null,
|
||||
pausedBy: null,
|
||||
pauseReason: null,
|
||||
retryState: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
229
apps/web/src/components/practice/RetryTransitionScreen.tsx
Normal file
229
apps/web/src/components/practice/RetryTransitionScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -136,6 +136,7 @@ function createMockSessionPlan(config: {
|
||||
pausedAt: null,
|
||||
pausedBy: null,
|
||||
pauseReason: null,
|
||||
retryState: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user