feat(practice): refactor disambiguation into state machine with comprehensive tests
- Complete migration of disambiguation state into the state machine - Remove backward compatibility code (no legacy concerns in new app) - Eliminate dual-state patterns in ActiveSession.tsx - Export derived state from hook (attempt, helpContext, outgoingAttempt) - Export boolean predicates (isTransitioning, isPaused, isSubmitting) - Add comprehensive tests for awaitingDisambiguation phase - Fix tests to match actual unambiguous prefix sum behavior - Add SSR support with proper hydration for practice pages The state machine is now the single source of truth for all UI state. Unambiguous prefix matches immediately trigger helpMode, while ambiguous matches enter awaitingDisambiguation with a 4-second timer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -132,7 +132,8 @@
|
||||
"Bash(../../README.md )",
|
||||
"Bash(.claude/CLAUDE.md)",
|
||||
"Bash(mcp__sqlite__describe_table:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(mcp__sqlite__list_tables:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { SessionPlan } from '@/db/schema/session-plans'
|
||||
import {
|
||||
ActiveSessionExistsError,
|
||||
type GenerateSessionPlanOptions,
|
||||
generateSessionPlan,
|
||||
getActiveSessionPlan,
|
||||
@@ -74,6 +75,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const plan = await generateSessionPlan(options)
|
||||
return NextResponse.json({ plan: serializePlan(plan) }, { status: 201 })
|
||||
} catch (error) {
|
||||
// Handle active session conflict
|
||||
if (error instanceof ActiveSessionExistsError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Active session exists',
|
||||
code: 'ACTIVE_SESSION_EXISTS',
|
||||
existingPlan: serializePlan(error.existingSession),
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Error generating session plan:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate session plan' }, { status: 500 })
|
||||
}
|
||||
|
||||
98
apps/web/src/app/practice/PracticeClient.tsx
Normal file
98
apps/web/src/app/practice/PracticeClient.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface PracticeClientProps {
|
||||
initialPlayers: Player[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Practice page client component
|
||||
*
|
||||
* Receives prefetched player data as props from the server component.
|
||||
* This avoids SSR hydration issues with React Query.
|
||||
*/
|
||||
export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Use initial data from server
|
||||
const players = initialPlayers
|
||||
|
||||
// Convert players to StudentWithProgress format
|
||||
const students: StudentWithProgress[] = players.map((player) => ({
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
createdAt: player.createdAt,
|
||||
}))
|
||||
|
||||
// Handle student selection - navigate to student's practice page
|
||||
const handleSelectStudent = useCallback(
|
||||
(student: StudentWithProgress) => {
|
||||
router.push(`/practice/${student.id}`)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Daily Practice
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Build your soroban skills one step at a time
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Student Selector */}
|
||||
<StudentSelector students={students} onSelectStudent={handleSelectStudent} />
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
106
apps/web/src/app/practice/PracticeSkeleton.tsx
Normal file
106
apps/web/src/app/practice/PracticeSkeleton.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
/**
|
||||
* Skeleton component shown while practice page data is loading
|
||||
*
|
||||
* This is used as a fallback for the Suspense boundary, but in practice
|
||||
* it should rarely be seen since data is prefetched on the server.
|
||||
*/
|
||||
export function PracticeSkeleton() {
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page-skeleton"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header skeleton */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '200px',
|
||||
height: '2rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '8px',
|
||||
margin: '0 auto 0.5rem auto',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '280px',
|
||||
height: '1rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
margin: '0 auto',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* Student cards skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '50%',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '100px',
|
||||
height: '1.25rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
165
apps/web/src/app/practice/[studentId]/PracticePageSkeleton.tsx
Normal file
165
apps/web/src/app/practice/[studentId]/PracticePageSkeleton.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
/**
|
||||
* Skeleton component shown while practice page data is loading
|
||||
*
|
||||
* This is used as a fallback for the Suspense boundary, but in practice
|
||||
* it should rarely be seen since data is prefetched on the server.
|
||||
* It may appear briefly during client-side navigation.
|
||||
*/
|
||||
export function PracticePageSkeleton() {
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page-skeleton"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header skeleton */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '200px',
|
||||
height: '2rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '8px',
|
||||
margin: '0 auto 0.5rem auto',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '280px',
|
||||
height: '1rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
margin: '0 auto',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* Dashboard card skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
{/* Student info skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '50%',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
width: '150px',
|
||||
height: '1.5rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '0.5rem',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '100px',
|
||||
height: '1rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase info skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'gray.50',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '120px',
|
||||
height: '1rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '0.75rem',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '200px',
|
||||
height: '1.25rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '0.5rem',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '0.875rem',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Button skeleton */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '56px',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
558
apps/web/src/app/practice/[studentId]/StudentPracticeClient.tsx
Normal file
558
apps/web/src/app/practice/[studentId]/StudentPracticeClient.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import {
|
||||
ActiveSession,
|
||||
ContinueSessionCard,
|
||||
type CurrentPhaseInfo,
|
||||
PlanReview,
|
||||
PracticeErrorBoundary,
|
||||
ProgressDashboard,
|
||||
SessionSummary,
|
||||
type SkillProgress,
|
||||
type StudentWithProgress,
|
||||
} from '@/components/practice'
|
||||
import { ManualSkillSelector } from '@/components/practice/ManualSkillSelector'
|
||||
import {
|
||||
type OfflineSessionData,
|
||||
OfflineSessionForm,
|
||||
} from '@/components/practice/OfflineSessionForm'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { PlayerCurriculum } from '@/db/schema/player-curriculum'
|
||||
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import type { PracticeSession } from '@/db/schema/practice-sessions'
|
||||
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
|
||||
import {
|
||||
useAbandonSession,
|
||||
useApproveSessionPlan,
|
||||
useEndSessionEarly,
|
||||
useGenerateSessionPlan,
|
||||
useRecordSlotResult,
|
||||
useStartSessionPlan,
|
||||
} from '@/hooks/useSessionPlan'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
// Mock curriculum phase data (until we integrate with actual curriculum)
|
||||
function getPhaseInfo(phaseId: string): CurrentPhaseInfo {
|
||||
// Parse phase ID format: L{level}.{operation}.{number}.{technique}
|
||||
const parts = phaseId.split('.')
|
||||
const level = parts[0]?.replace('L', '') || '1'
|
||||
const operation = parts[1] || 'add'
|
||||
const number = parts[2] || '+1'
|
||||
const technique = parts[3] || 'direct'
|
||||
|
||||
const operationName = operation === 'add' ? 'Addition' : 'Subtraction'
|
||||
const techniqueName =
|
||||
technique === 'direct'
|
||||
? 'Direct Method'
|
||||
: technique === 'five'
|
||||
? 'Five Complement'
|
||||
: technique === 'ten'
|
||||
? 'Ten Complement'
|
||||
: technique
|
||||
|
||||
return {
|
||||
phaseId,
|
||||
levelName: `Level ${level}`,
|
||||
phaseName: `${operationName}: ${number} (${techniqueName})`,
|
||||
description: `Practice ${operation === 'add' ? 'adding' : 'subtracting'} ${number.replace('+', '').replace('-', '')} using the ${techniqueName.toLowerCase()}.`,
|
||||
skillsToMaster: [`${operation}.${number}.${technique}`],
|
||||
masteredSkills: 0,
|
||||
totalSkills: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// View is derived from session plan state, not managed separately
|
||||
type SessionView = 'dashboard' | 'continue' | 'reviewing' | 'practicing' | 'summary'
|
||||
|
||||
interface CurriculumData {
|
||||
curriculum: PlayerCurriculum | null
|
||||
skills: PlayerSkillMastery[]
|
||||
recentSessions: PracticeSession[]
|
||||
}
|
||||
|
||||
interface StudentPracticeClientProps {
|
||||
studentId: string
|
||||
initialPlayer: Player
|
||||
initialActiveSession: SessionPlan | null
|
||||
initialCurriculum: CurriculumData
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component for student practice page
|
||||
*
|
||||
* Receives prefetched data as props from server component.
|
||||
* This avoids SSR hydration issues with React Query.
|
||||
*/
|
||||
export function StudentPracticeClient({
|
||||
studentId,
|
||||
initialPlayer,
|
||||
initialActiveSession,
|
||||
initialCurriculum,
|
||||
}: StudentPracticeClientProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Use initial data from server props
|
||||
const player = initialPlayer
|
||||
const curriculumData = initialCurriculum
|
||||
|
||||
// Modal states for onboarding features
|
||||
const [showManualSkillModal, setShowManualSkillModal] = useState(false)
|
||||
const [showOfflineSessionModal, setShowOfflineSessionModal] = useState(false)
|
||||
|
||||
// Build the student object
|
||||
const selectedStudent: StudentWithProgress = {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
createdAt: player.createdAt,
|
||||
}
|
||||
|
||||
// Session plan mutations
|
||||
const generatePlan = useGenerateSessionPlan()
|
||||
const approvePlan = useApproveSessionPlan()
|
||||
const startPlan = useStartSessionPlan()
|
||||
const recordResult = useRecordSlotResult()
|
||||
const endEarly = useEndSessionEarly()
|
||||
const abandonSession = useAbandonSession()
|
||||
|
||||
// Current plan from mutations or initial active session (use the latest successful result)
|
||||
const currentPlan =
|
||||
recordResult.data ??
|
||||
startPlan.data ??
|
||||
approvePlan.data ??
|
||||
generatePlan.data ??
|
||||
initialActiveSession ??
|
||||
null
|
||||
|
||||
// Derive error state from mutations
|
||||
const error =
|
||||
startPlan.error || approvePlan.error
|
||||
? {
|
||||
context: 'start' as const,
|
||||
message: 'Unable to start practice session',
|
||||
suggestion:
|
||||
'The plan was created but could not be started. Try clicking "Let\'s Go!" again, or go back and create a new plan.',
|
||||
}
|
||||
: null
|
||||
|
||||
// Derive view from session plan state - NO useState!
|
||||
// This eliminates the "bastard state" problem where viewState and currentPlan could diverge
|
||||
const sessionView: SessionView = useMemo(() => {
|
||||
if (!currentPlan) return 'dashboard'
|
||||
if (currentPlan.completedAt) return 'summary'
|
||||
if (currentPlan.startedAt) return 'practicing'
|
||||
if (currentPlan.approvedAt) return 'reviewing'
|
||||
return 'continue' // Plan exists but not yet approved
|
||||
}, [currentPlan])
|
||||
|
||||
// Handle continue practice - navigate to configuration page
|
||||
const handleContinuePractice = useCallback(() => {
|
||||
router.push(`/practice/${studentId}/configure`)
|
||||
}, [studentId, router])
|
||||
|
||||
// Handle resuming an existing session
|
||||
const handleResumeSession = useCallback(() => {
|
||||
if (!currentPlan) return
|
||||
|
||||
// Session already started → view will show 'practicing' automatically
|
||||
if (currentPlan.startedAt) {
|
||||
// No action needed - sessionView is already 'practicing'
|
||||
return
|
||||
}
|
||||
|
||||
// Approved but not started → start it
|
||||
if (currentPlan.approvedAt) {
|
||||
startPlan.mutate({ playerId: studentId, planId: currentPlan.id })
|
||||
return
|
||||
}
|
||||
|
||||
// Draft (not approved) → need to approve it first
|
||||
// This will update sessionView to 'reviewing'
|
||||
approvePlan.mutate({ playerId: studentId, planId: currentPlan.id })
|
||||
}, [currentPlan, studentId, startPlan, approvePlan])
|
||||
|
||||
// Handle starting fresh (abandon current session)
|
||||
const handleStartFresh = useCallback(() => {
|
||||
if (!currentPlan) return
|
||||
|
||||
abandonSession.mutate(
|
||||
{ playerId: studentId, planId: currentPlan.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Navigate to configure page for a fresh start
|
||||
router.push(`/practice/${studentId}/configure`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [studentId, currentPlan, abandonSession, router])
|
||||
|
||||
// Handle approving the plan (approve + start in sequence)
|
||||
// View will update automatically via derived state when mutations complete
|
||||
const handleApprovePlan = useCallback(() => {
|
||||
if (!currentPlan) return
|
||||
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
|
||||
// First approve, then start - view updates automatically from derived state
|
||||
approvePlan.mutate(
|
||||
{ playerId: studentId, planId: currentPlan.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
startPlan.mutate({ playerId: studentId, planId: currentPlan.id })
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [studentId, currentPlan, approvePlan, startPlan])
|
||||
|
||||
// Handle canceling the plan review - navigate to configure page
|
||||
const handleCancelPlan = useCallback(() => {
|
||||
// Abandon the current plan and go to configure
|
||||
if (currentPlan) {
|
||||
abandonSession.mutate(
|
||||
{ playerId: studentId, planId: currentPlan.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push(`/practice/${studentId}/configure`)
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
router.push(`/practice/${studentId}/configure`)
|
||||
}
|
||||
}, [studentId, currentPlan, abandonSession, router])
|
||||
|
||||
// Handle recording an answer
|
||||
const handleAnswer = useCallback(
|
||||
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
|
||||
if (!currentPlan) return
|
||||
|
||||
await recordResult.mutateAsync({
|
||||
playerId: studentId,
|
||||
planId: currentPlan.id,
|
||||
result,
|
||||
})
|
||||
},
|
||||
[studentId, currentPlan, recordResult]
|
||||
)
|
||||
|
||||
// Handle ending session early
|
||||
// View will update automatically to 'summary' when completedAt is set
|
||||
const handleEndEarly = useCallback(
|
||||
(reason?: string) => {
|
||||
if (!currentPlan) return
|
||||
|
||||
endEarly.mutate({
|
||||
playerId: studentId,
|
||||
planId: currentPlan.id,
|
||||
reason,
|
||||
})
|
||||
// View updates automatically via derived state when completedAt is set
|
||||
},
|
||||
[studentId, currentPlan, endEarly]
|
||||
)
|
||||
|
||||
// Handle session completion - view updates automatically via derived state
|
||||
const handleSessionComplete = useCallback(() => {
|
||||
// The session is marked complete by the ActiveSession component
|
||||
// View will automatically show 'summary' when completedAt is set
|
||||
}, [])
|
||||
|
||||
// Handle practice again - navigate to configure page
|
||||
const handlePracticeAgain = useCallback(() => {
|
||||
// Reset all mutations to clear the plan from cache
|
||||
generatePlan.reset()
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
recordResult.reset()
|
||||
endEarly.reset()
|
||||
abandonSession.reset()
|
||||
// Navigate to configure page for new session
|
||||
router.push(`/practice/${studentId}/configure`)
|
||||
}, [
|
||||
generatePlan,
|
||||
approvePlan,
|
||||
startPlan,
|
||||
recordResult,
|
||||
endEarly,
|
||||
abandonSession,
|
||||
router,
|
||||
studentId,
|
||||
])
|
||||
|
||||
// Handle back to dashboard - just reset mutations and let derived state show dashboard
|
||||
const handleBackToDashboard = useCallback(() => {
|
||||
// Reset all mutations to clear the plan from cache
|
||||
// Completed sessions don't need abandonment - they stay in DB for teacher review
|
||||
generatePlan.reset()
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
recordResult.reset()
|
||||
endEarly.reset()
|
||||
abandonSession.reset()
|
||||
// With mutations cleared, currentPlan becomes null (only initialActiveSession which was completed)
|
||||
// sessionView will automatically become 'dashboard'
|
||||
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly, abandonSession])
|
||||
|
||||
// Handle view full progress (not yet implemented)
|
||||
const handleViewFullProgress = useCallback(() => {
|
||||
// TODO: Navigate to detailed progress view when implemented
|
||||
}, [])
|
||||
|
||||
// Handle generate worksheet
|
||||
const handleGenerateWorksheet = useCallback(() => {
|
||||
// Navigate to worksheet generator with student's current level
|
||||
window.location.href = '/create/worksheets/addition'
|
||||
}, [])
|
||||
|
||||
// Handle opening placement test - navigate to placement test route
|
||||
const handleRunPlacementTest = useCallback(() => {
|
||||
router.push(`/practice/${studentId}/placement-test`)
|
||||
}, [studentId, router])
|
||||
|
||||
// Handle opening manual skill selector
|
||||
const handleSetSkillsManually = useCallback(() => {
|
||||
setShowManualSkillModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle saving manual skill selections
|
||||
const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise<void> => {
|
||||
// TODO: Save skills to curriculum via API
|
||||
console.log('Manual skills saved:', masteredSkillIds)
|
||||
setShowManualSkillModal(false)
|
||||
}, [])
|
||||
|
||||
// Handle opening offline session form
|
||||
const handleRecordOfflinePractice = useCallback(() => {
|
||||
setShowOfflineSessionModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle submitting offline session
|
||||
const handleSubmitOfflineSession = useCallback(
|
||||
async (data: OfflineSessionData): Promise<void> => {
|
||||
// TODO: Save offline session to database via API
|
||||
console.log('Offline session recorded:', data)
|
||||
setShowOfflineSessionModal(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Build current phase info from curriculum
|
||||
const currentPhase = curriculumData.curriculum
|
||||
? getPhaseInfo(curriculumData.curriculum.currentPhaseId)
|
||||
: getPhaseInfo('L1.add.+1.direct')
|
||||
|
||||
// Update phase info with actual skill mastery
|
||||
if (curriculumData.skills.length > 0) {
|
||||
const phaseSkills = curriculumData.skills.filter((s) =>
|
||||
currentPhase.skillsToMaster.includes(s.skillId)
|
||||
)
|
||||
currentPhase.masteredSkills = phaseSkills.filter((s) => s.masteryLevel === 'mastered').length
|
||||
currentPhase.totalSkills = currentPhase.skillsToMaster.length
|
||||
}
|
||||
|
||||
// Map skills to display format
|
||||
const recentSkills: SkillProgress[] = curriculumData.skills.slice(0, 5).map((s) => ({
|
||||
skillId: s.skillId,
|
||||
skillName: formatSkillName(s.skillId),
|
||||
masteryLevel: s.masteryLevel,
|
||||
attempts: s.attempts,
|
||||
correct: s.correct,
|
||||
consecutiveCorrect: s.consecutiveCorrect,
|
||||
}))
|
||||
|
||||
// Format skill ID to human-readable name
|
||||
function formatSkillName(skillId: string): string {
|
||||
// Example: "add.+3.direct" -> "+3 Direct"
|
||||
const parts = skillId.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const number = parts[1] || skillId
|
||||
const technique = parts[2]
|
||||
const techLabel =
|
||||
technique === 'direct'
|
||||
? ''
|
||||
: technique === 'five'
|
||||
? ' (5s)'
|
||||
: technique === 'ten'
|
||||
? ' (10s)'
|
||||
: ''
|
||||
return `${number}${techLabel}`
|
||||
}
|
||||
return skillId
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: sessionView === 'practicing' ? '80px' : 'calc(80px + 2rem)',
|
||||
paddingLeft: sessionView === 'practicing' ? '0' : '2rem',
|
||||
paddingRight: sessionView === 'practicing' ? '0' : '2rem',
|
||||
paddingBottom: sessionView === 'practicing' ? '0' : '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: sessionView === 'practicing' ? '100%' : '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header - hide during practice */}
|
||||
{sessionView !== 'practicing' && (
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Daily Practice
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Build your soroban skills one step at a time
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Content based on session view (derived from data) */}
|
||||
{sessionView === 'continue' && currentPlan && (
|
||||
<ContinueSessionCard
|
||||
studentName={selectedStudent.name}
|
||||
studentEmoji={selectedStudent.emoji}
|
||||
studentColor={selectedStudent.color}
|
||||
session={currentPlan}
|
||||
onContinue={handleResumeSession}
|
||||
onStartFresh={handleStartFresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sessionView === 'dashboard' && (
|
||||
<ProgressDashboard
|
||||
student={selectedStudent}
|
||||
currentPhase={currentPhase}
|
||||
recentSkills={recentSkills}
|
||||
onContinuePractice={handleContinuePractice}
|
||||
onViewFullProgress={handleViewFullProgress}
|
||||
onGenerateWorksheet={handleGenerateWorksheet}
|
||||
onRunPlacementTest={handleRunPlacementTest}
|
||||
onSetSkillsManually={handleSetSkillsManually}
|
||||
onRecordOfflinePractice={handleRecordOfflinePractice}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sessionView === 'reviewing' && currentPlan && (
|
||||
<div data-section="plan-review-wrapper">
|
||||
{/* Error display for session start */}
|
||||
{error?.context === 'start' && (
|
||||
<div
|
||||
data-element="error-banner"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: 'red.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto 1rem auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
<div className={css({ fontSize: '0.875rem', color: 'red.600' })}>
|
||||
{error.suggestion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PlanReview
|
||||
plan={currentPlan}
|
||||
studentName={selectedStudent.name}
|
||||
onApprove={handleApprovePlan}
|
||||
onCancel={handleCancelPlan}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionView === 'practicing' && currentPlan && (
|
||||
<PracticeErrorBoundary studentName={selectedStudent.name}>
|
||||
<ActiveSession
|
||||
plan={currentPlan}
|
||||
studentName={selectedStudent.name}
|
||||
onAnswer={handleAnswer}
|
||||
onEndEarly={handleEndEarly}
|
||||
onComplete={handleSessionComplete}
|
||||
/>
|
||||
</PracticeErrorBoundary>
|
||||
)}
|
||||
|
||||
{sessionView === 'summary' && currentPlan && (
|
||||
<SessionSummary
|
||||
plan={currentPlan}
|
||||
studentName={selectedStudent.name}
|
||||
onPracticeAgain={handlePracticeAgain}
|
||||
onBackToDashboard={handleBackToDashboard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Skill Selector Modal */}
|
||||
<ManualSkillSelector
|
||||
studentName={selectedStudent.name}
|
||||
playerId={selectedStudent.id}
|
||||
open={showManualSkillModal}
|
||||
onClose={() => setShowManualSkillModal(false)}
|
||||
onSave={handleSaveManualSkills}
|
||||
/>
|
||||
|
||||
{/* Offline Session Form Modal */}
|
||||
<OfflineSessionForm
|
||||
studentName={selectedStudent.name}
|
||||
playerId={selectedStudent.id}
|
||||
open={showOfflineSessionModal}
|
||||
onClose={() => setShowOfflineSessionModal(false)}
|
||||
onSubmit={handleSubmitOfflineSession}
|
||||
/>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
'use client'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import {
|
||||
ActiveSessionExistsClientError,
|
||||
sessionPlanKeys,
|
||||
useGenerateSessionPlan,
|
||||
} from '@/hooks/useSessionPlan'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface SessionConfig {
|
||||
durationMinutes: number
|
||||
}
|
||||
|
||||
interface ConfigureClientProps {
|
||||
studentId: string
|
||||
playerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component for session configuration
|
||||
*
|
||||
* This page is only accessible when there's no active session.
|
||||
* The server component guards against this by redirecting if a session exists.
|
||||
*/
|
||||
export function ConfigureClient({ studentId, playerName }: ConfigureClientProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const [sessionConfig, setSessionConfig] = useState<SessionConfig>({
|
||||
durationMinutes: 10,
|
||||
})
|
||||
|
||||
const generatePlan = useGenerateSessionPlan()
|
||||
|
||||
// Derive error state from mutation (excluding ActiveSessionExistsClientError since we handle it)
|
||||
const error =
|
||||
generatePlan.error && !(generatePlan.error instanceof ActiveSessionExistsClientError)
|
||||
? {
|
||||
message: 'Unable to create practice plan',
|
||||
suggestion:
|
||||
'This may be a temporary issue. Try selecting a different duration or refresh the page.',
|
||||
}
|
||||
: null
|
||||
|
||||
const handleGeneratePlan = useCallback(() => {
|
||||
generatePlan.reset()
|
||||
generatePlan.mutate(
|
||||
{
|
||||
playerId: studentId,
|
||||
durationMinutes: sessionConfig.durationMinutes,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Redirect to main practice page - view will derive from session data
|
||||
router.push(`/practice/${studentId}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
// If an active session already exists, use it and redirect
|
||||
if (err instanceof ActiveSessionExistsClientError) {
|
||||
// Update the cache with the existing plan so the practice page has it
|
||||
queryClient.setQueryData(sessionPlanKeys.active(studentId), err.existingPlan)
|
||||
// Redirect to practice page
|
||||
router.push(`/practice/${studentId}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [studentId, sessionConfig, generatePlan, router, queryClient])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
generatePlan.reset()
|
||||
router.push(`/practice/${studentId}`)
|
||||
}, [studentId, generatePlan, router])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="configure-practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Configure Practice for {playerName}
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Set up your practice session
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Configuration Card */}
|
||||
<div
|
||||
data-section="session-config"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5rem',
|
||||
padding: '2rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Configure Practice Session
|
||||
</h2>
|
||||
|
||||
{/* Duration selector */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Session Duration
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{[5, 10, 15, 20].map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
onClick={() => setSessionConfig((c) => ({ ...c, durationMinutes: mins }))}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '1rem',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
sessionConfig.durationMinutes === mins
|
||||
? 'white'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.700',
|
||||
backgroundColor:
|
||||
sessionConfig.durationMinutes === mins
|
||||
? 'blue.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
sessionConfig.durationMinutes === mins
|
||||
? 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{mins} min
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session structure preview */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Today's Practice Structure
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<span>🧮</span>
|
||||
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>
|
||||
<strong>Part 1:</strong> Use abacus
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<span>🧠</span>
|
||||
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>
|
||||
<strong>Part 2:</strong> Mental math (visualization)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<span>💭</span>
|
||||
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>
|
||||
<strong>Part 3:</strong> Mental math (linear)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div
|
||||
data-element="error-banner"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'red.900' : 'red.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.700' : 'red.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'red.300' : 'red.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'red.400' : 'red.600',
|
||||
})}
|
||||
>
|
||||
{error.suggestion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '1rem',
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePlan}
|
||||
disabled={generatePlan.isPending}
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '1rem',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: generatePlan.isPending ? 'gray.400' : 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: generatePlan.isPending ? 'not-allowed' : 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: generatePlan.isPending ? 'gray.400' : 'green.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{generatePlan.isPending ? 'Generating...' : 'Generate Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
40
apps/web/src/app/practice/[studentId]/configure/page.tsx
Normal file
40
apps/web/src/app/practice/[studentId]/configure/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
|
||||
import { ConfigureClient } from './ConfigureClient'
|
||||
|
||||
// Disable caching - must check session state fresh every time
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface ConfigurePageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Practice Session Page - Server Component
|
||||
*
|
||||
* Guards against accessing this page when there's an active session.
|
||||
* If a session exists, redirects to the main practice page.
|
||||
*
|
||||
* URL: /practice/[studentId]/configure
|
||||
*/
|
||||
export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
// Fetch player and check for active session in parallel
|
||||
const [player, activeSession] = await Promise.all([
|
||||
getPlayer(studentId),
|
||||
getActiveSessionPlan(studentId),
|
||||
])
|
||||
|
||||
// 404 if player doesn't exist
|
||||
if (!player) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Guard: redirect if there's an active session
|
||||
if (activeSession) {
|
||||
redirect(`/practice/${studentId}`)
|
||||
}
|
||||
|
||||
return <ConfigureClient studentId={studentId} playerName={player.name} />
|
||||
}
|
||||
74
apps/web/src/app/practice/[studentId]/not-found.tsx
Normal file
74
apps/web/src/app/practice/[studentId]/not-found.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
/**
|
||||
* Not Found page for invalid student IDs
|
||||
*
|
||||
* Shown when navigating to /practice/[studentId] with an ID that doesn't exist
|
||||
*/
|
||||
export default function StudentNotFound() {
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-not-found"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3rem', marginBottom: '1rem' })}>🔍</div>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Student Not Found
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
We couldn't find a student with this ID. They may have been removed.
|
||||
</p>
|
||||
<Link
|
||||
href="/practice"
|
||||
className={css({
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 2rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: 'blue.500',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
_hover: { backgroundColor: 'blue.600' },
|
||||
})}
|
||||
>
|
||||
Select a Student
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
55
apps/web/src/app/practice/[studentId]/page.tsx
Normal file
55
apps/web/src/app/practice/[studentId]/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import {
|
||||
getActiveSessionPlan,
|
||||
getAllSkillMastery,
|
||||
getPlayer,
|
||||
getPlayerCurriculum,
|
||||
getRecentSessions,
|
||||
} from '@/lib/curriculum/server'
|
||||
import { StudentPracticeClient } from './StudentPracticeClient'
|
||||
|
||||
// Disable caching for this page - session state must always be fresh
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface StudentPracticePageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Student Practice Page - Server Component
|
||||
*
|
||||
* Fetches all required data on the server and passes to client component.
|
||||
* This provides instant rendering with no loading spinner.
|
||||
*
|
||||
* URL: /practice/[studentId]
|
||||
*/
|
||||
export default async function StudentPracticePage({ params }: StudentPracticePageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [player, activeSession, curriculum, skills, recentSessions] = await Promise.all([
|
||||
getPlayer(studentId),
|
||||
getActiveSessionPlan(studentId),
|
||||
getPlayerCurriculum(studentId),
|
||||
getAllSkillMastery(studentId),
|
||||
getRecentSessions(studentId, 10),
|
||||
])
|
||||
|
||||
// 404 if player doesn't exist
|
||||
if (!player) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<StudentPracticeClient
|
||||
studentId={studentId}
|
||||
initialPlayer={player}
|
||||
initialActiveSession={activeSession}
|
||||
initialCurriculum={{
|
||||
curriculum,
|
||||
skills,
|
||||
recentSessions,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { PlacementTest } from '@/components/practice/PlacementTest'
|
||||
|
||||
interface PlacementTestClientProps {
|
||||
studentId: string
|
||||
playerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component for placement test page
|
||||
*
|
||||
* Wraps the PlacementTest component and handles navigation
|
||||
* on completion or cancellation.
|
||||
*/
|
||||
export function PlacementTestClient({ studentId, playerName }: PlacementTestClientProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(results: {
|
||||
masteredSkillIds: string[]
|
||||
practicingSkillIds: string[]
|
||||
totalProblems: number
|
||||
totalCorrect: number
|
||||
}) => {
|
||||
// TODO: Save results to curriculum via API
|
||||
console.log('Placement test complete:', results)
|
||||
// Return to main practice page
|
||||
router.push(`/practice/${studentId}`)
|
||||
},
|
||||
[studentId, router]
|
||||
)
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/practice/${studentId}`)
|
||||
}, [studentId, router])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<PlacementTest
|
||||
studentName={playerName}
|
||||
playerId={studentId}
|
||||
onComplete={handleComplete}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPlayer } from '@/lib/curriculum/server'
|
||||
import { PlacementTestClient } from './PlacementTestClient'
|
||||
|
||||
interface PlacementTestPageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement Test Page - Server Component
|
||||
*
|
||||
* Orthogonal to session state - can be accessed anytime.
|
||||
* Results are saved and user is redirected to main practice page on completion.
|
||||
*
|
||||
* URL: /practice/[studentId]/placement-test
|
||||
*/
|
||||
export default async function PlacementTestPage({ params }: PlacementTestPageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
const player = await getPlayer(studentId)
|
||||
|
||||
// 404 if player doesn't exist
|
||||
if (!player) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <PlacementTestClient studentId={studentId} playerName={player.name} />
|
||||
}
|
||||
@@ -1,852 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ActiveSession,
|
||||
type CurrentPhaseInfo,
|
||||
PlanReview,
|
||||
ProgressDashboard,
|
||||
SessionSummary,
|
||||
type SkillProgress,
|
||||
StudentSelector,
|
||||
type StudentWithProgress,
|
||||
} from '@/components/practice'
|
||||
import { ManualSkillSelector } from '@/components/practice/ManualSkillSelector'
|
||||
import {
|
||||
type OfflineSessionData,
|
||||
OfflineSessionForm,
|
||||
} from '@/components/practice/OfflineSessionForm'
|
||||
import { PlacementTest } from '@/components/practice/PlacementTest'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { SlotResult } from '@/db/schema/session-plans'
|
||||
import { usePlayerCurriculum } from '@/hooks/usePlayerCurriculum'
|
||||
import {
|
||||
useApproveSessionPlan,
|
||||
useEndSessionEarly,
|
||||
useGenerateSessionPlan,
|
||||
useRecordSlotResult,
|
||||
useStartSessionPlan,
|
||||
} from '@/hooks/useSessionPlan'
|
||||
import { useUserPlayers } from '@/hooks/useUserPlayers'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
// Mock curriculum phase data (until we integrate with actual curriculum)
|
||||
function getPhaseInfo(phaseId: string): CurrentPhaseInfo {
|
||||
// Parse phase ID format: L{level}.{operation}.{number}.{technique}
|
||||
const parts = phaseId.split('.')
|
||||
const level = parts[0]?.replace('L', '') || '1'
|
||||
const operation = parts[1] || 'add'
|
||||
const number = parts[2] || '+1'
|
||||
const technique = parts[3] || 'direct'
|
||||
|
||||
const operationName = operation === 'add' ? 'Addition' : 'Subtraction'
|
||||
const techniqueName =
|
||||
technique === 'direct'
|
||||
? 'Direct Method'
|
||||
: technique === 'five'
|
||||
? 'Five Complement'
|
||||
: technique === 'ten'
|
||||
? 'Ten Complement'
|
||||
: technique
|
||||
|
||||
return {
|
||||
phaseId,
|
||||
levelName: `Level ${level}`,
|
||||
phaseName: `${operationName}: ${number} (${techniqueName})`,
|
||||
description: `Practice ${operation === 'add' ? 'adding' : 'subtracting'} ${number.replace('+', '').replace('-', '')} using the ${techniqueName.toLowerCase()}.`,
|
||||
skillsToMaster: [`${operation}.${number}.${technique}`],
|
||||
masteredSkills: 0,
|
||||
totalSkills: 1,
|
||||
}
|
||||
}
|
||||
|
||||
type ViewState =
|
||||
| 'selecting'
|
||||
| 'dashboard'
|
||||
| 'configuring'
|
||||
| 'reviewing'
|
||||
| 'practicing'
|
||||
| 'summary'
|
||||
| 'creating'
|
||||
| 'placement-test'
|
||||
|
||||
interface SessionConfig {
|
||||
durationMinutes: number
|
||||
}
|
||||
import { getPlayersForViewer } from '@/lib/curriculum/server'
|
||||
import { PracticeClient } from './PracticeClient'
|
||||
|
||||
/**
|
||||
* Practice page - Entry point for student practice sessions
|
||||
* Practice page - Server Component
|
||||
*
|
||||
* Flow:
|
||||
* 1. Show StudentSelector to choose which student is practicing
|
||||
* 2. Show ProgressDashboard with current progress and actions
|
||||
* 3. Configure session (duration, mode)
|
||||
* 4. Review generated plan
|
||||
* 5. Practice!
|
||||
* 6. View summary
|
||||
* Fetches player list on the server and passes to client component.
|
||||
* This provides instant rendering with no loading spinner.
|
||||
*
|
||||
* URL: /practice
|
||||
*/
|
||||
export default function PracticePage() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
export default async function PracticePage() {
|
||||
// Fetch players directly on server - no HTTP round-trip
|
||||
const players = await getPlayersForViewer()
|
||||
|
||||
const [viewState, setViewState] = useState<ViewState>('selecting')
|
||||
const [selectedStudent, setSelectedStudent] = useState<StudentWithProgress | null>(null)
|
||||
const [sessionConfig, setSessionConfig] = useState<SessionConfig>({
|
||||
durationMinutes: 10,
|
||||
})
|
||||
|
||||
// Modal states for onboarding features
|
||||
const [showManualSkillModal, setShowManualSkillModal] = useState(false)
|
||||
const [showOfflineSessionModal, setShowOfflineSessionModal] = useState(false)
|
||||
|
||||
// React Query hooks for players
|
||||
const { data: players = [], isLoading: isLoadingStudents } = useUserPlayers()
|
||||
|
||||
// Get curriculum data for selected student
|
||||
const curriculum = usePlayerCurriculum(selectedStudent?.id ?? null)
|
||||
|
||||
// Session plan mutations
|
||||
const generatePlan = useGenerateSessionPlan()
|
||||
const approvePlan = useApproveSessionPlan()
|
||||
const startPlan = useStartSessionPlan()
|
||||
const recordResult = useRecordSlotResult()
|
||||
const endEarly = useEndSessionEarly()
|
||||
|
||||
// Current plan from mutations (use the latest successful result)
|
||||
const currentPlan =
|
||||
recordResult.data ?? startPlan.data ?? approvePlan.data ?? generatePlan.data ?? null
|
||||
|
||||
// Derive error state from mutations
|
||||
const error = generatePlan.error
|
||||
? {
|
||||
context: 'generate' as const,
|
||||
message: 'Unable to create practice plan',
|
||||
suggestion:
|
||||
'This may be a temporary issue. Try selecting a different duration or refresh the page.',
|
||||
}
|
||||
: startPlan.error || approvePlan.error
|
||||
? {
|
||||
context: 'start' as const,
|
||||
message: 'Unable to start practice session',
|
||||
suggestion:
|
||||
'The plan was created but could not be started. Try clicking "Let\'s Go!" again, or go back and create a new plan.',
|
||||
}
|
||||
: null
|
||||
|
||||
// Convert players to StudentWithProgress format
|
||||
// Note: For full curriculum enrichment, we'd need separate queries per player
|
||||
// For now, use basic player data
|
||||
const students: StudentWithProgress[] = players.map((player) => ({
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
createdAt: player.createdAt,
|
||||
}))
|
||||
|
||||
// Calculate mastery percentage from skills
|
||||
function calculateMasteryPercent(skills: Array<{ masteryLevel: string }>): number {
|
||||
if (skills.length === 0) return 0
|
||||
const mastered = skills.filter((s) => s.masteryLevel === 'mastered').length
|
||||
return Math.round((mastered / skills.length) * 100)
|
||||
}
|
||||
|
||||
// Handle student selection
|
||||
const handleSelectStudent = useCallback((student: StudentWithProgress) => {
|
||||
setSelectedStudent(student)
|
||||
setViewState('dashboard')
|
||||
}, [])
|
||||
|
||||
// Handle adding a new student
|
||||
const handleAddStudent = useCallback(() => {
|
||||
setViewState('creating')
|
||||
}, [])
|
||||
|
||||
// Handle going back to student selection
|
||||
const handleChangeStudent = useCallback(() => {
|
||||
setSelectedStudent(null)
|
||||
// Reset all mutations to clear plan state
|
||||
generatePlan.reset()
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
recordResult.reset()
|
||||
endEarly.reset()
|
||||
setViewState('selecting')
|
||||
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly])
|
||||
|
||||
// Handle continue practice - go to session configuration
|
||||
const handleContinuePractice = useCallback(() => {
|
||||
setViewState('configuring')
|
||||
}, [])
|
||||
|
||||
// Handle generating a session plan
|
||||
const handleGeneratePlan = useCallback(() => {
|
||||
if (!selectedStudent) return
|
||||
|
||||
generatePlan.reset() // Clear any previous errors
|
||||
generatePlan.mutate(
|
||||
{
|
||||
playerId: selectedStudent.id,
|
||||
durationMinutes: sessionConfig.durationMinutes,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setViewState('reviewing')
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [selectedStudent, sessionConfig, generatePlan])
|
||||
|
||||
// Handle approving the plan (approve + start in sequence)
|
||||
const handleApprovePlan = useCallback(() => {
|
||||
if (!selectedStudent || !currentPlan) return
|
||||
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
|
||||
// First approve, then start
|
||||
approvePlan.mutate(
|
||||
{ playerId: selectedStudent.id, planId: currentPlan.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
startPlan.mutate(
|
||||
{ playerId: selectedStudent.id, planId: currentPlan.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setViewState('practicing')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [selectedStudent, currentPlan, approvePlan, startPlan])
|
||||
|
||||
// Handle canceling the plan review
|
||||
const handleCancelPlan = useCallback(() => {
|
||||
generatePlan.reset()
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
setViewState('configuring')
|
||||
}, [generatePlan, approvePlan, startPlan])
|
||||
|
||||
// Handle recording an answer
|
||||
const handleAnswer = useCallback(
|
||||
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
|
||||
if (!selectedStudent || !currentPlan) return
|
||||
|
||||
await recordResult.mutateAsync({
|
||||
playerId: selectedStudent.id,
|
||||
planId: currentPlan.id,
|
||||
result,
|
||||
})
|
||||
},
|
||||
[selectedStudent, currentPlan, recordResult]
|
||||
)
|
||||
|
||||
// Handle ending session early
|
||||
const handleEndEarly = useCallback(
|
||||
(reason?: string) => {
|
||||
if (!selectedStudent || !currentPlan) return
|
||||
|
||||
endEarly.mutate(
|
||||
{
|
||||
playerId: selectedStudent.id,
|
||||
planId: currentPlan.id,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setViewState('summary')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[selectedStudent, currentPlan, endEarly]
|
||||
)
|
||||
|
||||
// Handle session completion
|
||||
const handleSessionComplete = useCallback(() => {
|
||||
setViewState('summary')
|
||||
}, [])
|
||||
|
||||
// Handle practice again
|
||||
const handlePracticeAgain = useCallback(() => {
|
||||
// Reset all mutations to clear the plan
|
||||
generatePlan.reset()
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
recordResult.reset()
|
||||
endEarly.reset()
|
||||
setViewState('configuring')
|
||||
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly])
|
||||
|
||||
// Handle back to dashboard
|
||||
const handleBackToDashboard = useCallback(() => {
|
||||
// Reset all mutations to clear the plan
|
||||
generatePlan.reset()
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
recordResult.reset()
|
||||
endEarly.reset()
|
||||
setViewState('dashboard')
|
||||
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly])
|
||||
|
||||
// Handle view full progress (not yet implemented)
|
||||
const handleViewFullProgress = useCallback(() => {
|
||||
// TODO: Navigate to detailed progress view when implemented
|
||||
}, [])
|
||||
|
||||
// Handle generate worksheet
|
||||
const handleGenerateWorksheet = useCallback(() => {
|
||||
// Navigate to worksheet generator with student's current level
|
||||
window.location.href = '/create/worksheets/addition'
|
||||
}, [])
|
||||
|
||||
// Handle opening placement test
|
||||
const handleRunPlacementTest = useCallback(() => {
|
||||
setViewState('placement-test')
|
||||
}, [])
|
||||
|
||||
// Handle placement test completion
|
||||
const handlePlacementTestComplete = useCallback(
|
||||
(results: {
|
||||
masteredSkillIds: string[]
|
||||
practicingSkillIds: string[]
|
||||
totalProblems: number
|
||||
totalCorrect: number
|
||||
}) => {
|
||||
// TODO: Save results to curriculum via API
|
||||
console.log('Placement test complete:', results)
|
||||
// Return to dashboard after completion
|
||||
setViewState('dashboard')
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle placement test cancel
|
||||
const handlePlacementTestCancel = useCallback(() => {
|
||||
setViewState('dashboard')
|
||||
}, [])
|
||||
|
||||
// Handle opening manual skill selector
|
||||
const handleSetSkillsManually = useCallback(() => {
|
||||
setShowManualSkillModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle saving manual skill selections
|
||||
const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise<void> => {
|
||||
// TODO: Save skills to curriculum via API
|
||||
console.log('Manual skills saved:', masteredSkillIds)
|
||||
setShowManualSkillModal(false)
|
||||
}, [])
|
||||
|
||||
// Handle opening offline session form
|
||||
const handleRecordOfflinePractice = useCallback(() => {
|
||||
setShowOfflineSessionModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle submitting offline session
|
||||
const handleSubmitOfflineSession = useCallback(
|
||||
async (data: OfflineSessionData): Promise<void> => {
|
||||
// TODO: Save offline session to database via API
|
||||
console.log('Offline session recorded:', data)
|
||||
setShowOfflineSessionModal(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Build current phase info from curriculum
|
||||
const currentPhase = curriculum.curriculum
|
||||
? getPhaseInfo(curriculum.curriculum.currentPhaseId)
|
||||
: getPhaseInfo('L1.add.+1.direct')
|
||||
|
||||
// Update phase info with actual skill mastery
|
||||
if (curriculum.skills.length > 0) {
|
||||
const phaseSkills = curriculum.skills.filter((s) =>
|
||||
currentPhase.skillsToMaster.includes(s.skillId)
|
||||
)
|
||||
currentPhase.masteredSkills = phaseSkills.filter((s) => s.masteryLevel === 'mastered').length
|
||||
currentPhase.totalSkills = currentPhase.skillsToMaster.length
|
||||
}
|
||||
|
||||
// Map skills to display format
|
||||
const recentSkills: SkillProgress[] = curriculum.skills.slice(0, 5).map((s) => ({
|
||||
skillId: s.skillId,
|
||||
skillName: formatSkillName(s.skillId),
|
||||
masteryLevel: s.masteryLevel,
|
||||
attempts: s.attempts,
|
||||
correct: s.correct,
|
||||
consecutiveCorrect: s.consecutiveCorrect,
|
||||
}))
|
||||
|
||||
// Format skill ID to human-readable name
|
||||
function formatSkillName(skillId: string): string {
|
||||
// Example: "add.+3.direct" -> "+3 Direct"
|
||||
const parts = skillId.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const number = parts[1] || skillId
|
||||
const technique = parts[2]
|
||||
const techLabel =
|
||||
technique === 'direct'
|
||||
? ''
|
||||
: technique === 'five'
|
||||
? ' (5s)'
|
||||
: technique === 'ten'
|
||||
? ' (10s)'
|
||||
: ''
|
||||
return `${number}${techLabel}`
|
||||
}
|
||||
return skillId
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: viewState === 'practicing' ? '80px' : 'calc(80px + 2rem)',
|
||||
paddingLeft: viewState === 'practicing' ? '0' : '2rem',
|
||||
paddingRight: viewState === 'practicing' ? '0' : '2rem',
|
||||
paddingBottom: viewState === 'practicing' ? '0' : '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: viewState === 'practicing' ? '100%' : '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header - hide during practice */}
|
||||
{viewState !== 'practicing' && (
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Daily Practice
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Build your soroban skills one step at a time
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Content based on view state */}
|
||||
{viewState === 'selecting' &&
|
||||
(isLoadingStudents ? (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
Loading students...
|
||||
</div>
|
||||
) : (
|
||||
<StudentSelector
|
||||
students={students}
|
||||
selectedStudent={selectedStudent ?? undefined}
|
||||
onSelectStudent={handleSelectStudent}
|
||||
onAddStudent={handleAddStudent}
|
||||
/>
|
||||
))}
|
||||
|
||||
{viewState === 'dashboard' && selectedStudent && (
|
||||
<ProgressDashboard
|
||||
student={selectedStudent}
|
||||
currentPhase={currentPhase}
|
||||
recentSkills={recentSkills}
|
||||
onContinuePractice={handleContinuePractice}
|
||||
onViewFullProgress={handleViewFullProgress}
|
||||
onGenerateWorksheet={handleGenerateWorksheet}
|
||||
onChangeStudent={handleChangeStudent}
|
||||
onRunPlacementTest={handleRunPlacementTest}
|
||||
onSetSkillsManually={handleSetSkillsManually}
|
||||
onRecordOfflinePractice={handleRecordOfflinePractice}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewState === 'configuring' && selectedStudent && (
|
||||
<div
|
||||
data-section="session-config"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5rem',
|
||||
padding: '2rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Configure Practice Session
|
||||
</h2>
|
||||
|
||||
{/* Duration selector */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Session Duration
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{[5, 10, 15, 20].map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
onClick={() => setSessionConfig((c) => ({ ...c, durationMinutes: mins }))}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '1rem',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: sessionConfig.durationMinutes === mins ? 'white' : 'gray.700',
|
||||
backgroundColor:
|
||||
sessionConfig.durationMinutes === mins ? 'blue.500' : 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
sessionConfig.durationMinutes === mins ? 'blue.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{mins} min
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session structure preview */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Today's Practice Structure
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<span>🧮</span>
|
||||
<span className={css({ color: 'gray.700' })}>
|
||||
<strong>Part 1:</strong> Use abacus
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<span>🧠</span>
|
||||
<span className={css({ color: 'gray.700' })}>
|
||||
<strong>Part 2:</strong> Mental math (visualization)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<span>💭</span>
|
||||
<span className={css({ color: 'gray.700' })}>
|
||||
<strong>Part 3:</strong> Mental math (linear)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display for plan generation */}
|
||||
{error?.context === 'generate' && (
|
||||
<div
|
||||
data-element="error-banner"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: 'red.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
<div className={css({ fontSize: '0.875rem', color: 'red.600' })}>
|
||||
{error.suggestion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
generatePlan.reset()
|
||||
setViewState('dashboard')
|
||||
}}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '1rem',
|
||||
fontSize: '1rem',
|
||||
color: 'gray.600',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePlan}
|
||||
disabled={generatePlan.isPending}
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '1rem',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: generatePlan.isPending ? 'gray.400' : 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: generatePlan.isPending ? 'not-allowed' : 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: generatePlan.isPending ? 'gray.400' : 'green.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{generatePlan.isPending ? 'Generating...' : 'Generate Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewState === 'reviewing' && selectedStudent && currentPlan && (
|
||||
<div data-section="plan-review-wrapper">
|
||||
{/* Error display for session start */}
|
||||
{error?.context === 'start' && (
|
||||
<div
|
||||
data-element="error-banner"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: 'red.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto 1rem auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
<div className={css({ fontSize: '0.875rem', color: 'red.600' })}>
|
||||
{error.suggestion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PlanReview
|
||||
plan={currentPlan}
|
||||
studentName={selectedStudent.name}
|
||||
onApprove={handleApprovePlan}
|
||||
onCancel={handleCancelPlan}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewState === 'practicing' && selectedStudent && currentPlan && (
|
||||
<ActiveSession
|
||||
plan={currentPlan}
|
||||
studentName={selectedStudent.name}
|
||||
onAnswer={handleAnswer}
|
||||
onEndEarly={handleEndEarly}
|
||||
onComplete={handleSessionComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewState === 'summary' && selectedStudent && currentPlan && (
|
||||
<SessionSummary
|
||||
plan={currentPlan}
|
||||
studentName={selectedStudent.name}
|
||||
onPracticeAgain={handlePracticeAgain}
|
||||
onBackToDashboard={handleBackToDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewState === 'creating' && (
|
||||
<div
|
||||
data-section="create-student"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
Add New Student
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
Student creation form coming soon!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewState('selecting')}
|
||||
className={css({
|
||||
padding: '0.75rem 2rem',
|
||||
fontSize: '1rem',
|
||||
color: 'gray.700',
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
← Back to Student Selection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewState === 'placement-test' && selectedStudent && (
|
||||
<PlacementTest
|
||||
studentName={selectedStudent.name}
|
||||
playerId={selectedStudent.id}
|
||||
onComplete={handlePlacementTestComplete}
|
||||
onCancel={handlePlacementTestCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Skill Selector Modal */}
|
||||
{selectedStudent && (
|
||||
<ManualSkillSelector
|
||||
studentName={selectedStudent.name}
|
||||
playerId={selectedStudent.id}
|
||||
open={showManualSkillModal}
|
||||
onClose={() => setShowManualSkillModal(false)}
|
||||
onSave={handleSaveManualSkills}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Offline Session Form Modal */}
|
||||
{selectedStudent && (
|
||||
<OfflineSessionForm
|
||||
studentName={selectedStudent.name}
|
||||
playerId={selectedStudent.id}
|
||||
open={showOfflineSessionModal}
|
||||
onClose={() => setShowOfflineSessionModal(false)}
|
||||
onSubmit={handleSubmitOfflineSession}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
return <PracticeClient initialPlayers={players} />
|
||||
}
|
||||
|
||||
681
apps/web/src/app/students/page.tsx
Normal file
681
apps/web/src/app/students/page.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import {
|
||||
useCreatePlayer,
|
||||
useDeletePlayer,
|
||||
useUpdatePlayer,
|
||||
useUserPlayers,
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
// Available emojis for student selection
|
||||
const AVAILABLE_EMOJIS = ['🦊', '🐸', '🐻', '🐼', '🐨', '🦁', '🐯', '🐮', '🐷', '🐵', '🦄', '🐝']
|
||||
|
||||
// Available colors for student avatars
|
||||
const AVAILABLE_COLORS = [
|
||||
'#FFB3BA', // light pink
|
||||
'#FFDFBA', // light orange
|
||||
'#FFFFBA', // light yellow
|
||||
'#BAFFC9', // light green
|
||||
'#BAE1FF', // light blue
|
||||
'#DCC6E0', // light purple
|
||||
'#F0E68C', // khaki
|
||||
'#98D8C8', // mint
|
||||
'#F7DC6F', // gold
|
||||
'#BB8FCE', // orchid
|
||||
'#85C1E9', // sky blue
|
||||
'#F8B500', // amber
|
||||
]
|
||||
|
||||
type ViewMode = 'list' | 'create' | 'edit'
|
||||
|
||||
interface EditingStudent {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Students management page
|
||||
* Allows creating, editing, and deleting students (players)
|
||||
*/
|
||||
export default function StudentsPage() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [editingStudent, setEditingStudent] = useState<EditingStudent | null>(null)
|
||||
|
||||
// Form state for new/editing student
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formEmoji, setFormEmoji] = useState(AVAILABLE_EMOJIS[0])
|
||||
const [formColor, setFormColor] = useState(AVAILABLE_COLORS[0])
|
||||
|
||||
// React Query hooks
|
||||
const { data: players = [], isLoading } = useUserPlayers()
|
||||
const createPlayer = useCreatePlayer()
|
||||
const updatePlayer = useUpdatePlayer()
|
||||
const deletePlayer = useDeletePlayer()
|
||||
|
||||
// Start creating a new student
|
||||
const handleStartCreate = useCallback(() => {
|
||||
setFormName('')
|
||||
setFormEmoji(AVAILABLE_EMOJIS[Math.floor(Math.random() * AVAILABLE_EMOJIS.length)])
|
||||
setFormColor(AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)])
|
||||
setEditingStudent(null)
|
||||
setViewMode('create')
|
||||
}, [])
|
||||
|
||||
// Start editing an existing student
|
||||
const handleStartEdit = useCallback((player: Player) => {
|
||||
setFormName(player.name)
|
||||
setFormEmoji(player.emoji)
|
||||
setFormColor(player.color)
|
||||
setEditingStudent({
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
})
|
||||
setViewMode('edit')
|
||||
}, [])
|
||||
|
||||
// Cancel form and return to list
|
||||
const handleCancel = useCallback(() => {
|
||||
setFormName('')
|
||||
setEditingStudent(null)
|
||||
setViewMode('list')
|
||||
}, [])
|
||||
|
||||
// Submit form (create or update)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!formName.trim()) return
|
||||
|
||||
if (viewMode === 'create') {
|
||||
createPlayer.mutate(
|
||||
{
|
||||
name: formName.trim(),
|
||||
emoji: formEmoji,
|
||||
color: formColor,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setViewMode('list')
|
||||
setFormName('')
|
||||
},
|
||||
}
|
||||
)
|
||||
} else if (viewMode === 'edit' && editingStudent) {
|
||||
updatePlayer.mutate(
|
||||
{
|
||||
id: editingStudent.id,
|
||||
updates: {
|
||||
name: formName.trim(),
|
||||
emoji: formEmoji,
|
||||
color: formColor,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setViewMode('list')
|
||||
setEditingStudent(null)
|
||||
setFormName('')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [viewMode, formName, formEmoji, formColor, editingStudent, createPlayer, updatePlayer])
|
||||
|
||||
// Delete a student
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this student? This cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
deletePlayer.mutate(id, {
|
||||
onSuccess: () => {
|
||||
if (editingStudent?.id === id) {
|
||||
setViewMode('list')
|
||||
setEditingStudent(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
[deletePlayer, editingStudent]
|
||||
)
|
||||
|
||||
// Navigate to practice
|
||||
const handleNavigateToPractice = useCallback(() => {
|
||||
window.location.href = '/practice'
|
||||
}, [])
|
||||
|
||||
const isPending = createPlayer.isPending || updatePlayer.isPending || deletePlayer.isPending
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="students-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Manage Students
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Add, edit, or remove students for practice sessions
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* List View */}
|
||||
{viewMode === 'list' && (
|
||||
<div data-section="student-list">
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
Loading students...
|
||||
</div>
|
||||
) : players.length === 0 ? (
|
||||
<div
|
||||
data-element="empty-state"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
👋
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
No students yet
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
Add your first student to get started with practice sessions.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-action="add-first-student"
|
||||
onClick={handleStartCreate}
|
||||
className={css({
|
||||
padding: '0.75rem 2rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: 'green.600' },
|
||||
})}
|
||||
>
|
||||
Add Student
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Student cards */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{players.map((player) => (
|
||||
<div
|
||||
key={player.id}
|
||||
data-element="student-card"
|
||||
data-student-id={player.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: 'sm',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={css({
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
})}
|
||||
style={{ backgroundColor: player.color }}
|
||||
>
|
||||
{player.emoji}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{player.name}
|
||||
</h3>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="edit-student"
|
||||
onClick={() => handleStartEdit(player)}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'blue.900/50' : 'blue.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="delete-student"
|
||||
onClick={() => handleDelete(player.id)}
|
||||
disabled={deletePlayer.isPending}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'red.300' : 'red.600',
|
||||
backgroundColor: isDark ? 'red.900/30' : 'red.50',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'red.900/50' : 'red.100',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="add-student"
|
||||
onClick={handleStartCreate}
|
||||
className={css({
|
||||
padding: '0.75rem 2rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: 'green.600' },
|
||||
})}
|
||||
>
|
||||
Add Student
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="go-to-practice"
|
||||
onClick={handleNavigateToPractice}
|
||||
className={css({
|
||||
padding: '0.75rem 2rem',
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Go to Practice
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||
<div
|
||||
data-section="student-form"
|
||||
className={css({
|
||||
padding: '2rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
marginBottom: '1.5rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{viewMode === 'create' ? 'Add New Student' : 'Edit Student'}
|
||||
</h2>
|
||||
|
||||
{/* Preview */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
data-element="avatar-preview"
|
||||
className={css({
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2.5rem',
|
||||
boxShadow: 'md',
|
||||
})}
|
||||
style={{ backgroundColor: formColor }}
|
||||
>
|
||||
{formEmoji}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name input */}
|
||||
<div className={css({ marginBottom: '1.5rem' })}>
|
||||
<label
|
||||
htmlFor="student-name"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="student-name"
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Enter student name"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Emoji selector */}
|
||||
<div className={css({ marginBottom: '1.5rem' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Avatar
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{AVAILABLE_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => setFormEmoji(emoji)}
|
||||
className={css({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
fontSize: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: formEmoji === emoji ? 'blue.500' : 'transparent',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color selector */}
|
||||
<div className={css({ marginBottom: '2rem' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setFormColor(color)}
|
||||
className={css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid',
|
||||
borderColor: formColor === color ? 'blue.500' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
_hover: {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
})}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="save"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !formName.trim()}
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: isPending ? 'gray.400' : 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isPending ? 'gray.400' : 'green.600',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isPending ? 'Saving...' : viewMode === 'create' ? 'Add Student' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -214,18 +214,23 @@ export function ActiveSession({
|
||||
// Interaction state machine - single source of truth for UI state
|
||||
const {
|
||||
phase,
|
||||
attempt,
|
||||
helpContext,
|
||||
outgoingAttempt,
|
||||
canAcceptInput,
|
||||
showAsCompleted,
|
||||
showHelpOverlay,
|
||||
showInputArea,
|
||||
showFeedback,
|
||||
inputIsFocused,
|
||||
isTransitioning,
|
||||
isPaused,
|
||||
isSubmitting,
|
||||
prefixSums,
|
||||
matchedPrefixIndex,
|
||||
canSubmit,
|
||||
shouldAutoSubmit,
|
||||
ambiguousHelpTermIndex,
|
||||
ambiguousTimerElapsed,
|
||||
loadProblem,
|
||||
handleDigit,
|
||||
handleBackspace,
|
||||
@@ -260,60 +265,6 @@ export function ActiveSession({
|
||||
config: { tension: 200, friction: 26 },
|
||||
}))
|
||||
|
||||
// Extract attempt from phase for UI rendering
|
||||
const attempt = useMemo(() => {
|
||||
switch (phase.phase) {
|
||||
case 'inputting':
|
||||
case 'helpMode':
|
||||
case 'submitting':
|
||||
case 'showingFeedback':
|
||||
return phase.attempt
|
||||
case 'transitioning':
|
||||
return phase.incoming
|
||||
case 'paused': {
|
||||
const inner = phase.resumePhase
|
||||
if (
|
||||
inner.phase === 'inputting' ||
|
||||
inner.phase === 'helpMode' ||
|
||||
inner.phase === 'submitting' ||
|
||||
inner.phase === 'showingFeedback'
|
||||
) {
|
||||
return inner.attempt
|
||||
}
|
||||
if (inner.phase === 'transitioning') {
|
||||
return inner.incoming
|
||||
}
|
||||
return null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [phase])
|
||||
|
||||
// Extract help context from phase
|
||||
const helpContext = useMemo(() => {
|
||||
if (phase.phase === 'helpMode') {
|
||||
return phase.helpContext
|
||||
}
|
||||
// Also check paused phase
|
||||
if (phase.phase === 'paused' && phase.resumePhase.phase === 'helpMode') {
|
||||
return phase.resumePhase.helpContext
|
||||
}
|
||||
return null
|
||||
}, [phase])
|
||||
|
||||
// Extract outgoing attempt for transition animation
|
||||
const outgoingAttempt = phase.phase === 'transitioning' ? phase.outgoing : null
|
||||
|
||||
// Check if we're in transitioning phase
|
||||
const isTransitioning = phase.phase === 'transitioning'
|
||||
|
||||
// Check if we're paused
|
||||
const isPaused = phase.phase === 'paused'
|
||||
|
||||
// Check if we're submitting
|
||||
const isSubmitting = phase.phase === 'submitting'
|
||||
|
||||
// Spring for submit button entrance animation
|
||||
const submitButtonSpring = useSpring({
|
||||
transform: attempt?.manualSubmitRequired ? 'translateY(0px)' : 'translateY(60px)',
|
||||
@@ -407,21 +358,14 @@ export function ActiveSession({
|
||||
}
|
||||
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, phase.phase, loadProblem])
|
||||
|
||||
// Auto-trigger help when prefix sum is detected
|
||||
// For unambiguous matches: trigger immediately
|
||||
// For ambiguous matches: wait for the disambiguation timer to elapse
|
||||
// Auto-trigger help when an unambiguous prefix sum is detected
|
||||
// The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires
|
||||
// This effect only handles the inputting phase case for unambiguous matches
|
||||
useEffect(() => {
|
||||
// Only handle unambiguous prefix matches in inputting phase
|
||||
// Ambiguous cases are handled by awaitingDisambiguation phase, which auto-transitions to helpMode
|
||||
if (phase.phase !== 'inputting') return
|
||||
|
||||
// If there's an ambiguous match, only trigger help when timer has elapsed
|
||||
if (ambiguousHelpTermIndex >= 0) {
|
||||
if (ambiguousTimerElapsed) {
|
||||
enterHelpMode(ambiguousHelpTermIndex)
|
||||
}
|
||||
// Otherwise, wait - the "need help?" prompt is shown via ambiguousHelpTermIndex
|
||||
return
|
||||
}
|
||||
|
||||
// For unambiguous matches, trigger immediately
|
||||
if (matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1) {
|
||||
const newConfirmedCount = matchedPrefixIndex + 1
|
||||
@@ -429,14 +373,7 @@ export function ActiveSession({
|
||||
enterHelpMode(newConfirmedCount)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
phase,
|
||||
matchedPrefixIndex,
|
||||
prefixSums.length,
|
||||
ambiguousHelpTermIndex,
|
||||
ambiguousTimerElapsed,
|
||||
enterHelpMode,
|
||||
])
|
||||
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode])
|
||||
|
||||
// Handle when student reaches target value on help abacus
|
||||
const handleTargetReached = useCallback(() => {
|
||||
@@ -448,7 +385,14 @@ export function ActiveSession({
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (phase.phase !== 'inputting' && phase.phase !== 'helpMode') return
|
||||
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
|
||||
if (
|
||||
phase.phase !== 'inputting' &&
|
||||
phase.phase !== 'awaitingDisambiguation' &&
|
||||
phase.phase !== 'helpMode'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!phase.attempt.userAnswer) return
|
||||
|
||||
const attemptData = phase.attempt
|
||||
|
||||
212
apps/web/src/components/practice/ContinueSessionCard.tsx
Normal file
212
apps/web/src/components/practice/ContinueSessionCard.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { SessionPart, SessionPlan } from '@/db/schema/session-plans'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
function getPartTypeLabel(type: SessionPart['type']): string {
|
||||
switch (type) {
|
||||
case 'abacus':
|
||||
return 'Use Abacus'
|
||||
case 'visualization':
|
||||
return 'Mental Math (Visualization)'
|
||||
case 'linear':
|
||||
return 'Mental Math (Linear)'
|
||||
}
|
||||
}
|
||||
|
||||
function getPartTypeEmoji(type: SessionPart['type']): string {
|
||||
switch (type) {
|
||||
case 'abacus':
|
||||
return '🧮'
|
||||
case 'visualization':
|
||||
return '🧠'
|
||||
case 'linear':
|
||||
return '💭'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContinueSessionCardProps {
|
||||
studentName: string
|
||||
studentEmoji: string
|
||||
studentColor: string
|
||||
session: SessionPlan
|
||||
onContinue: () => void
|
||||
onStartFresh: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Card shown when a student has an active session in progress.
|
||||
* Displays progress and options to continue or start fresh.
|
||||
*/
|
||||
export function ContinueSessionCard({
|
||||
studentName,
|
||||
studentEmoji,
|
||||
studentColor,
|
||||
session,
|
||||
onContinue,
|
||||
onStartFresh,
|
||||
}: ContinueSessionCardProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Calculate progress
|
||||
const completedProblems = session.results.length
|
||||
const totalProblems = session.parts.reduce((sum, part) => sum + part.slots.length, 0)
|
||||
const progressPercent =
|
||||
totalProblems > 0 ? Math.round((completedProblems / totalProblems) * 100) : 0
|
||||
|
||||
const currentPart = session.parts[session.currentPartIndex]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="continue-session-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1.5rem',
|
||||
padding: '2rem',
|
||||
maxWidth: '500px',
|
||||
margin: '0 auto',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'lg',
|
||||
})}
|
||||
>
|
||||
{/* Avatar and greeting */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2.5rem',
|
||||
})}
|
||||
style={{ backgroundColor: studentColor }}
|
||||
>
|
||||
{studentEmoji}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Welcome back, {studentName}!
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Progress summary */}
|
||||
<div className={css({ width: '100%', textAlign: 'center' })}>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
You're on problem <strong>{completedProblems + 1}</strong> of{' '}
|
||||
<strong>{totalProblems}</strong>
|
||||
</p>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '12px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
backgroundColor: isDark ? 'green.400' : 'green.500',
|
||||
borderRadius: '6px',
|
||||
transition: 'width 0.3s ease',
|
||||
})}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current part indicator */}
|
||||
{currentPart && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
{getPartTypeEmoji(currentPart.type)} Part {session.currentPartIndex + 1}:{' '}
|
||||
{getPartTypeLabel(currentPart.type)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="continue-session"
|
||||
onClick={onContinue}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: 'green.600' },
|
||||
})}
|
||||
>
|
||||
Continue Session
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="start-fresh"
|
||||
onClick={onStartFresh}
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Fresh (abandon current session)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
266
apps/web/src/components/practice/PracticeErrorBoundary.tsx
Normal file
266
apps/web/src/components/practice/PracticeErrorBoundary.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import React, { Component, type ReactNode } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
studentName?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for practice sessions
|
||||
* Shows kid-friendly message with clear actions for grown-ups
|
||||
*/
|
||||
export class PracticeErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[PracticeErrorBoundary] Caught error:', error, errorInfo)
|
||||
}
|
||||
|
||||
resetError = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
return (
|
||||
<PracticeErrorFallback
|
||||
error={this.state.error}
|
||||
resetError={this.resetError}
|
||||
studentName={this.props.studentName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kid-friendly error UI with grown-up instructions
|
||||
*/
|
||||
function PracticeErrorFallback({
|
||||
error,
|
||||
resetError,
|
||||
studentName,
|
||||
}: {
|
||||
error: Error
|
||||
resetError: () => void
|
||||
studentName?: string
|
||||
}) {
|
||||
const [showDetails, setShowDetails] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="practice-error-boundary"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: '2rem',
|
||||
backgroundColor: 'amber.50',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Kid-friendly section */}
|
||||
<div
|
||||
className={css({
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '4rem', marginBottom: '1rem' })}>😕</div>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Oops! Something went wrong.
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Go get a grown-up to help fix this.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grown-up section */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: 'amber.300',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
For grown-ups:
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.600',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
{studentName ? `${studentName}'s` : 'The'} practice session encountered an error. Try one
|
||||
of these options:
|
||||
</p>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetError}
|
||||
data-action="try-again"
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: 'blue.600',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'blue.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (window.location.href = '/practice')}
|
||||
data-action="start-over"
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: 'gray.200',
|
||||
color: 'gray.800',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Over (Pick Student Again)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical details (collapsible) */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
paddingTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
data-action="toggle-error-details"
|
||||
className={css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'gray.500',
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
_hover: {
|
||||
color: 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} technical details
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'left',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Error: {error.message}
|
||||
</div>
|
||||
|
||||
{error.stack && (
|
||||
<pre
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
fontFamily: 'monospace',
|
||||
color: 'gray.600',
|
||||
overflow: 'auto',
|
||||
maxHeight: '150px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
})}
|
||||
>
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,6 @@ interface ProgressDashboardProps {
|
||||
onContinuePractice: () => void
|
||||
onViewFullProgress: () => void
|
||||
onGenerateWorksheet: () => void
|
||||
onChangeStudent: () => void
|
||||
/** Callback to run placement test */
|
||||
onRunPlacementTest?: () => void
|
||||
/** Callback to manually set skills */
|
||||
@@ -94,7 +93,6 @@ export function ProgressDashboard({
|
||||
onContinuePractice,
|
||||
onViewFullProgress,
|
||||
onGenerateWorksheet,
|
||||
onChangeStudent,
|
||||
onRunPlacementTest,
|
||||
onSetSkillsManually,
|
||||
onRecordOfflinePractice,
|
||||
@@ -154,23 +152,19 @@ export function ProgressDashboard({
|
||||
>
|
||||
Hi {student.name}!
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeStudent}
|
||||
<a
|
||||
href="/students"
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'blue.400' : 'blue.500',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'none',
|
||||
_hover: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Not {student.name}? Switch student
|
||||
</button>
|
||||
Manage students
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { StudentSelector, type StudentWithProgress } from './StudentSelector'
|
||||
|
||||
@@ -57,11 +56,9 @@ const newStudent: StudentWithProgress = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive demo with selection
|
||||
* Interactive demo - clicking a student logs to console
|
||||
*/
|
||||
function InteractiveSelectorDemo() {
|
||||
const [selected, setSelected] = useState<StudentWithProgress | undefined>()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -73,9 +70,7 @@ function InteractiveSelectorDemo() {
|
||||
>
|
||||
<StudentSelector
|
||||
students={sampleStudents}
|
||||
selectedStudent={selected}
|
||||
onSelectStudent={setSelected}
|
||||
onAddStudent={() => alert('Add Student clicked!')}
|
||||
onSelectStudent={(student) => console.log('Selected:', student.name)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -89,7 +84,6 @@ export const NoStudents: Story = {
|
||||
args: {
|
||||
students: [],
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
title: 'Who is practicing today?',
|
||||
},
|
||||
}
|
||||
@@ -97,45 +91,28 @@ export const NoStudents: Story = {
|
||||
export const SingleStudent: Story = {
|
||||
args: {
|
||||
students: [sampleStudents[0]],
|
||||
selectedStudent: undefined,
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
},
|
||||
}
|
||||
|
||||
export const MultipleStudents: Story = {
|
||||
args: {
|
||||
students: sampleStudents,
|
||||
selectedStudent: undefined,
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedStudent: Story = {
|
||||
args: {
|
||||
students: sampleStudents,
|
||||
selectedStudent: sampleStudents[0],
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
},
|
||||
}
|
||||
|
||||
export const NewStudentHighlighted: Story = {
|
||||
export const WithNewStudent: Story = {
|
||||
args: {
|
||||
students: [...sampleStudents, newStudent],
|
||||
selectedStudent: newStudent,
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomTitle: Story = {
|
||||
args: {
|
||||
students: sampleStudents,
|
||||
selectedStudent: undefined,
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
title: "Let's Practice Math!",
|
||||
},
|
||||
}
|
||||
@@ -150,8 +127,6 @@ export const StudentsWithoutProgress: Story = {
|
||||
masteryPercent: undefined,
|
||||
currentLevel: undefined,
|
||||
})),
|
||||
selectedStudent: undefined,
|
||||
onSelectStudent: () => {},
|
||||
onAddStudent: () => alert('Add Student clicked!'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
gapSm,
|
||||
paddingLg,
|
||||
paddingMd,
|
||||
primaryButtonStyles,
|
||||
progressBarContainerStyles,
|
||||
progressBarFillStyles,
|
||||
roundedLg,
|
||||
@@ -35,14 +34,14 @@ export interface StudentWithProgress extends Player {
|
||||
|
||||
interface StudentCardProps {
|
||||
student: StudentWithProgress
|
||||
isSelected?: boolean
|
||||
onSelect: (student: StudentWithProgress) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual student card showing avatar, name, and progress
|
||||
* Clicking navigates to the student's practice page
|
||||
*/
|
||||
function StudentCard({ student, isSelected, onSelect }: StudentCardProps) {
|
||||
function StudentCard({ student, onSelect }: StudentCardProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const levelLabel = student.currentLevel ? `Lv.${student.currentLevel}` : 'New'
|
||||
@@ -51,7 +50,6 @@ function StudentCard({ student, isSelected, onSelect }: StudentCardProps) {
|
||||
<button
|
||||
type="button"
|
||||
data-component="student-card"
|
||||
data-selected={isSelected}
|
||||
onClick={() => onSelect(student)}
|
||||
className={css({
|
||||
...centerStack,
|
||||
@@ -59,9 +57,9 @@ function StudentCard({ student, isSelected, onSelect }: StudentCardProps) {
|
||||
...paddingMd,
|
||||
...roundedLg,
|
||||
...transitionNormal,
|
||||
border: isSelected ? '3px solid' : '2px solid',
|
||||
borderColor: isSelected ? 'blue.500' : themed('border', isDark),
|
||||
backgroundColor: isSelected ? themed('info', isDark) : themed('surface', isDark),
|
||||
border: '2px solid',
|
||||
borderColor: themed('border', isDark),
|
||||
backgroundColor: themed('surface', isDark),
|
||||
cursor: 'pointer',
|
||||
minWidth: '100px',
|
||||
_hover: {
|
||||
@@ -103,22 +101,17 @@ function StudentCard({ student, isSelected, onSelect }: StudentCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface AddStudentButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Button to add a new student
|
||||
* Link to manage students page
|
||||
*/
|
||||
function AddStudentButton({ onClick }: AddStudentButtonProps) {
|
||||
function ManageStudentsLink() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-action="add-student"
|
||||
onClick={onClick}
|
||||
<a
|
||||
href="/students"
|
||||
data-action="manage-students"
|
||||
className={css({
|
||||
...centerStack,
|
||||
justifyContent: 'center',
|
||||
@@ -132,6 +125,7 @@ function AddStudentButton({ onClick }: AddStudentButtonProps) {
|
||||
cursor: 'pointer',
|
||||
minWidth: '100px',
|
||||
minHeight: '140px',
|
||||
textDecoration: 'none',
|
||||
_hover: {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: themed('info', isDark),
|
||||
@@ -140,7 +134,7 @@ function AddStudentButton({ onClick }: AddStudentButtonProps) {
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontSize: '1.5rem',
|
||||
color: themed('textSubtle', isDark),
|
||||
})}
|
||||
>
|
||||
@@ -150,19 +144,18 @@ function AddStudentButton({ onClick }: AddStudentButtonProps) {
|
||||
className={css({
|
||||
...textSm,
|
||||
color: themed('textMuted', isDark),
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Add New
|
||||
Manage Students
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
interface StudentSelectorProps {
|
||||
students: StudentWithProgress[]
|
||||
selectedStudent?: StudentWithProgress
|
||||
onSelectStudent: (student: StudentWithProgress) => void
|
||||
onAddStudent: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
@@ -170,14 +163,12 @@ interface StudentSelectorProps {
|
||||
* StudentSelector - Select which student is practicing today
|
||||
*
|
||||
* Displays all available students (players) with their current
|
||||
* curriculum level and progress. Parent/teacher selects a student
|
||||
* and hands the computer to the child.
|
||||
* curriculum level and progress. Clicking a student navigates
|
||||
* to their practice page at /practice/[studentId].
|
||||
*/
|
||||
export function StudentSelector({
|
||||
students,
|
||||
selectedStudent,
|
||||
onSelectStudent,
|
||||
onAddStudent,
|
||||
title = 'Who is practicing today?',
|
||||
}: StudentSelectorProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
@@ -212,43 +203,11 @@ export function StudentSelector({
|
||||
})}
|
||||
>
|
||||
{students.map((student) => (
|
||||
<StudentCard
|
||||
key={student.id}
|
||||
student={student}
|
||||
isSelected={selectedStudent?.id === student.id}
|
||||
onSelect={onSelectStudent}
|
||||
/>
|
||||
<StudentCard key={student.id} student={student} onSelect={onSelectStudent} />
|
||||
))}
|
||||
|
||||
<AddStudentButton onClick={onAddStudent} />
|
||||
<ManageStudentsLink />
|
||||
</div>
|
||||
|
||||
{/* Selected student action */}
|
||||
{selectedStudent && (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
...textBase,
|
||||
color: themed('textMuted', isDark),
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
Selected:{' '}
|
||||
<strong className={css({ color: themed('text', isDark) })}>
|
||||
{selectedStudent.name}
|
||||
</strong>{' '}
|
||||
{selectedStudent.emoji}
|
||||
</p>
|
||||
<button type="button" data-action="start-practice" className={css(primaryButtonStyles())}>
|
||||
Start Practice →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,24 +73,102 @@ describe('isDigitConsistent', () => {
|
||||
expect(isDigitConsistent('4', '2', bigSums)).toBe(true) // 42
|
||||
expect(isDigitConsistent('4', '3', bigSums)).toBe(false) // 43 not valid
|
||||
})
|
||||
|
||||
describe('leading zeros for disambiguation', () => {
|
||||
// Problem [2, 1, 30] -> sums [2, 3, 33]
|
||||
const ambiguousSums = [2, 3, 33]
|
||||
|
||||
it('accepts leading zero as first digit', () => {
|
||||
// "0" alone is allowed - indicates user is building a multi-digit number
|
||||
expect(isDigitConsistent('', '0', ambiguousSums)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts digit after leading zero that matches a prefix sum', () => {
|
||||
// "03" is valid - stripped to "3" which matches prefix sum
|
||||
expect(isDigitConsistent('0', '3', ambiguousSums)).toBe(true)
|
||||
// "02" is valid - stripped to "2" which matches prefix sum
|
||||
expect(isDigitConsistent('0', '2', ambiguousSums)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects digit after leading zero that does not match any prefix sum', () => {
|
||||
// "05" -> "5" does not match any prefix sum in [2, 3, 33]
|
||||
expect(isDigitConsistent('0', '5', ambiguousSums)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows multiple leading zeros up to max answer length', () => {
|
||||
// "00" is valid if we haven't exceeded max digit count
|
||||
expect(isDigitConsistent('0', '0', ambiguousSums)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('findMatchedPrefixIndex', () => {
|
||||
const sums = [3, 7, 12]
|
||||
|
||||
it('finds exact match', () => {
|
||||
expect(findMatchedPrefixIndex('3', sums)).toBe(0)
|
||||
expect(findMatchedPrefixIndex('7', sums)).toBe(1)
|
||||
expect(findMatchedPrefixIndex('12', sums)).toBe(2)
|
||||
it('finds exact match for intermediate prefix sum (unambiguous)', () => {
|
||||
// "7" matches prefix sum at index 1, and 7 is NOT a digit-prefix of 12
|
||||
const result = findMatchedPrefixIndex('7', sums)
|
||||
expect(result.matchedIndex).toBe(1)
|
||||
expect(result.isAmbiguous).toBe(false)
|
||||
expect(result.helpTermIndex).toBe(2) // help with term at index 2
|
||||
})
|
||||
|
||||
it('returns -1 for no match', () => {
|
||||
expect(findMatchedPrefixIndex('5', sums)).toBe(-1)
|
||||
expect(findMatchedPrefixIndex('', sums)).toBe(-1)
|
||||
it('finds final answer match', () => {
|
||||
const result = findMatchedPrefixIndex('12', sums)
|
||||
expect(result.matchedIndex).toBe(2) // final answer
|
||||
expect(result.isAmbiguous).toBe(false)
|
||||
expect(result.helpTermIndex).toBe(-1) // no help needed for final answer
|
||||
})
|
||||
|
||||
it('returns -1 for non-numeric', () => {
|
||||
expect(findMatchedPrefixIndex('abc', sums)).toBe(-1)
|
||||
it('detects ambiguous match when prefix sum is also digit-prefix of final answer', () => {
|
||||
// Problem: [2, 1, 30] -> prefix sums [2, 3, 33], final answer 33
|
||||
const ambiguousSums = [2, 3, 33]
|
||||
const result = findMatchedPrefixIndex('3', ambiguousSums)
|
||||
expect(result.matchedIndex).toBe(1) // matches prefix sum at index 1
|
||||
expect(result.isAmbiguous).toBe(true) // "3" is also digit-prefix of "33"
|
||||
expect(result.helpTermIndex).toBe(2) // help with term at index 2
|
||||
})
|
||||
|
||||
it('returns no match for input that does not match any prefix sum', () => {
|
||||
const result = findMatchedPrefixIndex('5', sums)
|
||||
expect(result.matchedIndex).toBe(-1)
|
||||
expect(result.isAmbiguous).toBe(false)
|
||||
expect(result.helpTermIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns no match for empty input', () => {
|
||||
const result = findMatchedPrefixIndex('', sums)
|
||||
expect(result.matchedIndex).toBe(-1)
|
||||
expect(result.isAmbiguous).toBe(false)
|
||||
expect(result.helpTermIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns no match for non-numeric input', () => {
|
||||
const result = findMatchedPrefixIndex('abc', sums)
|
||||
expect(result.matchedIndex).toBe(-1)
|
||||
expect(result.isAmbiguous).toBe(false)
|
||||
expect(result.helpTermIndex).toBe(-1)
|
||||
})
|
||||
|
||||
describe('leading zeros disambiguation', () => {
|
||||
// Problem: [2, 1, 30] -> prefix sums [2, 3, 33]
|
||||
const ambiguousSums = [2, 3, 33]
|
||||
|
||||
it('treats leading zero as explicit help request (not ambiguous)', () => {
|
||||
// "03" means "I want help with prefix sum 3" - unambiguous
|
||||
const result = findMatchedPrefixIndex('03', ambiguousSums)
|
||||
expect(result.matchedIndex).toBe(1) // matches prefix sum 3 at index 1
|
||||
expect(result.isAmbiguous).toBe(false) // leading zero removes ambiguity
|
||||
expect(result.helpTermIndex).toBe(2) // help with term at index 2
|
||||
})
|
||||
|
||||
it('treats single leading zero as part of a multi-digit entry', () => {
|
||||
// "02" -> numeric value 2 matches prefix sum at index 0
|
||||
const result = findMatchedPrefixIndex('02', ambiguousSums)
|
||||
expect(result.matchedIndex).toBe(0)
|
||||
expect(result.isAmbiguous).toBe(false) // leading zero = explicit help request
|
||||
expect(result.helpTermIndex).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -911,17 +989,6 @@ describe('useInteractionPhase', () => {
|
||||
})
|
||||
|
||||
describe('matchedPrefixIndex', () => {
|
||||
it('returns index when answer matches prefix sum', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.matchedPrefixIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('returns -1 when no match', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
@@ -932,6 +999,446 @@ describe('useInteractionPhase', () => {
|
||||
|
||||
expect(result.current.matchedPrefixIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns -1 when partial match (not yet complete)', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
// Use twoDigitProblem [15, 27] -> sums [15, 42]
|
||||
act(() => {
|
||||
result.current.loadProblem(twoDigitProblem, 0, 0)
|
||||
result.current.handleDigit('4') // partial match for 42
|
||||
})
|
||||
|
||||
expect(result.current.matchedPrefixIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns final answer index when answer is complete', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.handleDigit('1')
|
||||
result.current.handleDigit('2') // 12 = final answer
|
||||
})
|
||||
|
||||
expect(result.current.matchedPrefixIndex).toBe(2) // Final answer index
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Disambiguation Phase Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('awaitingDisambiguation phase', () => {
|
||||
// Problem [2, 1, 30] -> prefix sums [2, 3, 33], answer 33
|
||||
// Typing "3" is ambiguous: could be prefix sum 3 OR first digit of 33
|
||||
const ambiguousProblem = createTestProblem([2, 1, 30])
|
||||
|
||||
describe('entering awaitingDisambiguation', () => {
|
||||
it('transitions to awaitingDisambiguation when typing ambiguous prefix match', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleDigit('3') // Ambiguous: prefix sum 3 or first digit of 33
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
if (result.current.phase.phase === 'awaitingDisambiguation') {
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('3')
|
||||
expect(result.current.phase.disambiguationContext.matchedPrefixIndex).toBe(1)
|
||||
expect(result.current.phase.disambiguationContext.helpTermIndex).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('sets ambiguousHelpTermIndex when in awaitingDisambiguation', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.ambiguousHelpTermIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('sets correct UI predicates in awaitingDisambiguation', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.canAcceptInput).toBe(true)
|
||||
expect(result.current.showInputArea).toBe(true)
|
||||
expect(result.current.inputIsFocused).toBe(true)
|
||||
expect(result.current.showHelpOverlay).toBe(false) // Not in help mode yet
|
||||
expect(result.current.isTransitioning).toBe(false)
|
||||
expect(result.current.isPaused).toBe(false)
|
||||
expect(result.current.isSubmitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exiting awaitingDisambiguation via continued typing', () => {
|
||||
it('returns to inputting when typing digit that breaks ambiguity (continues final answer)', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3') // → awaitingDisambiguation
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
act(() => {
|
||||
result.current.handleDigit('3') // "33" is the final answer - no longer ambiguous
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('inputting')
|
||||
if (result.current.phase.phase === 'inputting') {
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('33')
|
||||
}
|
||||
expect(result.current.ambiguousHelpTermIndex).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exiting awaitingDisambiguation via leading zero', () => {
|
||||
it('immediately enters helpMode when typing leading zero followed by prefix sum', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
})
|
||||
|
||||
// Type "0" first (leading zero)
|
||||
act(() => {
|
||||
result.current.handleDigit('0')
|
||||
})
|
||||
|
||||
// Then type "3" - "03" is unambiguous, means "help me with prefix sum 3"
|
||||
act(() => {
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('helpMode')
|
||||
if (result.current.phase.phase === 'helpMode') {
|
||||
expect(result.current.phase.helpContext.termIndex).toBe(2) // help with term at index 2
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('') // answer cleared for help
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('exiting awaitingDisambiguation via backspace', () => {
|
||||
it('returns to inputting when backspacing', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
act(() => {
|
||||
result.current.handleBackspace()
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('inputting')
|
||||
if (result.current.phase.phase === 'inputting') {
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('exiting awaitingDisambiguation via timer', () => {
|
||||
it('automatically transitions to helpMode when timer expires', async () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
// Advance time past the disambiguation delay (4000ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(4001)
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('helpMode')
|
||||
if (result.current.phase.phase === 'helpMode') {
|
||||
expect(result.current.phase.helpContext.termIndex).toBe(2)
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('') // cleared for help
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels timer when typing continues', async () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
// Advance time but not past the delay
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
// Type another digit to break ambiguity
|
||||
act(() => {
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('inputting')
|
||||
|
||||
// Advance past original timer - should NOT transition to helpMode
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('inputting')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit from awaitingDisambiguation', () => {
|
||||
it('allows submitting from awaitingDisambiguation phase', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
act(() => {
|
||||
result.current.startSubmit()
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('submitting')
|
||||
if (result.current.phase.phase === 'submitting') {
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('3')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('enterHelpMode from awaitingDisambiguation', () => {
|
||||
it('allows entering helpMode explicitly from awaitingDisambiguation', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
act(() => {
|
||||
result.current.enterHelpMode(2)
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('helpMode')
|
||||
if (result.current.phase.phase === 'helpMode') {
|
||||
expect(result.current.phase.helpContext.termIndex).toBe(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoSubmit in awaitingDisambiguation', () => {
|
||||
it('does not auto-submit when in awaitingDisambiguation', () => {
|
||||
// Problem [2, 1, 30] -> sums [2, 3, 33], answer 33
|
||||
// Typing "3" matches prefix sum 3, but "3" is also a digit-prefix of "33"
|
||||
// This creates an ambiguous situation
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(ambiguousProblem, 0, 0)
|
||||
result.current.handleDigit('3')
|
||||
})
|
||||
|
||||
// Should be in awaitingDisambiguation phase
|
||||
expect(result.current.phase.phase).toBe('awaitingDisambiguation')
|
||||
|
||||
// shouldAutoSubmit should be false because we're in awaitingDisambiguation
|
||||
expect(result.current.shouldAutoSubmit).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Exported State Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('exported state from hook', () => {
|
||||
it('exports attempt correctly', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.handleDigit('1')
|
||||
})
|
||||
|
||||
expect(result.current.attempt).not.toBeNull()
|
||||
expect(result.current.attempt?.userAnswer).toBe('1')
|
||||
expect(result.current.attempt?.problem).toBe(simpleProblem)
|
||||
})
|
||||
|
||||
it('exports helpContext when in helpMode', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.enterHelpMode(1)
|
||||
})
|
||||
|
||||
expect(result.current.helpContext).not.toBeNull()
|
||||
expect(result.current.helpContext?.termIndex).toBe(1)
|
||||
expect(result.current.helpContext?.currentValue).toBe(3)
|
||||
expect(result.current.helpContext?.targetValue).toBe(7)
|
||||
})
|
||||
|
||||
it('exports null helpContext when not in helpMode', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
expect(result.current.helpContext).toBeNull()
|
||||
})
|
||||
|
||||
it('exports outgoingAttempt during transition', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
const nextProblem = createTestProblem([5, 6])
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.handleDigit('1')
|
||||
result.current.handleDigit('2')
|
||||
result.current.startSubmit()
|
||||
result.current.completeSubmit('correct')
|
||||
result.current.startTransition(nextProblem, 1)
|
||||
})
|
||||
|
||||
expect(result.current.outgoingAttempt).not.toBeNull()
|
||||
expect(result.current.outgoingAttempt?.userAnswer).toBe('12')
|
||||
expect(result.current.outgoingAttempt?.result).toBe('correct')
|
||||
})
|
||||
|
||||
it('exports null outgoingAttempt when not transitioning', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
expect(result.current.outgoingAttempt).toBeNull()
|
||||
})
|
||||
|
||||
it('exports isTransitioning correctly', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
const nextProblem = createTestProblem([5, 6])
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
expect(result.current.isTransitioning).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDigit('1')
|
||||
result.current.handleDigit('2')
|
||||
result.current.startSubmit()
|
||||
result.current.completeSubmit('correct')
|
||||
result.current.startTransition(nextProblem, 1)
|
||||
})
|
||||
|
||||
expect(result.current.isTransitioning).toBe(true)
|
||||
})
|
||||
|
||||
it('exports isPaused correctly', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
expect(result.current.isPaused).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.pause()
|
||||
})
|
||||
|
||||
expect(result.current.isPaused).toBe(true)
|
||||
})
|
||||
|
||||
it('exports isSubmitting correctly', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
expect(result.current.isSubmitting).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.startSubmit()
|
||||
})
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Unambiguous Prefix Sum Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('unambiguous prefix sum handling', () => {
|
||||
// Problem [3, 4, 5] -> prefix sums [3, 7, 12]
|
||||
// "7" is unambiguous - it's NOT a digit prefix of "12"
|
||||
|
||||
it('immediately transitions to helpMode for unambiguous prefix match', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.handleDigit('7') // Unambiguous prefix sum
|
||||
})
|
||||
|
||||
// Unambiguous prefix matches immediately trigger helpMode
|
||||
expect(result.current.phase.phase).toBe('helpMode')
|
||||
if (result.current.phase.phase === 'helpMode') {
|
||||
// Help is for the term AFTER the matched prefix (index 2)
|
||||
expect(result.current.phase.helpContext.termIndex).toBe(2)
|
||||
// User's answer is cleared for help mode
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('')
|
||||
}
|
||||
expect(result.current.ambiguousHelpTermIndex).toBe(-1) // Not in awaitingDisambiguation
|
||||
})
|
||||
|
||||
it('transitions to helpMode for first prefix sum when unambiguous', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
result.current.handleDigit('3') // First prefix sum, not a digit-prefix of 12
|
||||
})
|
||||
|
||||
expect(result.current.phase.phase).toBe('helpMode')
|
||||
if (result.current.phase.phase === 'helpMode') {
|
||||
// Help is for the term AFTER the first prefix sum (index 1)
|
||||
expect(result.current.phase.helpContext.termIndex).toBe(1)
|
||||
expect(result.current.phase.attempt.userAnswer).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
@@ -989,20 +1496,60 @@ describe('useInteractionPhase', () => {
|
||||
})
|
||||
|
||||
describe('help mode flow', () => {
|
||||
it('inputting → helpMode → inputting → submitting', () => {
|
||||
it('inputting → helpMode (via prefix sum) → inputting → submitting', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
// Enter prefix sum that triggers help
|
||||
// Typing an unambiguous prefix sum immediately triggers helpMode
|
||||
act(() => {
|
||||
result.current.handleDigit('3')
|
||||
result.current.handleDigit('3') // Unambiguous prefix sum
|
||||
})
|
||||
expect(result.current.matchedPrefixIndex).toBe(0)
|
||||
// For simpleProblem [3, 4, 5], "3" is an unambiguous prefix match
|
||||
// (3 is not a digit-prefix of 12), so we go straight to helpMode
|
||||
expect(result.current.phase.phase).toBe('helpMode')
|
||||
expect(result.current.showHelpOverlay).toBe(true)
|
||||
if (result.current.phase.phase === 'helpMode') {
|
||||
// Help is for term at index 1 (the NEXT term after prefix sum at index 0)
|
||||
expect(result.current.phase.helpContext.termIndex).toBe(1)
|
||||
}
|
||||
|
||||
// Enter help mode
|
||||
// Exit help mode
|
||||
act(() => {
|
||||
result.current.exitHelpMode()
|
||||
})
|
||||
expect(result.current.phase.phase).toBe('inputting')
|
||||
expect(result.current.showHelpOverlay).toBe(false)
|
||||
|
||||
// Enter final answer
|
||||
act(() => {
|
||||
result.current.handleDigit('1')
|
||||
result.current.handleDigit('2')
|
||||
})
|
||||
|
||||
// Submit
|
||||
act(() => {
|
||||
result.current.startSubmit()
|
||||
})
|
||||
expect(result.current.phase.phase).toBe('submitting')
|
||||
})
|
||||
|
||||
it('inputting → helpMode (via explicit call) → inputting → submitting', () => {
|
||||
const { result } = renderHook(() => useInteractionPhase())
|
||||
|
||||
act(() => {
|
||||
result.current.loadProblem(simpleProblem, 0, 0)
|
||||
})
|
||||
|
||||
// Type "1" (valid digit but not a prefix sum match)
|
||||
act(() => {
|
||||
result.current.handleDigit('1')
|
||||
})
|
||||
expect(result.current.phase.phase).toBe('inputting')
|
||||
|
||||
// Explicitly enter help mode
|
||||
act(() => {
|
||||
result.current.enterHelpMode(1)
|
||||
})
|
||||
|
||||
@@ -54,12 +54,30 @@ export interface OutgoingAttempt {
|
||||
result: 'correct' | 'incorrect'
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for the disambiguation phase - when user's input matches
|
||||
* an intermediate prefix sum but could also be the start of the final answer
|
||||
*/
|
||||
export interface DisambiguationContext {
|
||||
/** Index of the matched prefix sum */
|
||||
matchedPrefixIndex: number
|
||||
/** Term index to show help for if they want it */
|
||||
helpTermIndex: number
|
||||
/** When the disambiguation timer started */
|
||||
timerStartedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-paused phases (used for resumePhase type)
|
||||
*/
|
||||
export type ActivePhase =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'inputting'; attempt: AttemptInput }
|
||||
| {
|
||||
phase: 'awaitingDisambiguation'
|
||||
attempt: AttemptInput
|
||||
disambiguationContext: DisambiguationContext
|
||||
}
|
||||
| { phase: 'helpMode'; attempt: AttemptInput; helpContext: HelpContext }
|
||||
| { phase: 'submitting'; attempt: AttemptInput }
|
||||
| { phase: 'showingFeedback'; attempt: AttemptInput; result: 'correct' | 'incorrect' }
|
||||
@@ -207,7 +225,10 @@ export interface PrefixMatchResult {
|
||||
* - "3" alone is ambiguous (could be prefix sum 3 OR first digit of 33)
|
||||
* - "03" is unambiguous - user clearly wants help with prefix sum 3
|
||||
*/
|
||||
export function findMatchedPrefixIndex(userAnswer: string, prefixSums: number[]): PrefixMatchResult {
|
||||
export function findMatchedPrefixIndex(
|
||||
userAnswer: string,
|
||||
prefixSums: number[]
|
||||
): PrefixMatchResult {
|
||||
const noMatch: PrefixMatchResult = { matchedIndex: -1, isAmbiguous: false, helpTermIndex: -1 }
|
||||
|
||||
if (!userAnswer) return noMatch
|
||||
@@ -228,7 +249,9 @@ export function findMatchedPrefixIndex(userAnswer: string, prefixSums: number[])
|
||||
}
|
||||
|
||||
// Check if user's input matches an intermediate prefix sum
|
||||
const matchedIndex = prefixSums.findIndex((sum, i) => i < prefixSums.length - 1 && sum === answerNum)
|
||||
const matchedIndex = prefixSums.findIndex(
|
||||
(sum, i) => i < prefixSums.length - 1 && sum === answerNum
|
||||
)
|
||||
|
||||
if (matchedIndex === -1) return noMatch
|
||||
|
||||
@@ -284,6 +307,14 @@ export interface UseInteractionPhaseReturn {
|
||||
// Current phase
|
||||
phase: InteractionPhase
|
||||
|
||||
// Extracted state from phase (single source of truth)
|
||||
/** Current attempt being worked on (null in loading/complete phases) */
|
||||
attempt: AttemptInput | null
|
||||
/** Help context when in helpMode (null otherwise) */
|
||||
helpContext: HelpContext | null
|
||||
/** Outgoing attempt during transition animation (null otherwise) */
|
||||
outgoingAttempt: OutgoingAttempt | null
|
||||
|
||||
// Derived predicates for UI
|
||||
/** Can we accept keyboard/keypad input? */
|
||||
canAcceptInput: boolean
|
||||
@@ -297,6 +328,12 @@ export interface UseInteractionPhaseReturn {
|
||||
showFeedback: boolean
|
||||
/** Is the input box focused? */
|
||||
inputIsFocused: boolean
|
||||
/** Are we currently in the transitioning phase? */
|
||||
isTransitioning: boolean
|
||||
/** Are we currently paused? */
|
||||
isPaused: boolean
|
||||
/** Are we currently submitting? */
|
||||
isSubmitting: boolean
|
||||
|
||||
// Computed values (only valid when attempt exists)
|
||||
/** Prefix sums for current problem */
|
||||
@@ -311,10 +348,8 @@ export interface UseInteractionPhaseReturn {
|
||||
shouldAutoSubmit: boolean
|
||||
|
||||
// Ambiguous prefix state
|
||||
/** Term index to show "need help?" prompt for (-1 if not in ambiguous state) */
|
||||
/** Term index to show "need help?" prompt for (-1 if not in awaitingDisambiguation phase) */
|
||||
ambiguousHelpTermIndex: number
|
||||
/** Whether the disambiguation timer has elapsed */
|
||||
ambiguousTimerElapsed: boolean
|
||||
|
||||
// Actions
|
||||
/** Load a new problem (loading → inputting) */
|
||||
@@ -373,6 +408,7 @@ export function useInteractionPhase(
|
||||
const attempt = useMemo((): AttemptInput | null => {
|
||||
switch (phase.phase) {
|
||||
case 'inputting':
|
||||
case 'awaitingDisambiguation':
|
||||
case 'helpMode':
|
||||
case 'submitting':
|
||||
case 'showingFeedback':
|
||||
@@ -384,6 +420,7 @@ export function useInteractionPhase(
|
||||
const inner = phase.resumePhase
|
||||
if (
|
||||
inner.phase === 'inputting' ||
|
||||
inner.phase === 'awaitingDisambiguation' ||
|
||||
inner.phase === 'helpMode' ||
|
||||
inner.phase === 'submitting' ||
|
||||
inner.phase === 'showingFeedback'
|
||||
@@ -410,62 +447,62 @@ export function useInteractionPhase(
|
||||
return findMatchedPrefixIndex(attempt.userAnswer, prefixSums)
|
||||
}, [attempt, prefixSums])
|
||||
|
||||
// Shorthand for backward compatibility
|
||||
// Convenience shorthand - most callers only need the index
|
||||
const matchedPrefixIndex = prefixMatch.matchedIndex
|
||||
|
||||
// ==========================================================================
|
||||
// Ambiguous Prefix Timer
|
||||
// Disambiguation Timer (managed via phase state)
|
||||
// ==========================================================================
|
||||
|
||||
// Track when the current ambiguous match started
|
||||
const [ambiguousTimerElapsed, setAmbiguousTimerElapsed] = useState(false)
|
||||
const ambiguousTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const lastAmbiguousKeyRef = useRef<string | null>(null)
|
||||
// Timer ref for the disambiguation timeout
|
||||
const disambiguationTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Create a stable key for the current ambiguous state
|
||||
const ambiguousKey = useMemo(() => {
|
||||
if (!prefixMatch.isAmbiguous || prefixMatch.helpTermIndex === -1) return null
|
||||
// Key includes the matched sum and term index so timer resets if they change
|
||||
return `${attempt?.userAnswer}-${prefixMatch.helpTermIndex}`
|
||||
}, [prefixMatch.isAmbiguous, prefixMatch.helpTermIndex, attempt?.userAnswer])
|
||||
|
||||
// Manage the timer
|
||||
// Manage disambiguation timer based on phase
|
||||
useEffect(() => {
|
||||
// Clear existing timer
|
||||
if (ambiguousTimerRef.current) {
|
||||
clearTimeout(ambiguousTimerRef.current)
|
||||
ambiguousTimerRef.current = null
|
||||
// Clear any existing timer
|
||||
if (disambiguationTimerRef.current) {
|
||||
clearTimeout(disambiguationTimerRef.current)
|
||||
disambiguationTimerRef.current = null
|
||||
}
|
||||
|
||||
// If no ambiguous state, reset
|
||||
if (!ambiguousKey) {
|
||||
setAmbiguousTimerElapsed(false)
|
||||
lastAmbiguousKeyRef.current = null
|
||||
return
|
||||
}
|
||||
// Only run timer when in awaitingDisambiguation phase
|
||||
if (phase.phase !== 'awaitingDisambiguation') return
|
||||
|
||||
// If this is a new ambiguous state, reset and start timer
|
||||
if (ambiguousKey !== lastAmbiguousKeyRef.current) {
|
||||
setAmbiguousTimerElapsed(false)
|
||||
lastAmbiguousKeyRef.current = ambiguousKey
|
||||
const ctx = phase.disambiguationContext
|
||||
const elapsed = Date.now() - ctx.timerStartedAt
|
||||
|
||||
ambiguousTimerRef.current = setTimeout(() => {
|
||||
setAmbiguousTimerElapsed(true)
|
||||
}, AMBIGUOUS_HELP_DELAY_MS)
|
||||
if (elapsed >= AMBIGUOUS_HELP_DELAY_MS) {
|
||||
// Timer already elapsed - transition to help mode immediately
|
||||
const helpContext = computeHelpContext(phase.attempt.problem.terms, ctx.helpTermIndex)
|
||||
setPhase({ phase: 'helpMode', attempt: { ...phase.attempt, userAnswer: '' }, helpContext })
|
||||
} else {
|
||||
// Set timer for remaining time
|
||||
const remaining = AMBIGUOUS_HELP_DELAY_MS - elapsed
|
||||
disambiguationTimerRef.current = setTimeout(() => {
|
||||
setPhase((prev) => {
|
||||
if (prev.phase !== 'awaitingDisambiguation') return prev
|
||||
const helpContext = computeHelpContext(
|
||||
prev.attempt.problem.terms,
|
||||
prev.disambiguationContext.helpTermIndex
|
||||
)
|
||||
return { phase: 'helpMode', attempt: { ...prev.attempt, userAnswer: '' }, helpContext }
|
||||
})
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ambiguousTimerRef.current) {
|
||||
clearTimeout(ambiguousTimerRef.current)
|
||||
if (disambiguationTimerRef.current) {
|
||||
clearTimeout(disambiguationTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [ambiguousKey])
|
||||
}, [phase])
|
||||
|
||||
// Compute the term index to show "need help?" for
|
||||
// Derive which term to show "need help?" prompt for from the phase
|
||||
// Returns -1 when not in awaitingDisambiguation phase
|
||||
const ambiguousHelpTermIndex = useMemo(() => {
|
||||
if (!prefixMatch.isAmbiguous) return -1
|
||||
return prefixMatch.helpTermIndex
|
||||
}, [prefixMatch])
|
||||
if (phase.phase !== 'awaitingDisambiguation') return -1
|
||||
return phase.disambiguationContext.helpTermIndex
|
||||
}, [phase])
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!attempt || !attempt.userAnswer) return false
|
||||
@@ -474,6 +511,7 @@ export function useInteractionPhase(
|
||||
}, [attempt])
|
||||
|
||||
const shouldAutoSubmit = useMemo(() => {
|
||||
// Don't auto-submit in awaitingDisambiguation - the input might be a prefix sum
|
||||
if (phase.phase !== 'inputting' && phase.phase !== 'helpMode') return false
|
||||
if (!attempt || !attempt.userAnswer) return false
|
||||
if (attempt.correctionCount > MANUAL_SUBMIT_THRESHOLD) return false
|
||||
@@ -485,18 +523,53 @@ export function useInteractionPhase(
|
||||
}, [phase.phase, attempt])
|
||||
|
||||
// UI predicates
|
||||
const canAcceptInput = phase.phase === 'inputting' || phase.phase === 'helpMode'
|
||||
const canAcceptInput =
|
||||
phase.phase === 'inputting' ||
|
||||
phase.phase === 'awaitingDisambiguation' ||
|
||||
phase.phase === 'helpMode'
|
||||
|
||||
const showAsCompleted = phase.phase === 'showingFeedback'
|
||||
|
||||
const showHelpOverlay = phase.phase === 'helpMode'
|
||||
|
||||
const showInputArea =
|
||||
phase.phase === 'inputting' || phase.phase === 'helpMode' || phase.phase === 'submitting'
|
||||
phase.phase === 'inputting' ||
|
||||
phase.phase === 'awaitingDisambiguation' ||
|
||||
phase.phase === 'helpMode' ||
|
||||
phase.phase === 'submitting'
|
||||
|
||||
const showFeedback = phase.phase === 'showingFeedback' && phase.result === 'incorrect'
|
||||
|
||||
const inputIsFocused = phase.phase === 'inputting' || phase.phase === 'helpMode'
|
||||
const inputIsFocused =
|
||||
phase.phase === 'inputting' ||
|
||||
phase.phase === 'awaitingDisambiguation' ||
|
||||
phase.phase === 'helpMode'
|
||||
|
||||
const isTransitioning = phase.phase === 'transitioning'
|
||||
|
||||
const isPaused = phase.phase === 'paused'
|
||||
|
||||
const isSubmitting = phase.phase === 'submitting'
|
||||
|
||||
// Extract helpContext from phase
|
||||
const helpContext = useMemo((): HelpContext | null => {
|
||||
if (phase.phase === 'helpMode') {
|
||||
return phase.helpContext
|
||||
}
|
||||
// Also check paused phase
|
||||
if (phase.phase === 'paused' && phase.resumePhase.phase === 'helpMode') {
|
||||
return phase.resumePhase.helpContext
|
||||
}
|
||||
return null
|
||||
}, [phase])
|
||||
|
||||
// Extract outgoingAttempt from phase (only during transitions)
|
||||
const outgoingAttempt = useMemo((): OutgoingAttempt | null => {
|
||||
if (phase.phase === 'transitioning') {
|
||||
return phase.outgoing
|
||||
}
|
||||
return null
|
||||
}, [phase])
|
||||
|
||||
// ==========================================================================
|
||||
// Actions
|
||||
@@ -513,7 +586,14 @@ export function useInteractionPhase(
|
||||
const handleDigit = useCallback(
|
||||
(digit: string) => {
|
||||
setPhase((prev) => {
|
||||
if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev
|
||||
// Accept input in inputting, awaitingDisambiguation, or helpMode
|
||||
if (
|
||||
prev.phase !== 'inputting' &&
|
||||
prev.phase !== 'awaitingDisambiguation' &&
|
||||
prev.phase !== 'helpMode'
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const attempt = prev.attempt
|
||||
const sums = computePrefixSums(attempt.problem.terms)
|
||||
@@ -524,7 +604,41 @@ export function useInteractionPhase(
|
||||
userAnswer: attempt.userAnswer + digit,
|
||||
rejectedDigit: null,
|
||||
}
|
||||
return { ...prev, attempt: updatedAttempt }
|
||||
|
||||
// Check if the new answer creates an ambiguous prefix match
|
||||
const newPrefixMatch = findMatchedPrefixIndex(updatedAttempt.userAnswer, sums)
|
||||
|
||||
if (newPrefixMatch.isAmbiguous && newPrefixMatch.helpTermIndex >= 0) {
|
||||
// Ambiguous match - transition to awaitingDisambiguation
|
||||
return {
|
||||
phase: 'awaitingDisambiguation',
|
||||
attempt: updatedAttempt,
|
||||
disambiguationContext: {
|
||||
matchedPrefixIndex: newPrefixMatch.matchedIndex,
|
||||
helpTermIndex: newPrefixMatch.helpTermIndex,
|
||||
timerStartedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
} else if (
|
||||
!newPrefixMatch.isAmbiguous &&
|
||||
newPrefixMatch.matchedIndex >= 0 &&
|
||||
newPrefixMatch.helpTermIndex >= 0
|
||||
) {
|
||||
// Unambiguous intermediate prefix match (e.g., "03" for prefix sum 3)
|
||||
// Immediately enter help mode
|
||||
const helpContext = computeHelpContext(
|
||||
attempt.problem.terms,
|
||||
newPrefixMatch.helpTermIndex
|
||||
)
|
||||
return {
|
||||
phase: 'helpMode',
|
||||
attempt: { ...updatedAttempt, userAnswer: '' },
|
||||
helpContext,
|
||||
}
|
||||
} else {
|
||||
// No special prefix match - stay in inputting (or return to it from awaitingDisambiguation)
|
||||
return { phase: 'inputting', attempt: updatedAttempt }
|
||||
}
|
||||
} else {
|
||||
// Reject the digit
|
||||
const newCorrectionCount = attempt.correctionCount + 1
|
||||
@@ -541,6 +655,11 @@ export function useInteractionPhase(
|
||||
correctionCount: newCorrectionCount,
|
||||
manualSubmitRequired: attempt.manualSubmitRequired || nowRequiresManualSubmit,
|
||||
}
|
||||
|
||||
// Keep the same phase type but update attempt
|
||||
if (prev.phase === 'awaitingDisambiguation') {
|
||||
return { ...prev, attempt: updatedAttempt }
|
||||
}
|
||||
return { ...prev, attempt: updatedAttempt }
|
||||
}
|
||||
})
|
||||
@@ -548,7 +667,13 @@ export function useInteractionPhase(
|
||||
// Clear rejected digit after animation
|
||||
setTimeout(() => {
|
||||
setPhase((prev) => {
|
||||
if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev
|
||||
if (
|
||||
prev.phase !== 'inputting' &&
|
||||
prev.phase !== 'awaitingDisambiguation' &&
|
||||
prev.phase !== 'helpMode'
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return { ...prev, attempt: { ...prev.attempt, rejectedDigit: null } }
|
||||
})
|
||||
}, 300)
|
||||
@@ -558,7 +683,13 @@ export function useInteractionPhase(
|
||||
|
||||
const handleBackspace = useCallback(() => {
|
||||
setPhase((prev) => {
|
||||
if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev
|
||||
if (
|
||||
prev.phase !== 'inputting' &&
|
||||
prev.phase !== 'awaitingDisambiguation' &&
|
||||
prev.phase !== 'helpMode'
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const attempt = prev.attempt
|
||||
if (attempt.userAnswer.length === 0) return prev
|
||||
@@ -577,14 +708,22 @@ export function useInteractionPhase(
|
||||
correctionCount: newCorrectionCount,
|
||||
manualSubmitRequired: attempt.manualSubmitRequired || nowRequiresManualSubmit,
|
||||
}
|
||||
return { ...prev, attempt: updatedAttempt }
|
||||
|
||||
// After backspace, always return to inputting phase (no longer ambiguous)
|
||||
return { phase: 'inputting', attempt: updatedAttempt }
|
||||
})
|
||||
}, [onManualSubmitRequired])
|
||||
|
||||
const enterHelpMode = useCallback((termIndex: number) => {
|
||||
setPhase((prev) => {
|
||||
// Allow entering help mode from inputting or helpMode (to navigate to a different term)
|
||||
if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev
|
||||
// Allow entering help mode from inputting, awaitingDisambiguation, or helpMode (to navigate to a different term)
|
||||
if (
|
||||
prev.phase !== 'inputting' &&
|
||||
prev.phase !== 'awaitingDisambiguation' &&
|
||||
prev.phase !== 'helpMode'
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const helpContext = computeHelpContext(prev.attempt.problem.terms, termIndex)
|
||||
const updatedAttempt = { ...prev.attempt, userAnswer: '' }
|
||||
@@ -602,7 +741,14 @@ export function useInteractionPhase(
|
||||
|
||||
const startSubmit = useCallback(() => {
|
||||
setPhase((prev) => {
|
||||
if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev
|
||||
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
|
||||
if (
|
||||
prev.phase !== 'inputting' &&
|
||||
prev.phase !== 'awaitingDisambiguation' &&
|
||||
prev.phase !== 'helpMode'
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return { phase: 'submitting', attempt: prev.attempt }
|
||||
})
|
||||
}, [])
|
||||
@@ -670,19 +816,24 @@ export function useInteractionPhase(
|
||||
|
||||
return {
|
||||
phase,
|
||||
attempt,
|
||||
helpContext,
|
||||
outgoingAttempt,
|
||||
canAcceptInput,
|
||||
showAsCompleted,
|
||||
showHelpOverlay,
|
||||
showInputArea,
|
||||
showFeedback,
|
||||
inputIsFocused,
|
||||
isTransitioning,
|
||||
isPaused,
|
||||
isSubmitting,
|
||||
prefixSums,
|
||||
prefixMatch,
|
||||
matchedPrefixIndex,
|
||||
canSubmit,
|
||||
shouldAutoSubmit,
|
||||
ambiguousHelpTermIndex,
|
||||
ambiguousTimerElapsed,
|
||||
loadProblem,
|
||||
handleDigit,
|
||||
handleBackspace,
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
*/
|
||||
|
||||
export { ActiveSession } from './ActiveSession'
|
||||
export { ContinueSessionCard } from './ContinueSessionCard'
|
||||
// Hooks
|
||||
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'
|
||||
export { NumericKeypad } from './NumericKeypad'
|
||||
export { PlanReview } from './PlanReview'
|
||||
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
|
||||
export type { CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
|
||||
export { ProgressDashboard } from './ProgressDashboard'
|
||||
export { SessionSummary } from './SessionSummary'
|
||||
|
||||
@@ -401,6 +401,9 @@ export const DEFAULT_PLAN_CONFIG = {
|
||||
mastered: 7,
|
||||
practicing: 3,
|
||||
},
|
||||
|
||||
/** Session timeout in hours - sessions older than this are auto-abandoned on next access */
|
||||
sessionTimeoutHours: 24,
|
||||
} as const
|
||||
|
||||
export type PlanGenerationConfig = typeof DEFAULT_PLAN_CONFIG
|
||||
|
||||
@@ -3,10 +3,19 @@
|
||||
*
|
||||
* Provides access to curriculum position, skill mastery, and practice sessions
|
||||
* for the currently selected student (player).
|
||||
*
|
||||
* Uses React Query for data fetching and caching, enabling SSR prefetching.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import type { MasteryLevel } from '@/db/schema/player-skill-mastery'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { curriculumKeys } from '@/lib/queryKeys'
|
||||
|
||||
// Re-export query keys for consumers
|
||||
export { curriculumKeys } from '@/lib/queryKeys'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -40,324 +49,321 @@ export interface PracticeSessionData {
|
||||
completedAt: Date | null
|
||||
}
|
||||
|
||||
export interface CurriculumState {
|
||||
export interface CurriculumData {
|
||||
curriculum: CurriculumPosition | null
|
||||
skills: SkillMasteryData[]
|
||||
recentSessions: PracticeSessionData[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CurriculumActions {
|
||||
/** Refresh curriculum data from server */
|
||||
refresh: () => Promise<void>
|
||||
/** Advance to the next phase */
|
||||
advancePhase: (nextPhaseId: string, nextLevel?: number) => Promise<void>
|
||||
/** Record a skill attempt */
|
||||
recordAttempt: (skillId: string, isCorrect: boolean) => Promise<void>
|
||||
/** Record multiple skill attempts at once */
|
||||
recordAttempts: (results: Array<{ skillId: string; isCorrect: boolean }>) => Promise<void>
|
||||
/** Start a new practice session */
|
||||
startSession: (phaseId: string, visualizationMode?: boolean) => Promise<string | null>
|
||||
/** Complete the current practice session */
|
||||
completeSession: (
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
async function fetchCurriculum(playerId: string): Promise<CurriculumData> {
|
||||
const response = await api(`curriculum/${playerId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch curriculum: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
curriculum: data.curriculum,
|
||||
skills: data.skills || [],
|
||||
recentSessions: data.recentSessions || [],
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook for fetching curriculum data (useQuery version)
|
||||
* Use this when you need loading/error states
|
||||
*/
|
||||
export function usePlayerCurriculumQuery(playerId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: curriculumKeys.detail(playerId ?? ''),
|
||||
queryFn: () => fetchCurriculum(playerId!),
|
||||
enabled: !!playerId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching curriculum data (useSuspenseQuery version)
|
||||
* Use this in SSR contexts where data is prefetched
|
||||
*/
|
||||
export function usePlayerCurriculumSuspense(playerId: string) {
|
||||
return useSuspenseQuery({
|
||||
queryKey: curriculumKeys.detail(playerId),
|
||||
queryFn: () => fetchCurriculum(playerId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for curriculum mutations (advance phase, record attempts, etc.)
|
||||
*/
|
||||
export function usePlayerCurriculumMutations(playerId: string | null) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => {
|
||||
if (playerId) {
|
||||
queryClient.invalidateQueries({ queryKey: curriculumKeys.detail(playerId) })
|
||||
}
|
||||
}
|
||||
|
||||
// Advance to next phase
|
||||
const advancePhase = useMutation({
|
||||
mutationFn: async ({ nextPhaseId, nextLevel }: { nextPhaseId: string; nextLevel?: number }) => {
|
||||
if (!playerId) throw new Error('No player selected')
|
||||
|
||||
const response = await api(`curriculum/${playerId}/advance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nextPhaseId, nextLevel }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to advance phase: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
// Record a single skill attempt
|
||||
const recordAttempt = useMutation({
|
||||
mutationFn: async ({ skillId, isCorrect }: { skillId: string; isCorrect: boolean }) => {
|
||||
if (!playerId) throw new Error('No player selected')
|
||||
|
||||
const response = await api(`curriculum/${playerId}/skills`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId, isCorrect }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to record attempt: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (updatedSkill) => {
|
||||
// Optimistically update the skill in cache
|
||||
if (playerId) {
|
||||
queryClient.setQueryData<CurriculumData>(curriculumKeys.detail(playerId), (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
skills: old.skills.some((s) => s.skillId === updatedSkill.skillId)
|
||||
? old.skills.map((s) => (s.skillId === updatedSkill.skillId ? updatedSkill : s))
|
||||
: [...old.skills, updatedSkill],
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Record multiple skill attempts
|
||||
const recordAttempts = useMutation({
|
||||
mutationFn: async (results: Array<{ skillId: string; isCorrect: boolean }>) => {
|
||||
if (!playerId) throw new Error('No player selected')
|
||||
|
||||
const response = await api(`curriculum/${playerId}/skills/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ results }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to record attempts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
// Start a practice session
|
||||
const startSession = useMutation({
|
||||
mutationFn: async ({
|
||||
phaseId,
|
||||
visualizationMode = false,
|
||||
}: {
|
||||
phaseId: string
|
||||
visualizationMode?: boolean
|
||||
}) => {
|
||||
if (!playerId) throw new Error('No player selected')
|
||||
|
||||
const response = await api(`curriculum/${playerId}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phaseId, visualizationMode }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to start session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
if (playerId) {
|
||||
queryClient.setQueryData<CurriculumData>(curriculumKeys.detail(playerId), (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
recentSessions: [session, ...old.recentSessions].slice(0, 10),
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Complete a practice session
|
||||
const completeSession = useMutation({
|
||||
mutationFn: async ({
|
||||
sessionId,
|
||||
data,
|
||||
}: {
|
||||
sessionId: string
|
||||
data?: {
|
||||
problemsAttempted?: number
|
||||
problemsCorrect?: number
|
||||
skillsUsed?: string[]
|
||||
}
|
||||
}) => {
|
||||
if (!playerId) throw new Error('No player selected')
|
||||
|
||||
const response = await api(`curriculum/${playerId}/sessions/${sessionId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data || {}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to complete session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
// Update curriculum settings (worksheet preset, visualization mode)
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: async (settings: {
|
||||
worksheetPreset?: string | null
|
||||
visualizationMode?: boolean
|
||||
}) => {
|
||||
if (!playerId) throw new Error('No player selected')
|
||||
|
||||
const response = await api(`curriculum/${playerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update settings: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
if (playerId) {
|
||||
queryClient.setQueryData<CurriculumData>(curriculumKeys.detail(playerId), (old) => {
|
||||
if (!old) return old
|
||||
return { ...old, curriculum: updated }
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
advancePhase,
|
||||
recordAttempt,
|
||||
recordAttempts,
|
||||
startSession,
|
||||
completeSession,
|
||||
updateSettings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook that provides both query and mutations
|
||||
* This maintains backwards compatibility with existing code
|
||||
*/
|
||||
export function usePlayerCurriculum(playerId: string | null) {
|
||||
const query = usePlayerCurriculumQuery(playerId)
|
||||
const mutations = usePlayerCurriculumMutations(playerId)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Refresh function for backwards compatibility
|
||||
const refresh = async () => {
|
||||
if (playerId) {
|
||||
await queryClient.invalidateQueries({ queryKey: curriculumKeys.detail(playerId) })
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience wrappers for backwards compatibility
|
||||
const advancePhase = async (nextPhaseId: string, nextLevel?: number) => {
|
||||
await mutations.advancePhase.mutateAsync({ nextPhaseId, nextLevel })
|
||||
}
|
||||
|
||||
const recordAttempt = async (skillId: string, isCorrect: boolean) => {
|
||||
await mutations.recordAttempt.mutateAsync({ skillId, isCorrect })
|
||||
}
|
||||
|
||||
const recordAttempts = async (results: Array<{ skillId: string; isCorrect: boolean }>) => {
|
||||
await mutations.recordAttempts.mutateAsync(results)
|
||||
}
|
||||
|
||||
const startSession = async (
|
||||
phaseId: string,
|
||||
visualizationMode: boolean = false
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const session = await mutations.startSession.mutateAsync({ phaseId, visualizationMode })
|
||||
return session.id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const completeSession = async (
|
||||
sessionId: string,
|
||||
data?: {
|
||||
problemsAttempted?: number
|
||||
problemsCorrect?: number
|
||||
skillsUsed?: string[]
|
||||
}
|
||||
) => Promise<void>
|
||||
/** Update worksheet preset */
|
||||
updateWorksheetPreset: (preset: string | null) => Promise<void>
|
||||
/** Toggle visualization mode */
|
||||
toggleVisualizationMode: () => Promise<void>
|
||||
}
|
||||
) => {
|
||||
await mutations.completeSession.mutateAsync({ sessionId, data })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
const updateWorksheetPreset = async (preset: string | null) => {
|
||||
await mutations.updateSettings.mutateAsync({ worksheetPreset: preset })
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing a player's curriculum progress
|
||||
*
|
||||
* @param playerId - The ID of the player (student) to manage
|
||||
* @returns Curriculum state and actions
|
||||
*/
|
||||
export function usePlayerCurriculum(playerId: string | null): CurriculumState & CurriculumActions {
|
||||
const [state, setState] = useState<CurriculumState>({
|
||||
curriculum: null,
|
||||
skills: [],
|
||||
recentSessions: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Fetch curriculum data
|
||||
const fetchCurriculum = useCallback(async () => {
|
||||
if (!playerId) {
|
||||
setState({
|
||||
curriculum: null,
|
||||
skills: [],
|
||||
recentSessions: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }))
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch curriculum: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setState({
|
||||
curriculum: data.curriculum,
|
||||
skills: data.skills || [],
|
||||
recentSessions: data.recentSessions || [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
}, [playerId])
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchCurriculum()
|
||||
}, [fetchCurriculum])
|
||||
|
||||
// Advance to next phase
|
||||
const advancePhase = useCallback(
|
||||
async (nextPhaseId: string, nextLevel?: number) => {
|
||||
if (!playerId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}/advance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nextPhaseId, nextLevel }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to advance phase: ${response.statusText}`)
|
||||
}
|
||||
|
||||
await fetchCurriculum()
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
},
|
||||
[playerId, fetchCurriculum]
|
||||
)
|
||||
|
||||
// Record a single skill attempt
|
||||
const recordAttempt = useCallback(
|
||||
async (skillId: string, isCorrect: boolean) => {
|
||||
if (!playerId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}/skills`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId, isCorrect }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to record attempt: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Update local state with new skill data
|
||||
const updatedSkill = await response.json()
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
skills: prev.skills.some((s) => s.skillId === skillId)
|
||||
? prev.skills.map((s) => (s.skillId === skillId ? updatedSkill : s))
|
||||
: [...prev.skills, updatedSkill],
|
||||
}))
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
},
|
||||
[playerId]
|
||||
)
|
||||
|
||||
// Record multiple skill attempts
|
||||
const recordAttempts = useCallback(
|
||||
async (results: Array<{ skillId: string; isCorrect: boolean }>) => {
|
||||
if (!playerId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}/skills/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ results }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to record attempts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
await fetchCurriculum()
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
},
|
||||
[playerId, fetchCurriculum]
|
||||
)
|
||||
|
||||
// Start a practice session
|
||||
const startSession = useCallback(
|
||||
async (phaseId: string, visualizationMode: boolean = false): Promise<string | null> => {
|
||||
if (!playerId) return null
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phaseId, visualizationMode }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to start session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const session = await response.json()
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
recentSessions: [session, ...prev.recentSessions].slice(0, 10),
|
||||
}))
|
||||
|
||||
return session.id
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
return null
|
||||
}
|
||||
},
|
||||
[playerId]
|
||||
)
|
||||
|
||||
// Complete a practice session
|
||||
const completeSession = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
data?: {
|
||||
problemsAttempted?: number
|
||||
problemsCorrect?: number
|
||||
skillsUsed?: string[]
|
||||
}
|
||||
) => {
|
||||
if (!playerId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}/sessions/${sessionId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data || {}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to complete session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
await fetchCurriculum()
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
},
|
||||
[playerId, fetchCurriculum]
|
||||
)
|
||||
|
||||
// Update worksheet preset
|
||||
const updateWorksheetPreset = useCallback(
|
||||
async (preset: string | null) => {
|
||||
if (!playerId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worksheetPreset: preset }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update preset: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const updated = await response.json()
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
curriculum: updated,
|
||||
}))
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
},
|
||||
[playerId]
|
||||
)
|
||||
|
||||
// Toggle visualization mode
|
||||
const toggleVisualizationMode = useCallback(async () => {
|
||||
if (!playerId || !state.curriculum) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/curriculum/${playerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
visualizationMode: !state.curriculum.visualizationMode,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to toggle visualization: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const updated = await response.json()
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
curriculum: updated,
|
||||
}))
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
}, [playerId, state.curriculum])
|
||||
const toggleVisualizationMode = async () => {
|
||||
const current = query.data?.curriculum?.visualizationMode ?? false
|
||||
await mutations.updateSettings.mutateAsync({ visualizationMode: !current })
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
refresh: fetchCurriculum,
|
||||
// Data from query
|
||||
curriculum: query.data?.curriculum ?? null,
|
||||
skills: query.data?.skills ?? [],
|
||||
recentSessions: query.data?.recentSessions ?? [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error?.message ?? null,
|
||||
|
||||
// Actions
|
||||
refresh,
|
||||
advancePhase,
|
||||
recordAttempt,
|
||||
recordAttempts,
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { sessionPlanKeys } from '@/lib/queryKeys'
|
||||
|
||||
// ============================================================================
|
||||
// Query Key Factory
|
||||
// ============================================================================
|
||||
|
||||
export const sessionPlanKeys = {
|
||||
all: ['sessionPlans'] as const,
|
||||
lists: () => [...sessionPlanKeys.all, 'list'] as const,
|
||||
list: (playerId: string) => [...sessionPlanKeys.lists(), playerId] as const,
|
||||
active: (playerId: string) => [...sessionPlanKeys.all, 'active', playerId] as const,
|
||||
detail: (planId: string) => [...sessionPlanKeys.all, 'detail', planId] as const,
|
||||
}
|
||||
// Re-export query keys for consumers
|
||||
export { sessionPlanKeys } from '@/lib/queryKeys'
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
@@ -27,6 +19,17 @@ async function fetchActiveSessionPlan(playerId: string): Promise<SessionPlan | n
|
||||
return data.plan ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when trying to generate a plan but one already exists.
|
||||
* Contains the existing plan so callers can recover.
|
||||
*/
|
||||
export class ActiveSessionExistsClientError extends Error {
|
||||
constructor(public readonly existingPlan: SessionPlan) {
|
||||
super('Active session already exists')
|
||||
this.name = 'ActiveSessionExistsClientError'
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSessionPlan({
|
||||
playerId,
|
||||
durationMinutes,
|
||||
@@ -40,8 +43,18 @@ async function generateSessionPlan({
|
||||
body: JSON.stringify({ durationMinutes }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to generate session plan')
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
|
||||
// Handle 409 conflict - active session exists
|
||||
if (
|
||||
res.status === 409 &&
|
||||
errorData.code === 'ACTIVE_SESSION_EXISTS' &&
|
||||
errorData.existingPlan
|
||||
) {
|
||||
throw new ActiveSessionExistsClientError(errorData.existingPlan)
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || 'Failed to generate session plan')
|
||||
}
|
||||
const data = await res.json()
|
||||
return data.plan
|
||||
@@ -88,6 +101,16 @@ export function useActiveSessionPlan(playerId: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Fetch active session plan with Suspense (for SSR contexts)
|
||||
*/
|
||||
export function useActiveSessionPlanSuspense(playerId: string) {
|
||||
return useSuspenseQuery({
|
||||
queryKey: sessionPlanKeys.active(playerId),
|
||||
queryFn: () => fetchActiveSessionPlan(playerId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Generate a new session plan
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { playerKeys } from '@/lib/queryKeys'
|
||||
|
||||
/**
|
||||
* Query key factory for players
|
||||
*/
|
||||
export const playerKeys = {
|
||||
all: ['players'] as const,
|
||||
lists: () => [...playerKeys.all, 'list'] as const,
|
||||
list: () => [...playerKeys.lists()] as const,
|
||||
detail: (id: string) => [...playerKeys.all, 'detail', id] as const,
|
||||
}
|
||||
// Re-export query keys for consumers
|
||||
export { playerKeys } from '@/lib/queryKeys'
|
||||
|
||||
/**
|
||||
* Fetch all players for the current user
|
||||
@@ -88,6 +82,31 @@ export function useUserPlayers() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Fetch all players with Suspense (for SSR contexts)
|
||||
*/
|
||||
export function useUserPlayersSuspense() {
|
||||
return useSuspenseQuery({
|
||||
queryKey: playerKeys.list(),
|
||||
queryFn: fetchPlayers,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Fetch a single player with Suspense (for SSR contexts)
|
||||
*/
|
||||
export function usePlayerSuspense(playerId: string) {
|
||||
return useSuspenseQuery({
|
||||
queryKey: playerKeys.detail(playerId),
|
||||
queryFn: async () => {
|
||||
const res = await api(`players/${playerId}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch player')
|
||||
const data = await res.json()
|
||||
return data.player as Player
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Create a new player
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,7 @@ export {
|
||||
|
||||
// Session planning
|
||||
export {
|
||||
ActiveSessionExistsError,
|
||||
abandonSessionPlan,
|
||||
approveSessionPlan,
|
||||
completeSessionPlanEarly,
|
||||
|
||||
86
apps/web/src/lib/curriculum/problem-generator.ts
Normal file
86
apps/web/src/lib/curriculum/problem-generator.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Problem Generator - Generates problems from slot constraints
|
||||
*
|
||||
* Extracted from ActiveSession.tsx to be shared between:
|
||||
* - Server-side plan generation (session-planner.ts)
|
||||
* - Client-side fallback (ActiveSession.tsx)
|
||||
*
|
||||
* This is a pure function with no side effects, so it works in both environments.
|
||||
*/
|
||||
|
||||
import type { GeneratedProblem, ProblemConstraints } from '@/db/schema/session-plans'
|
||||
import { createBasicSkillSet, type SkillSet } from '@/types/tutorial'
|
||||
import {
|
||||
analyzeRequiredSkills,
|
||||
type ProblemConstraints as GeneratorConstraints,
|
||||
generateSingleProblem,
|
||||
} from '@/utils/problemGenerator'
|
||||
|
||||
/**
|
||||
* Generate a problem from slot constraints using the skill-based algorithm.
|
||||
*
|
||||
* @param constraints - The constraints for problem generation (skills, digit range, term count)
|
||||
* @returns A generated problem with terms, answer, and skills required
|
||||
*/
|
||||
export function generateProblemFromConstraints(constraints: ProblemConstraints): GeneratedProblem {
|
||||
const baseSkillSet = createBasicSkillSet()
|
||||
|
||||
const requiredSkills: SkillSet = {
|
||||
basic: { ...baseSkillSet.basic, ...constraints.requiredSkills?.basic },
|
||||
fiveComplements: {
|
||||
...baseSkillSet.fiveComplements,
|
||||
...constraints.requiredSkills?.fiveComplements,
|
||||
},
|
||||
tenComplements: {
|
||||
...baseSkillSet.tenComplements,
|
||||
...constraints.requiredSkills?.tenComplements,
|
||||
},
|
||||
fiveComplementsSub: {
|
||||
...baseSkillSet.fiveComplementsSub,
|
||||
...constraints.requiredSkills?.fiveComplementsSub,
|
||||
},
|
||||
tenComplementsSub: {
|
||||
...baseSkillSet.tenComplementsSub,
|
||||
...constraints.requiredSkills?.tenComplementsSub,
|
||||
},
|
||||
}
|
||||
|
||||
const maxDigits = constraints.digitRange?.max || 1
|
||||
const maxValue = 10 ** maxDigits - 1
|
||||
|
||||
const generatorConstraints: GeneratorConstraints = {
|
||||
numberRange: { min: 1, max: maxValue },
|
||||
maxTerms: constraints.termCount?.max || 5,
|
||||
problemCount: 1,
|
||||
}
|
||||
|
||||
const generatedProblem = generateSingleProblem(
|
||||
generatorConstraints,
|
||||
requiredSkills,
|
||||
constraints.targetSkills,
|
||||
constraints.forbiddenSkills
|
||||
)
|
||||
|
||||
if (generatedProblem) {
|
||||
return {
|
||||
terms: generatedProblem.terms,
|
||||
answer: generatedProblem.answer,
|
||||
skillsRequired: generatedProblem.requiredSkills,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generate a simple random problem if skill-based generation fails
|
||||
const termCount = constraints.termCount?.min || 3
|
||||
const terms: number[] = []
|
||||
for (let i = 0; i < termCount; i++) {
|
||||
terms.push(Math.floor(Math.random() * Math.min(maxValue, 9)) + 1)
|
||||
}
|
||||
const answer = terms.reduce((sum, t) => sum + t, 0)
|
||||
const skillsRequired = analyzeRequiredSkills(terms, answer)
|
||||
|
||||
return {
|
||||
terms,
|
||||
answer,
|
||||
skillsRequired,
|
||||
}
|
||||
}
|
||||
86
apps/web/src/lib/curriculum/server.ts
Normal file
86
apps/web/src/lib/curriculum/server.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Server-only data fetching for curriculum/practice pages
|
||||
*
|
||||
* These functions make direct database calls for use in Server Components,
|
||||
* avoiding the HTTP round-trip that would occur with API routes.
|
||||
*
|
||||
* Use these for SSR prefetching with React Query's HydrationBoundary.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import { getPlayer } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
|
||||
import { getActiveSessionPlan } from './session-planner'
|
||||
|
||||
// Re-export types that consumers might need
|
||||
export type { SessionPlan } from '@/db/schema/session-plans'
|
||||
export type { PlayerCurriculum } from '@/db/schema/player-curriculum'
|
||||
export type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
|
||||
export type { PracticeSession } from '@/db/schema/practice-sessions'
|
||||
export type { Player } from '@/db/schema/players'
|
||||
|
||||
/**
|
||||
* Prefetch all data needed for the practice page
|
||||
*
|
||||
* This fetches in parallel for optimal performance:
|
||||
* - Player details
|
||||
* - Active session plan
|
||||
* - Curriculum position
|
||||
* - Skill mastery records
|
||||
* - Recent practice sessions
|
||||
*/
|
||||
export async function prefetchPracticeData(playerId: string) {
|
||||
const [player, activeSession, curriculum, skills, recentSessions] = await Promise.all([
|
||||
getPlayer(playerId),
|
||||
getActiveSessionPlan(playerId),
|
||||
getPlayerCurriculum(playerId),
|
||||
getAllSkillMastery(playerId),
|
||||
getRecentSessions(playerId, 10),
|
||||
])
|
||||
|
||||
return {
|
||||
player: player ?? null,
|
||||
activeSession,
|
||||
curriculum,
|
||||
skills,
|
||||
recentSessions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all players for the current viewer (server-side)
|
||||
*
|
||||
* Uses getViewerId() to identify the current user/guest and fetches their players.
|
||||
*/
|
||||
export async function getPlayersForViewer(): Promise<Player[]> {
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get or create user record
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Create user if doesn't exist
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
// Get all players for this user
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
orderBy: (players, { desc }) => [desc(players.createdAt)],
|
||||
})
|
||||
|
||||
return players
|
||||
}
|
||||
|
||||
// Re-export the individual functions for granular prefetching
|
||||
export { getPlayer } from '@/lib/arcade/player-manager'
|
||||
export { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
|
||||
export { getActiveSessionPlan } from './session-planner'
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getPhaseDisplayInfo,
|
||||
getPhaseSkillConstraints,
|
||||
} from './definitions'
|
||||
import { generateProblemFromConstraints } from './problem-generator'
|
||||
import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
|
||||
|
||||
// ============================================================================
|
||||
@@ -49,8 +50,24 @@ export interface GenerateSessionPlanOptions {
|
||||
config?: Partial<PlanGenerationConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a player already has an active session
|
||||
*/
|
||||
export class ActiveSessionExistsError extends Error {
|
||||
code = 'ACTIVE_SESSION_EXISTS' as const
|
||||
existingSession: SessionPlan
|
||||
|
||||
constructor(existingSession: SessionPlan) {
|
||||
super('An active session already exists for this player')
|
||||
this.name = 'ActiveSessionExistsError'
|
||||
this.existingSession = existingSession
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a three-part session plan for a student
|
||||
*
|
||||
* @throws {ActiveSessionExistsError} If the player already has an active session that hasn't timed out
|
||||
*/
|
||||
export async function generateSessionPlan(
|
||||
options: GenerateSessionPlanOptions
|
||||
@@ -59,6 +76,21 @@ export async function generateSessionPlan(
|
||||
|
||||
const config = { ...DEFAULT_PLAN_CONFIG, ...configOverrides }
|
||||
|
||||
// Check for existing active session (one active session per kid rule)
|
||||
const existingActive = await getActiveSessionPlan(playerId)
|
||||
if (existingActive) {
|
||||
const sessionAgeMs = Date.now() - new Date(existingActive.createdAt).getTime()
|
||||
const timeoutMs = config.sessionTimeoutHours * 60 * 60 * 1000
|
||||
|
||||
if (sessionAgeMs > timeoutMs) {
|
||||
// Session has timed out - auto-abandon it
|
||||
await abandonSessionPlan(existingActive.id)
|
||||
} else {
|
||||
// Session is still active - throw error with session data
|
||||
throw new ActiveSessionExistsError(existingActive)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Load student state
|
||||
const curriculum = await getPlayerCurriculum(playerId)
|
||||
const skillMastery = await getAllSkillMastery(playerId)
|
||||
@@ -203,12 +235,18 @@ function buildSessionPart(
|
||||
// Shuffle to interleave purposes
|
||||
const shuffledSlots = intelligentShuffle(slots)
|
||||
|
||||
// Generate problems for each slot (persisted in DB for resume capability)
|
||||
const slotsWithProblems = shuffledSlots.map((slot) => ({
|
||||
...slot,
|
||||
problem: generateProblemFromConstraints(slot.constraints),
|
||||
}))
|
||||
|
||||
return {
|
||||
partNumber,
|
||||
type,
|
||||
format: type === 'linear' ? 'linear' : 'vertical',
|
||||
useAbacus: type === 'abacus',
|
||||
slots: shuffledSlots,
|
||||
slots: slotsWithProblems,
|
||||
estimatedMinutes: Math.round(partDurationMinutes),
|
||||
}
|
||||
}
|
||||
@@ -227,17 +265,53 @@ export async function getSessionPlan(planId: string): Promise<SessionPlan | null
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session plan has pre-generated problems for all slots
|
||||
* Old sessions created before problem pre-generation was added will fail this check
|
||||
*/
|
||||
function sessionHasPreGeneratedProblems(plan: SessionPlan): boolean {
|
||||
for (const part of plan.parts) {
|
||||
for (const slot of part.slots) {
|
||||
if (!slot.problem) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session plan for a player (in_progress status)
|
||||
* Returns the most recent in_progress session if multiple exist
|
||||
* Auto-abandons sessions that are missing pre-generated problems (legacy data)
|
||||
*/
|
||||
export async function getActiveSessionPlan(playerId: string): Promise<SessionPlan | null> {
|
||||
// Find any session that's not completed or abandoned
|
||||
// This includes: draft, approved, in_progress
|
||||
const result = await db.query.sessionPlans.findFirst({
|
||||
where: and(
|
||||
eq(schema.sessionPlans.playerId, playerId),
|
||||
eq(schema.sessionPlans.status, 'in_progress')
|
||||
inArray(schema.sessionPlans.status, ['draft', 'approved', 'in_progress'])
|
||||
),
|
||||
orderBy: (plans, { desc }) => [desc(plans.createdAt)],
|
||||
})
|
||||
return result ?? null
|
||||
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate session has pre-generated problems
|
||||
// Old sessions may not have them - auto-abandon those
|
||||
if (!sessionHasPreGeneratedProblems(result)) {
|
||||
console.warn(
|
||||
`[getActiveSessionPlan] Session ${result.id} missing pre-generated problems, auto-abandoning`
|
||||
)
|
||||
await abandonSessionPlan(result.id)
|
||||
// Recursively check for another active session
|
||||
return getActiveSessionPlan(playerId)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,16 +470,23 @@ function createSlot(
|
||||
purpose: ProblemSlot['purpose'],
|
||||
baseConstraints: ReturnType<typeof getPhaseSkillConstraints>
|
||||
): ProblemSlot {
|
||||
const constraints = {
|
||||
requiredSkills: baseConstraints.requiredSkills,
|
||||
targetSkills: baseConstraints.targetSkills,
|
||||
forbiddenSkills: baseConstraints.forbiddenSkills,
|
||||
termCount: { min: 3, max: 6 },
|
||||
digitRange: { min: 1, max: 2 },
|
||||
}
|
||||
|
||||
// Pre-generate the problem so it's persisted with the plan
|
||||
// This ensures page reloads show the same problem
|
||||
const problem = generateProblemFromConstraints(constraints)
|
||||
|
||||
return {
|
||||
index,
|
||||
purpose,
|
||||
constraints: {
|
||||
requiredSkills: baseConstraints.requiredSkills,
|
||||
targetSkills: baseConstraints.targetSkills,
|
||||
forbiddenSkills: baseConstraints.forbiddenSkills,
|
||||
termCount: { min: 3, max: 6 },
|
||||
digitRange: { min: 1, max: 2 },
|
||||
},
|
||||
constraints,
|
||||
problem,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type DefaultOptions, QueryClient } from '@tanstack/react-query'
|
||||
import { cache } from 'react'
|
||||
|
||||
const queryConfig: DefaultOptions = {
|
||||
queries: {
|
||||
@@ -21,6 +22,27 @@ export function createQueryClient() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side query client, memoized per-request via React cache().
|
||||
* Use this in Server Components for SSR data prefetching.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In a Server Component
|
||||
* const queryClient = getQueryClient()
|
||||
* await queryClient.prefetchQuery({
|
||||
* queryKey: ['user', id],
|
||||
* queryFn: () => getUser(id),
|
||||
* })
|
||||
* return (
|
||||
* <HydrationBoundary state={dehydrate(queryClient)}>
|
||||
* <ClientComponent />
|
||||
* </HydrationBoundary>
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const getQueryClient = cache(() => createQueryClient())
|
||||
|
||||
/**
|
||||
* Helper function to construct API URLs with the /api prefix.
|
||||
* Use this for consistency when making API calls.
|
||||
|
||||
29
apps/web/src/lib/queryKeys.ts
Normal file
29
apps/web/src/lib/queryKeys.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Query key factories for React Query
|
||||
*
|
||||
* These are used for both server-side prefetching and client-side queries.
|
||||
* Kept in a separate file (not 'use client') so they can be imported by server components.
|
||||
*/
|
||||
|
||||
// Player query keys
|
||||
export const playerKeys = {
|
||||
all: ['players'] as const,
|
||||
lists: () => [...playerKeys.all, 'list'] as const,
|
||||
list: () => [...playerKeys.lists()] as const,
|
||||
detail: (id: string) => [...playerKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
// Curriculum query keys
|
||||
export const curriculumKeys = {
|
||||
all: ['curriculum'] as const,
|
||||
detail: (playerId: string) => [...curriculumKeys.all, playerId] as const,
|
||||
}
|
||||
|
||||
// Session plan query keys
|
||||
export const sessionPlanKeys = {
|
||||
all: ['sessionPlans'] as const,
|
||||
lists: () => [...sessionPlanKeys.all, 'list'] as const,
|
||||
list: (playerId: string) => [...sessionPlanKeys.lists(), playerId] as const,
|
||||
active: (playerId: string) => [...sessionPlanKeys.all, 'active', playerId] as const,
|
||||
detail: (planId: string) => [...sessionPlanKeys.all, 'detail', planId] as const,
|
||||
}
|
||||
Reference in New Issue
Block a user