refactor(practice): unify configure page with live session preview
- Restructure practice routes so each route represents valid state - /practice/[studentId] now ONLY shows the current problem - New /dashboard route for progress view - New /summary route with guards (can't view mid-session) - Combine configure + plan review into single unified page with: - Duration selector that updates preview in real-time - Live problem count and session structure preview - Single "Let's Go!" button that generates + starts session - Replace two-stage flow with instant feedback UX - Delete StudentPracticeClient (replaced by simpler PracticeClient) - Add getMostRecentCompletedSession for summary page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9c646acc16
commit
5ebc743b43
|
|
@ -140,7 +140,5 @@
|
|||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ActiveSession, PracticeErrorBoundary } from '@/components/practice'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
|
||||
import {
|
||||
useActiveSessionPlan,
|
||||
useEndSessionEarly,
|
||||
useRecordSlotResult,
|
||||
} from '@/hooks/useSessionPlan'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
interface PracticeClientProps {
|
||||
studentId: string
|
||||
player: Player
|
||||
initialSession: SessionPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* Practice Client Component
|
||||
*
|
||||
* This component ONLY shows the current problem.
|
||||
* It assumes the session is in_progress (server guards ensure this).
|
||||
*
|
||||
* When the session completes, it redirects to /summary.
|
||||
*/
|
||||
export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) {
|
||||
const router = useRouter()
|
||||
|
||||
// Session plan mutations
|
||||
const recordResult = useRecordSlotResult()
|
||||
const endEarly = useEndSessionEarly()
|
||||
|
||||
// Fetch active session plan from cache or API with server data as initial
|
||||
const { data: fetchedPlan } = useActiveSessionPlan(studentId, initialSession)
|
||||
|
||||
// Current plan - mutations take priority, then fetched/cached data
|
||||
const currentPlan = endEarly.data ?? recordResult.data ?? fetchedPlan ?? initialSession
|
||||
|
||||
// Handle recording an answer
|
||||
const handleAnswer = useCallback(
|
||||
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
|
||||
const updatedPlan = await recordResult.mutateAsync({
|
||||
playerId: studentId,
|
||||
planId: currentPlan.id,
|
||||
result,
|
||||
})
|
||||
|
||||
// If session just completed, redirect to summary
|
||||
if (updatedPlan.completedAt) {
|
||||
router.push(`/practice/${studentId}/summary`, { scroll: false })
|
||||
}
|
||||
},
|
||||
[studentId, currentPlan.id, recordResult, router]
|
||||
)
|
||||
|
||||
// Handle ending session early
|
||||
const handleEndEarly = useCallback(
|
||||
async (reason?: string) => {
|
||||
await endEarly.mutateAsync({
|
||||
playerId: studentId,
|
||||
planId: currentPlan.id,
|
||||
reason,
|
||||
})
|
||||
// Redirect to summary after ending early
|
||||
router.push(`/practice/${studentId}/summary`, { scroll: false })
|
||||
},
|
||||
[studentId, currentPlan.id, endEarly, router]
|
||||
)
|
||||
|
||||
// Handle session completion (called by ActiveSession when all problems done)
|
||||
const handleSessionComplete = useCallback(() => {
|
||||
// Redirect to summary
|
||||
router.push(`/practice/${studentId}/summary`, { scroll: false })
|
||||
}, [studentId, router])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
paddingTop: '80px', // Nav height only, no extra padding for practice
|
||||
})}
|
||||
>
|
||||
<PracticeErrorBoundary studentName={player.name}>
|
||||
<ActiveSession
|
||||
plan={currentPlan}
|
||||
studentName={player.name}
|
||||
onAnswer={handleAnswer}
|
||||
onEndEarly={handleEndEarly}
|
||||
onComplete={handleSessionComplete}
|
||||
/>
|
||||
</PracticeErrorBoundary>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,596 +0,0 @@
|
|||
'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,
|
||||
useActiveSessionPlan,
|
||||
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()
|
||||
|
||||
// Fetch active session plan from cache or API
|
||||
// - If cache has data (from ConfigureClient mutation): uses cache immediately
|
||||
// - If no cache but initialActiveSession exists: uses server props as initial data
|
||||
// - If neither: fetches from API (shows loading state briefly)
|
||||
const { data: fetchedPlan, isLoading: isPlanLoading } = useActiveSessionPlan(
|
||||
studentId,
|
||||
initialActiveSession
|
||||
)
|
||||
|
||||
// Current plan from mutations or fetched data (priority order)
|
||||
// Mutations take priority (most recent user action), then fetched/cached data
|
||||
const currentPlan =
|
||||
endEarly.data ??
|
||||
recordResult.data ??
|
||||
startPlan.data ??
|
||||
approvePlan.data ??
|
||||
generatePlan.data ??
|
||||
fetchedPlan ??
|
||||
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 | 'loading' = useMemo(() => {
|
||||
// Show loading only if we're fetching AND don't have any data yet
|
||||
// (mutations or initial data would give us something to show)
|
||||
if (isPlanLoading && !currentPlan) return 'loading'
|
||||
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 (draft)
|
||||
}, [currentPlan, isPlanLoading])
|
||||
|
||||
// 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 → navigate to main practice page (no ?returning)
|
||||
if (currentPlan.startedAt) {
|
||||
router.push(`/practice/${studentId}`, { scroll: false })
|
||||
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, router])
|
||||
|
||||
// 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 === 'loading' && (
|
||||
<div
|
||||
data-section="loading"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '3rem',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
Loading practice session...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,82 +2,244 @@
|
|||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { SessionPlan } from '@/db/schema/session-plans'
|
||||
import { DEFAULT_PLAN_CONFIG } from '@/db/schema/session-plans'
|
||||
import {
|
||||
ActiveSessionExistsClientError,
|
||||
sessionPlanKeys,
|
||||
useApproveSessionPlan,
|
||||
useGenerateSessionPlan,
|
||||
useStartSessionPlan,
|
||||
} from '@/hooks/useSessionPlan'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface SessionConfig {
|
||||
durationMinutes: number
|
||||
// Plan configuration constants (from DEFAULT_PLAN_CONFIG)
|
||||
const PART_TIME_WEIGHTS = DEFAULT_PLAN_CONFIG.partTimeWeights
|
||||
const PURPOSE_WEIGHTS = {
|
||||
focus: DEFAULT_PLAN_CONFIG.focusWeight,
|
||||
reinforce: DEFAULT_PLAN_CONFIG.reinforceWeight,
|
||||
review: DEFAULT_PLAN_CONFIG.reviewWeight,
|
||||
challenge: DEFAULT_PLAN_CONFIG.challengeWeight,
|
||||
}
|
||||
|
||||
interface ConfigureClientProps {
|
||||
studentId: string
|
||||
playerName: string
|
||||
/** If there's an existing draft plan, pass it here */
|
||||
existingPlan?: SessionPlan | null
|
||||
/** Current phase focus description from curriculum */
|
||||
focusDescription: string
|
||||
/** Average seconds per problem based on student's history */
|
||||
avgSecondsPerProblem: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Session structure part types with their display info
|
||||
*/
|
||||
export function ConfigureClient({ studentId, playerName }: ConfigureClientProps) {
|
||||
const PART_INFO = [
|
||||
{
|
||||
type: 'abacus' as const,
|
||||
emoji: '🧮',
|
||||
label: 'Use Abacus',
|
||||
description: 'Practice with the physical abacus',
|
||||
},
|
||||
{
|
||||
type: 'visualization' as const,
|
||||
emoji: '🧠',
|
||||
label: 'Visualization',
|
||||
description: 'Mental math by picturing beads',
|
||||
},
|
||||
{
|
||||
type: 'linear' as const,
|
||||
emoji: '💭',
|
||||
label: 'Mental Math',
|
||||
description: 'Solve problems in your head',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get part type colors (dark mode aware)
|
||||
*/
|
||||
function getPartTypeColors(
|
||||
type: 'abacus' | 'visualization' | 'linear',
|
||||
isDark: boolean
|
||||
): { bg: string; border: string; text: string } {
|
||||
switch (type) {
|
||||
case 'abacus':
|
||||
return isDark
|
||||
? { bg: 'blue.900', border: 'blue.700', text: 'blue.200' }
|
||||
: { bg: 'blue.50', border: 'blue.200', text: 'blue.700' }
|
||||
case 'visualization':
|
||||
return isDark
|
||||
? { bg: 'purple.900', border: 'purple.700', text: 'purple.200' }
|
||||
: { bg: 'purple.50', border: 'purple.200', text: 'purple.700' }
|
||||
case 'linear':
|
||||
return isDark
|
||||
? { bg: 'orange.900', border: 'orange.700', text: 'orange.200' }
|
||||
: { bg: 'orange.50', border: 'orange.200', text: 'orange.700' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated session breakdown based on duration
|
||||
*/
|
||||
function calculateEstimates(durationMinutes: number, avgSecondsPerProblem: number) {
|
||||
const totalProblems = Math.max(3, Math.floor((durationMinutes * 60) / avgSecondsPerProblem))
|
||||
|
||||
// Calculate problems per part based on weights
|
||||
const parts = [
|
||||
{
|
||||
type: 'abacus' as const,
|
||||
weight: PART_TIME_WEIGHTS.abacus,
|
||||
minutes: Math.round(durationMinutes * PART_TIME_WEIGHTS.abacus),
|
||||
problems: Math.max(2, Math.round(totalProblems * PART_TIME_WEIGHTS.abacus)),
|
||||
},
|
||||
{
|
||||
type: 'visualization' as const,
|
||||
weight: PART_TIME_WEIGHTS.visualization,
|
||||
minutes: Math.round(durationMinutes * PART_TIME_WEIGHTS.visualization),
|
||||
problems: Math.max(1, Math.round(totalProblems * PART_TIME_WEIGHTS.visualization)),
|
||||
},
|
||||
{
|
||||
type: 'linear' as const,
|
||||
weight: PART_TIME_WEIGHTS.linear,
|
||||
minutes: Math.round(durationMinutes * PART_TIME_WEIGHTS.linear),
|
||||
problems: Math.max(1, Math.round(totalProblems * PART_TIME_WEIGHTS.linear)),
|
||||
},
|
||||
]
|
||||
|
||||
// Calculate purpose breakdown
|
||||
const focusCount = Math.round(totalProblems * PURPOSE_WEIGHTS.focus)
|
||||
const reinforceCount = Math.round(totalProblems * PURPOSE_WEIGHTS.reinforce)
|
||||
const reviewCount = Math.round(totalProblems * PURPOSE_WEIGHTS.review)
|
||||
const challengeCount = Math.max(0, totalProblems - focusCount - reinforceCount - reviewCount)
|
||||
|
||||
return {
|
||||
totalProblems,
|
||||
parts,
|
||||
purposes: {
|
||||
focus: focusCount,
|
||||
reinforce: reinforceCount,
|
||||
review: reviewCount,
|
||||
challenge: challengeCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified session configuration and preview component
|
||||
*
|
||||
* Features:
|
||||
* - Duration selector that updates preview in real-time
|
||||
* - Live preview showing estimated problems, session structure, problem breakdown
|
||||
* - Single "Let's Go!" button that generates + approves + starts the session
|
||||
* - Handles existing draft plans gracefully
|
||||
*/
|
||||
export function ConfigureClient({
|
||||
studentId,
|
||||
playerName,
|
||||
existingPlan,
|
||||
focusDescription,
|
||||
avgSecondsPerProblem,
|
||||
}: ConfigureClientProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const [sessionConfig, setSessionConfig] = useState<SessionConfig>({
|
||||
durationMinutes: 10,
|
||||
})
|
||||
// Duration state - use existing plan's duration if available
|
||||
const [durationMinutes, setDurationMinutes] = useState(existingPlan?.targetDurationMinutes ?? 10)
|
||||
|
||||
// Calculate live estimates based on current duration selection
|
||||
const estimates = useMemo(
|
||||
() => calculateEstimates(durationMinutes, avgSecondsPerProblem),
|
||||
[durationMinutes, avgSecondsPerProblem]
|
||||
)
|
||||
|
||||
const generatePlan = useGenerateSessionPlan()
|
||||
const approvePlan = useApproveSessionPlan()
|
||||
const startPlan = useStartSessionPlan()
|
||||
|
||||
// Derive error state from mutation (excluding ActiveSessionExistsClientError since we handle it)
|
||||
// Combined loading state
|
||||
const isStarting = generatePlan.isPending || approvePlan.isPending || startPlan.isPending
|
||||
|
||||
// Error state
|
||||
const error =
|
||||
generatePlan.error && !(generatePlan.error instanceof ActiveSessionExistsClientError)
|
||||
(generatePlan.error && !(generatePlan.error instanceof ActiveSessionExistsClientError)) ||
|
||||
approvePlan.error ||
|
||||
startPlan.error
|
||||
? {
|
||||
message: 'Unable to create practice plan',
|
||||
suggestion:
|
||||
'This may be a temporary issue. Try selecting a different duration or refresh the page.',
|
||||
message: 'Unable to start session',
|
||||
suggestion: 'This may be a temporary issue. Try again or refresh the page.',
|
||||
}
|
||||
: null
|
||||
|
||||
const handleGeneratePlan = useCallback(() => {
|
||||
/**
|
||||
* Handle "Let's Go!" click - generates, approves, and starts the session in one flow
|
||||
*/
|
||||
const handleStart = useCallback(async () => {
|
||||
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}`, { scroll: false })
|
||||
},
|
||||
onError: (err) => {
|
||||
// If an active session already exists, use it and redirect
|
||||
approvePlan.reset()
|
||||
startPlan.reset()
|
||||
|
||||
try {
|
||||
let plan: SessionPlan
|
||||
|
||||
// If we have an existing draft plan with the same duration, use it
|
||||
if (existingPlan && existingPlan.targetDurationMinutes === durationMinutes) {
|
||||
plan = existingPlan
|
||||
} else {
|
||||
// Generate a new plan
|
||||
try {
|
||||
plan = await generatePlan.mutateAsync({
|
||||
playerId: studentId,
|
||||
durationMinutes,
|
||||
})
|
||||
} catch (err) {
|
||||
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}`, { scroll: false })
|
||||
// Use the existing plan
|
||||
plan = err.existingPlan
|
||||
queryClient.setQueryData(sessionPlanKeys.active(studentId), plan)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
}, [studentId, sessionConfig, generatePlan, router, queryClient])
|
||||
|
||||
// Approve the plan
|
||||
await approvePlan.mutateAsync({
|
||||
playerId: studentId,
|
||||
planId: plan.id,
|
||||
})
|
||||
|
||||
// Start the plan
|
||||
await startPlan.mutateAsync({
|
||||
playerId: studentId,
|
||||
planId: plan.id,
|
||||
})
|
||||
|
||||
// Redirect to practice page (shows first problem)
|
||||
router.push(`/practice/${studentId}`, { scroll: false })
|
||||
} catch {
|
||||
// Errors will show in the error state
|
||||
}
|
||||
}, [
|
||||
studentId,
|
||||
durationMinutes,
|
||||
existingPlan,
|
||||
generatePlan,
|
||||
approvePlan,
|
||||
startPlan,
|
||||
queryClient,
|
||||
router,
|
||||
])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
generatePlan.reset()
|
||||
router.push(`/practice/${studentId}`, { scroll: false })
|
||||
}, [studentId, generatePlan, router])
|
||||
router.push(`/practice/${studentId}/dashboard`, { scroll: false })
|
||||
}, [studentId, router])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
|
|
@ -87,269 +249,443 @@ export function ConfigureClient({ studentId, playerName }: ConfigureClientProps)
|
|||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: 'calc(80px + 2rem)',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
maxWidth: '500px',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<header className={css({ textAlign: 'center' })}>
|
||||
<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',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
Configure Practice Session
|
||||
</h2>
|
||||
{playerName}'s Practice
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Focus: <strong>{focusDescription}</strong>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Duration selector */}
|
||||
<div>
|
||||
{/* Main Card - Duration + Preview */}
|
||||
<div
|
||||
data-section="session-config"
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Duration Selector */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1.25rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Session Duration
|
||||
How long today?
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<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 }))}
|
||||
data-setting={`duration-${mins}`}
|
||||
onClick={() => setDurationMinutes(mins)}
|
||||
disabled={isStarting}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '1rem',
|
||||
fontSize: '1.25rem',
|
||||
padding: '0.75rem 0.5rem',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
sessionConfig.durationMinutes === mins
|
||||
? 'white'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.700',
|
||||
color: 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',
|
||||
durationMinutes === mins ? 'blue.500' : isDark ? 'gray.700' : 'gray.100',
|
||||
borderRadius: '10px',
|
||||
border: '2px solid',
|
||||
borderColor: durationMinutes === mins ? 'blue.500' : 'transparent',
|
||||
cursor: isStarting ? 'not-allowed' : 'pointer',
|
||||
opacity: isStarting ? 0.6 : 1,
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
sessionConfig.durationMinutes === mins
|
||||
? 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
durationMinutes === mins ? 'blue.600' : isDark ? 'gray.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{mins} min
|
||||
{mins}
|
||||
<span className={css({ fontSize: '0.75rem', fontWeight: 'normal' })}> min</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session structure preview */}
|
||||
{/* Live Preview - Summary */}
|
||||
<div
|
||||
data-section="session-preview"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
padding: '1.25rem',
|
||||
})}
|
||||
>
|
||||
{/* Problem Count - centered prominently */}
|
||||
<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',
|
||||
textAlign: 'center',
|
||||
marginBottom: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.75rem',
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
lineHeight: 1,
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
|
||||
<div>
|
||||
{estimates.totalProblems}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'normal',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
>
|
||||
problems
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three-Part Structure */}
|
||||
<div className={css({ marginBottom: '1.25rem' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Session Structure
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.5rem' })}>
|
||||
{PART_INFO.map((partInfo, index) => {
|
||||
const partEstimate = estimates.parts[index]
|
||||
const colors = getPartTypeColors(partInfo.type, isDark)
|
||||
return (
|
||||
<div
|
||||
key={partInfo.type}
|
||||
data-element="part-preview"
|
||||
data-part={index + 1}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: colors.bg,
|
||||
border: '1px solid',
|
||||
borderColor: colors.border,
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>{partInfo.emoji}</span>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.875rem',
|
||||
color: colors.text,
|
||||
})}
|
||||
>
|
||||
Part {index + 1}: {partInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'right',
|
||||
fontSize: '0.75rem',
|
||||
color: colors.text,
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontWeight: 'bold' })}>
|
||||
{partEstimate.problems} problems
|
||||
</div>
|
||||
<div>~{partEstimate.minutes} min</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Problem Type Breakdown */}
|
||||
<div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Problem Mix
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '0.375rem',
|
||||
})}
|
||||
>
|
||||
{/* Focus */}
|
||||
<div
|
||||
data-element="purpose-count"
|
||||
data-purpose="focus"
|
||||
className={css({
|
||||
padding: '0.5rem 0.25rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isDark ? 'blue.900' : 'blue.50',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'red.300' : 'red.700',
|
||||
marginBottom: '0.25rem',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
})}
|
||||
>
|
||||
{error.message}
|
||||
{estimates.purposes.focus}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'red.400' : 'red.600',
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
})}
|
||||
>
|
||||
{error.suggestion}
|
||||
Focus
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reinforce */}
|
||||
<div
|
||||
data-element="purpose-count"
|
||||
data-purpose="reinforce"
|
||||
className={css({
|
||||
padding: '0.5rem 0.25rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isDark ? 'orange.900' : 'orange.50',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'orange.200' : 'orange.700',
|
||||
})}
|
||||
>
|
||||
{estimates.purposes.reinforce}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'orange.300' : 'orange.600',
|
||||
})}
|
||||
>
|
||||
Reinforce
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review */}
|
||||
<div
|
||||
data-element="purpose-count"
|
||||
data-purpose="review"
|
||||
className={css({
|
||||
padding: '0.5rem 0.25rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isDark ? 'green.900' : 'green.50',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.200' : 'green.700',
|
||||
})}
|
||||
>
|
||||
{estimates.purposes.review}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'green.300' : 'green.600',
|
||||
})}
|
||||
>
|
||||
Review
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenge */}
|
||||
<div
|
||||
data-element="purpose-count"
|
||||
data-purpose="challenge"
|
||||
className={css({
|
||||
padding: '0.5rem 0.25rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isDark ? 'purple.900' : 'purple.50',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'purple.200' : 'purple.700',
|
||||
})}
|
||||
>
|
||||
{estimates.purposes.challenge}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
color: isDark ? 'purple.300' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
Challenge
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div
|
||||
data-element="error-banner"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'red.900' : 'red.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.700' : 'red.200',
|
||||
})}
|
||||
>
|
||||
<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 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',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="start-session"
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: isStarting ? 'gray.400' : 'green.500',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: isStarting ? 'not-allowed' : 'pointer',
|
||||
boxShadow: isStarting ? 'none' : '0 4px 14px rgba(34, 197, 94, 0.4)',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: { backgroundColor: isStarting ? 'gray.400' : 'green.600' },
|
||||
})}
|
||||
>
|
||||
{isStarting ? 'Starting...' : "Let's Go!"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
onClick={handleCancel}
|
||||
disabled={isStarting}
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
cursor: isStarting ? 'not-allowed' : 'pointer',
|
||||
opacity: isStarting ? 0.6 : 1,
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: { backgroundColor: isDark ? 'gray.800' : 'gray.50' },
|
||||
})}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
|
||||
import { getPhaseDisplayInfo } from '@/lib/curriculum/definitions'
|
||||
import {
|
||||
getActiveSessionPlan,
|
||||
getPlayer,
|
||||
getPlayerCurriculum,
|
||||
getRecentSessions,
|
||||
} from '@/lib/curriculum/server'
|
||||
import { ConfigureClient } from './ConfigureClient'
|
||||
|
||||
// Disable caching - must check session state fresh every time
|
||||
|
|
@ -12,18 +18,27 @@ interface ConfigurePageProps {
|
|||
/**
|
||||
* 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.
|
||||
* Shows a unified session configuration page with live preview:
|
||||
* - Duration selector that updates the preview in real-time
|
||||
* - Live preview showing estimated problems, session structure, and problem breakdown
|
||||
* - Single "Let's Go!" button that generates + starts the session
|
||||
*
|
||||
* Guards:
|
||||
* - If there's an in_progress session → redirect to /practice (show problem)
|
||||
* - If there's a completed session → allow access (start new session)
|
||||
* - If there's a draft/approved session → allow access (will be handled by client)
|
||||
*
|
||||
* 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([
|
||||
// Fetch player, curriculum, sessions, and active session in parallel
|
||||
const [player, activeSession, curriculum, recentSessions] = await Promise.all([
|
||||
getPlayer(studentId),
|
||||
getActiveSessionPlan(studentId),
|
||||
getPlayerCurriculum(studentId),
|
||||
getRecentSessions(studentId, 10),
|
||||
])
|
||||
|
||||
// 404 if player doesn't exist
|
||||
|
|
@ -31,10 +46,40 @@ export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
|||
notFound()
|
||||
}
|
||||
|
||||
// Guard: redirect if there's an active session
|
||||
if (activeSession) {
|
||||
// Guard: if there's an in_progress session, redirect to practice (show problem)
|
||||
if (activeSession?.startedAt && !activeSession.completedAt) {
|
||||
redirect(`/practice/${studentId}`)
|
||||
}
|
||||
|
||||
return <ConfigureClient studentId={studentId} playerName={player.name} />
|
||||
// Get phase display info for the focus description
|
||||
const currentPhaseId = curriculum?.currentPhaseId || 'L1.add.+1.direct'
|
||||
const phaseInfo = getPhaseDisplayInfo(currentPhaseId)
|
||||
|
||||
// Calculate average time per problem from recent sessions (or use default)
|
||||
const DEFAULT_SECONDS_PER_PROBLEM = 45
|
||||
const validSessions = recentSessions.filter(
|
||||
(s) => s.averageTimeMs !== null && s.problemsAttempted > 0
|
||||
)
|
||||
let avgSecondsPerProblem = DEFAULT_SECONDS_PER_PROBLEM
|
||||
if (validSessions.length > 0) {
|
||||
const totalProblems = validSessions.reduce((sum, s) => sum + s.problemsAttempted, 0)
|
||||
const weightedSum = validSessions.reduce(
|
||||
(sum, s) => sum + s.averageTimeMs! * s.problemsAttempted,
|
||||
0
|
||||
)
|
||||
avgSecondsPerProblem = Math.round(weightedSum / totalProblems / 1000)
|
||||
}
|
||||
|
||||
// Allow access if:
|
||||
// - No active session (start fresh)
|
||||
// - Draft/approved session exists (will be started when "Let's Go!" clicked)
|
||||
return (
|
||||
<ConfigureClient
|
||||
studentId={studentId}
|
||||
playerName={player.name}
|
||||
existingPlan={activeSession}
|
||||
focusDescription={phaseInfo.phaseName}
|
||||
avgSecondsPerProblem={avgSecondsPerProblem}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import {
|
||||
type CurrentPhaseInfo,
|
||||
ProgressDashboard,
|
||||
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 { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface DashboardClientProps {
|
||||
studentId: string
|
||||
player: Player
|
||||
curriculum: PlayerCurriculum | null
|
||||
skills: PlayerSkillMastery[]
|
||||
recentSessions: PracticeSession[]
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Client Component
|
||||
*
|
||||
* Shows the student's progress dashboard.
|
||||
* "Continue Practice" navigates to /configure to set up a new session.
|
||||
*/
|
||||
export function DashboardClient({
|
||||
studentId,
|
||||
player,
|
||||
curriculum,
|
||||
skills,
|
||||
recentSessions,
|
||||
}: DashboardClientProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// Build current phase info from curriculum
|
||||
const currentPhase = curriculum
|
||||
? getPhaseInfo(curriculum.currentPhaseId)
|
||||
: getPhaseInfo('L1.add.+1.direct')
|
||||
|
||||
// Update phase info with actual skill mastery
|
||||
if (skills.length > 0) {
|
||||
const phaseSkills = 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 recentSkillsDisplay: SkillProgress[] = 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,
|
||||
}))
|
||||
|
||||
// Handle continue practice - navigate to configuration page
|
||||
const handleContinuePractice = useCallback(() => {
|
||||
router.push(`/practice/${studentId}/configure`, { scroll: false })
|
||||
}, [studentId, router])
|
||||
|
||||
// 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)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-dashboard-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>
|
||||
|
||||
{/* Progress Dashboard */}
|
||||
<ProgressDashboard
|
||||
student={selectedStudent}
|
||||
currentPhase={currentPhase}
|
||||
recentSkills={recentSkillsDisplay}
|
||||
onContinuePractice={handleContinuePractice}
|
||||
onViewFullProgress={handleViewFullProgress}
|
||||
onGenerateWorksheet={handleGenerateWorksheet}
|
||||
onRunPlacementTest={handleRunPlacementTest}
|
||||
onSetSkillsManually={handleSetSkillsManually}
|
||||
onRecordOfflinePractice={handleRecordOfflinePractice}
|
||||
/>
|
||||
</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,71 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import {
|
||||
getActiveSessionPlan,
|
||||
getAllSkillMastery,
|
||||
getPlayer,
|
||||
getPlayerCurriculum,
|
||||
getRecentSessions,
|
||||
} from '@/lib/curriculum/server'
|
||||
import { DashboardClient } from './DashboardClient'
|
||||
|
||||
// Disable caching for this page - session state must always be fresh
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface DashboardPageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Page - Server Component
|
||||
*
|
||||
* Shows the student's progress dashboard with:
|
||||
* - Current level and progress
|
||||
* - Recent skills
|
||||
* - "Continue Practice" button to start a new session
|
||||
*
|
||||
* Guards:
|
||||
* - If there's an in_progress session → redirect to /practice/[studentId] (show problem)
|
||||
* - If there's a draft/approved session → redirect to /configure (approve and start)
|
||||
*
|
||||
* URL: /practice/[studentId]/dashboard
|
||||
*/
|
||||
export default async function DashboardPage({ params }: DashboardPageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
// Fetch player and check for active session 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()
|
||||
}
|
||||
|
||||
// Guard: redirect based on active session state
|
||||
if (activeSession) {
|
||||
if (activeSession.startedAt && !activeSession.completedAt) {
|
||||
// In progress → go to practice (show problem)
|
||||
redirect(`/practice/${studentId}`)
|
||||
}
|
||||
if (!activeSession.startedAt) {
|
||||
// Draft or approved but not started → go to configure
|
||||
redirect(`/practice/${studentId}/configure`)
|
||||
}
|
||||
// Completed sessions don't block dashboard access
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardClient
|
||||
studentId={studentId}
|
||||
player={player}
|
||||
curriculum={curriculum}
|
||||
skills={skills}
|
||||
recentSessions={recentSessions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import {
|
||||
getActiveSessionPlan,
|
||||
getAllSkillMastery,
|
||||
getPlayer,
|
||||
getPlayerCurriculum,
|
||||
getRecentSessions,
|
||||
} from '@/lib/curriculum/server'
|
||||
import { StudentPracticeClient } from './StudentPracticeClient'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
|
||||
import { PracticeClient } from './PracticeClient'
|
||||
|
||||
// Disable caching for this page - session state must always be fresh
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
|
@ -18,21 +12,24 @@ interface StudentPracticePageProps {
|
|||
/**
|
||||
* 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.
|
||||
* This page ONLY shows the current problem for active practice sessions.
|
||||
* All other states redirect to appropriate pages.
|
||||
*
|
||||
* Guards/Redirects:
|
||||
* - No active session → /dashboard (show progress, start new session)
|
||||
* - Draft/approved session (not started) → /configure (approve and start)
|
||||
* - In_progress session → SHOW PROBLEM (this is the only state we render here)
|
||||
* - Completed session → /summary (show results)
|
||||
*
|
||||
* 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([
|
||||
// Fetch player and active session in parallel
|
||||
const [player, activeSession] = await Promise.all([
|
||||
getPlayer(studentId),
|
||||
getActiveSessionPlan(studentId),
|
||||
getPlayerCurriculum(studentId),
|
||||
getAllSkillMastery(studentId),
|
||||
getRecentSessions(studentId, 10),
|
||||
])
|
||||
|
||||
// 404 if player doesn't exist
|
||||
|
|
@ -40,16 +37,21 @@ export default async function StudentPracticePage({ params }: StudentPracticePag
|
|||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<StudentPracticeClient
|
||||
studentId={studentId}
|
||||
initialPlayer={player}
|
||||
initialActiveSession={activeSession}
|
||||
initialCurriculum={{
|
||||
curriculum,
|
||||
skills,
|
||||
recentSessions,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
// No active session → dashboard
|
||||
if (!activeSession) {
|
||||
redirect(`/practice/${studentId}/dashboard`)
|
||||
}
|
||||
|
||||
// Draft or approved but not started → configure page
|
||||
if (!activeSession.startedAt) {
|
||||
redirect(`/practice/${studentId}/configure`)
|
||||
}
|
||||
|
||||
// Session is completed → summary page
|
||||
if (activeSession.completedAt) {
|
||||
redirect(`/practice/${studentId}/summary`)
|
||||
}
|
||||
|
||||
// Only state left: in_progress session → show problem
|
||||
return <PracticeClient studentId={studentId} player={player} initialSession={activeSession} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { PlacementTest } from '@/components/practice/PlacementTest'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ContinueSessionCard } from '@/components/practice'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { SessionSummary } from '@/components/practice'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import type { SessionPlan } from '@/db/schema/session-plans'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface SummaryClientProps {
|
||||
studentId: string
|
||||
player: Player
|
||||
session: SessionPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary Client Component
|
||||
*
|
||||
* Displays the session results and provides navigation options.
|
||||
*/
|
||||
export function SummaryClient({ studentId, player, session }: SummaryClientProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Handle practice again - navigate to configure page for new session
|
||||
const handlePracticeAgain = useCallback(() => {
|
||||
router.push(`/practice/${studentId}/configure`, { scroll: false })
|
||||
}, [studentId, router])
|
||||
|
||||
// Handle back to dashboard
|
||||
const handleBackToDashboard = useCallback(() => {
|
||||
router.push(`/practice/${studentId}/dashboard`, { scroll: false })
|
||||
}, [studentId, router])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-summary-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',
|
||||
})}
|
||||
>
|
||||
Session Complete
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Great work on your practice session!
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Session Summary */}
|
||||
<SessionSummary
|
||||
plan={session}
|
||||
studentName={player.name}
|
||||
onPracticeAgain={handlePracticeAgain}
|
||||
onBackToDashboard={handleBackToDashboard}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import {
|
||||
getActiveSessionPlan,
|
||||
getMostRecentCompletedSession,
|
||||
getPlayer,
|
||||
} from '@/lib/curriculum/server'
|
||||
import { SummaryClient } from './SummaryClient'
|
||||
|
||||
// Disable caching for this page - session state must always be fresh
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface SummaryPageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary Page - Server Component
|
||||
*
|
||||
* Shows the results of a completed practice session.
|
||||
*
|
||||
* Guards:
|
||||
* - If there's an in_progress session → redirect to /practice/[studentId] (can't view summary mid-session)
|
||||
* - If there's no completed session → redirect to /dashboard (nothing to show)
|
||||
*
|
||||
* URL: /practice/[studentId]/summary
|
||||
*/
|
||||
export default async function SummaryPage({ params }: SummaryPageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
// Fetch player, active session, and most recent completed session in parallel
|
||||
const [player, activeSession, completedSession] = await Promise.all([
|
||||
getPlayer(studentId),
|
||||
getActiveSessionPlan(studentId),
|
||||
getMostRecentCompletedSession(studentId),
|
||||
])
|
||||
|
||||
// 404 if player doesn't exist
|
||||
if (!player) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Guard: if there's an in_progress session, can't view summary
|
||||
if (activeSession?.startedAt && !activeSession.completedAt) {
|
||||
redirect(`/practice/${studentId}`)
|
||||
}
|
||||
|
||||
// Guard: if there's a draft/approved session, redirect to configure
|
||||
if (activeSession && !activeSession.startedAt) {
|
||||
redirect(`/practice/${studentId}/configure`)
|
||||
}
|
||||
|
||||
// Guard: if no completed session exists, redirect to dashboard
|
||||
if (!completedSession) {
|
||||
redirect(`/practice/${studentId}/dashboard`)
|
||||
}
|
||||
|
||||
return <SummaryClient studentId={studentId} player={player} session={completedSession} />
|
||||
}
|
||||
|
|
@ -1157,10 +1157,36 @@ export function ActiveSession({
|
|||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
Take a break! Tap Resume when ready.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="resume-from-overlay"
|
||||
onClick={handleResume}
|
||||
className={css({
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: 'green.500',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: 'green.400',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
▶ Resume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
* These components support the daily practice system:
|
||||
* - StudentSelector: Choose which student is practicing
|
||||
* - ProgressDashboard: Show current progress and actions
|
||||
* - PlanReview: Review and approve session plan
|
||||
* - ActiveSession: Solve problems during practice
|
||||
* - SessionSummary: Results after completing session
|
||||
*/
|
||||
|
|
@ -14,7 +13,6 @@ 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'
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ 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'
|
||||
export type { PracticeSession } from '@/db/schema/practice-sessions'
|
||||
// Re-export types that consumers might need
|
||||
export type { SessionPlan } from '@/db/schema/session-plans'
|
||||
|
||||
/**
|
||||
* Prefetch all data needed for the practice page
|
||||
|
|
@ -83,4 +83,4 @@ export async function getPlayersForViewer(): Promise<Player[]> {
|
|||
// 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'
|
||||
export { getActiveSessionPlan, getMostRecentCompletedSession } from './session-planner'
|
||||
|
|
|
|||
|
|
@ -314,6 +314,21 @@ export async function getActiveSessionPlan(playerId: string): Promise<SessionPla
|
|||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently completed session plan for a player
|
||||
* Used for the summary page after completing a session
|
||||
*/
|
||||
export async function getMostRecentCompletedSession(playerId: string): Promise<SessionPlan | null> {
|
||||
const result = await db.query.sessionPlans.findFirst({
|
||||
where: and(
|
||||
eq(schema.sessionPlans.playerId, playerId),
|
||||
eq(schema.sessionPlans.status, 'completed')
|
||||
),
|
||||
orderBy: (plans, { desc }) => [desc(plans.completedAt)],
|
||||
})
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a plan (teacher says "Let's Go!")
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue