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:
Thomas Hallock 2025-12-09 14:13:54 -06:00
parent 9c646acc16
commit 5ebc743b43
16 changed files with 1282 additions and 871 deletions

View File

@ -140,7 +140,5 @@
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
"enabledMcpjsonServers": ["sqlite"]
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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}
/>
)
}

View File

@ -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>
)
}

View File

@ -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}
/>
)
}

View File

@ -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} />
}

View File

@ -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'

View File

@ -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'

View File

@ -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>
)
}

View File

@ -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} />
}

View File

@ -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>
)}

View File

@ -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'

View File

@ -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'

View File

@ -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!")
*/