diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 2b088fad..688158fe 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -140,7 +140,5 @@ "ask": [] }, "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "sqlite" - ] + "enabledMcpjsonServers": ["sqlite"] } diff --git a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx new file mode 100644 index 00000000..4d70b573 --- /dev/null +++ b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx @@ -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): Promise => { + 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 ( + +
+ + + +
+
+ ) +} diff --git a/apps/web/src/app/practice/[studentId]/StudentPracticeClient.tsx b/apps/web/src/app/practice/[studentId]/StudentPracticeClient.tsx deleted file mode 100644 index 9be2b603..00000000 --- a/apps/web/src/app/practice/[studentId]/StudentPracticeClient.tsx +++ /dev/null @@ -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): Promise => { - 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 => { - // 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 => { - // 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 ( - -
-
- {/* Header - hide during practice */} - {sessionView !== 'practicing' && ( -
-

- Daily Practice -

-

- Build your soroban skills one step at a time -

-
- )} - - {/* Content based on session view (derived from data) */} - {sessionView === 'loading' && ( -
-
- Loading practice session... -
-
- )} - - {sessionView === 'continue' && currentPlan && ( - - )} - - {sessionView === 'dashboard' && ( - - )} - - {sessionView === 'reviewing' && currentPlan && ( -
- {/* Error display for session start */} - {error?.context === 'start' && ( -
-
- ⚠️ -
-
- {error.message} -
-
- {error.suggestion} -
-
-
-
- )} - -
- )} - - {sessionView === 'practicing' && currentPlan && ( - - - - )} - - {sessionView === 'summary' && currentPlan && ( - - )} -
- - {/* Manual Skill Selector Modal */} - setShowManualSkillModal(false)} - onSave={handleSaveManualSkills} - /> - - {/* Offline Session Form Modal */} - setShowOfflineSessionModal(false)} - onSubmit={handleSubmitOfflineSession} - /> -
-
- ) -} diff --git a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx index ee10ced5..2158f9b3 100644 --- a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx +++ b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx @@ -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({ - 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 ( @@ -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', })} >
{/* Header */} -
+

- Configure Practice for {playerName} -

-

- Set up your practice session -

-
- - {/* Configuration Card */} -
-

- Configure Practice Session -

+ {playerName}'s Practice + +

+ Focus: {focusDescription} +

+
- {/* Duration selector */} -
+ {/* Main Card - Duration + Preview */} +
+ {/* Duration Selector */} +
-
+
{[5, 10, 15, 20].map((mins) => ( ))}
- {/* Session structure preview */} + {/* Live Preview - Summary */}
+ {/* Problem Count - centered prominently */}
- Today's Practice Structure -
-
-
- 🧮 - - Part 1: Use abacus - -
-
- 🧠 - - Part 2: Mental math (visualization) - -
-
- 💭 - - Part 3: Mental math (linear) - -
-
-
- - {/* Error display */} - {error && ( -
- ⚠️ -
+ {estimates.totalProblems} + + problems + +
+
+ + {/* Three-Part Structure */} +
+

+ Session Structure +

+
+ {PART_INFO.map((partInfo, index) => { + const partEstimate = estimates.parts[index] + const colors = getPartTypeColors(partInfo.type, isDark) + return ( +
+ {partInfo.emoji} +
+
+ Part {index + 1}: {partInfo.label} +
+
+
+
+ {partEstimate.problems} problems +
+
~{partEstimate.minutes} min
+
+
+ ) + })} +
+
+ + {/* Problem Type Breakdown */} +
+

+ Problem Mix +

+
+ {/* Focus */} +
- {error.message} + {estimates.purposes.focus}
- {error.suggestion} + Focus +
+
+ + {/* Reinforce */} +
+
+ {estimates.purposes.reinforce} +
+
+ Reinforce +
+
+ + {/* Review */} +
+
+ {estimates.purposes.review} +
+
+ Review +
+
+ + {/* Challenge */} +
+
+ {estimates.purposes.challenge} +
+
+ Challenge
- )} +
+
- {/* Action buttons */} + {/* Error display */} + {error && (
- - +
+ ⚠️ +
+
+ {error.message} +
+
+ {error.suggestion} +
+
+
+ )} + + {/* Action Buttons */} +
+ + +
diff --git a/apps/web/src/app/practice/[studentId]/configure/page.tsx b/apps/web/src/app/practice/[studentId]/configure/page.tsx index ff0a6a4d..96e03822 100644 --- a/apps/web/src/app/practice/[studentId]/configure/page.tsx +++ b/apps/web/src/app/practice/[studentId]/configure/page.tsx @@ -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 + // 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 ( + + ) } diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx new file mode 100644 index 00000000..fbee59c7 --- /dev/null +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -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 => { + // 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 => { + // TODO: Save offline session to database via API + console.log('Offline session recorded:', data) + setShowOfflineSessionModal(false) + }, + [] + ) + + return ( + +
+
+ {/* Header */} +
+

+ Daily Practice +

+

+ Build your soroban skills one step at a time +

+
+ + {/* Progress Dashboard */} + +
+ + {/* Manual Skill Selector Modal */} + setShowManualSkillModal(false)} + onSave={handleSaveManualSkills} + /> + + {/* Offline Session Form Modal */} + setShowOfflineSessionModal(false)} + onSubmit={handleSubmitOfflineSession} + /> +
+
+ ) +} diff --git a/apps/web/src/app/practice/[studentId]/dashboard/page.tsx b/apps/web/src/app/practice/[studentId]/dashboard/page.tsx new file mode 100644 index 00000000..1075a476 --- /dev/null +++ b/apps/web/src/app/practice/[studentId]/dashboard/page.tsx @@ -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 ( + + ) +} diff --git a/apps/web/src/app/practice/[studentId]/page.tsx b/apps/web/src/app/practice/[studentId]/page.tsx index 656e5f59..d9cb9bef 100644 --- a/apps/web/src/app/practice/[studentId]/page.tsx +++ b/apps/web/src/app/practice/[studentId]/page.tsx @@ -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 ( - - ) + // 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 } diff --git a/apps/web/src/app/practice/[studentId]/placement-test/PlacementTestClient.tsx b/apps/web/src/app/practice/[studentId]/placement-test/PlacementTestClient.tsx index 55f3b314..757337ae 100644 --- a/apps/web/src/app/practice/[studentId]/placement-test/PlacementTestClient.tsx +++ b/apps/web/src/app/practice/[studentId]/placement-test/PlacementTestClient.tsx @@ -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' diff --git a/apps/web/src/app/practice/[studentId]/resume/ResumeClient.tsx b/apps/web/src/app/practice/[studentId]/resume/ResumeClient.tsx index ad59a011..41f2c123 100644 --- a/apps/web/src/app/practice/[studentId]/resume/ResumeClient.tsx +++ b/apps/web/src/app/practice/[studentId]/resume/ResumeClient.tsx @@ -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' diff --git a/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx b/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx new file mode 100644 index 00000000..a5a2a32b --- /dev/null +++ b/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx @@ -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 ( + +
+
+ {/* Header */} +
+

+ Session Complete +

+

+ Great work on your practice session! +

+
+ + {/* Session Summary */} + +
+
+
+ ) +} diff --git a/apps/web/src/app/practice/[studentId]/summary/page.tsx b/apps/web/src/app/practice/[studentId]/summary/page.tsx new file mode 100644 index 00000000..9c8c4046 --- /dev/null +++ b/apps/web/src/app/practice/[studentId]/summary/page.tsx @@ -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 +} diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 2497bcbc..6f109154 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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.
+
)} diff --git a/apps/web/src/components/practice/index.ts b/apps/web/src/components/practice/index.ts index a731b4ee..8f579a1b 100644 --- a/apps/web/src/components/practice/index.ts +++ b/apps/web/src/components/practice/index.ts @@ -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' diff --git a/apps/web/src/lib/curriculum/server.ts b/apps/web/src/lib/curriculum/server.ts index 3fe68070..bd64d734 100644 --- a/apps/web/src/lib/curriculum/server.ts +++ b/apps/web/src/lib/curriculum/server.ts @@ -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 { // 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' diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index fe7fdb16..fd7c714d 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -314,6 +314,21 @@ export async function getActiveSessionPlan(playerId: string): Promise { + 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!") */