soroban-abacus-flashcards/apps/web/src/app/practice/[studentId]/StudentPracticeClient.tsx

559 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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`, { scroll: false })
}, [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`, { scroll: false })
},
}
)
}, [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`, { scroll: false })
},
}
)
} else {
router.push(`/practice/${studentId}/configure`, { scroll: false })
}
}, [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`, { scroll: false })
}, [
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`, { scroll: false })
}, [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>
)
}