diff --git a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx index a59598a6..a7706e81 100644 --- a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx +++ b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx @@ -1,17 +1,14 @@ 'use client' import { useRouter } from 'next/navigation' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { PageWithNav } from '@/components/PageWithNav' import { ActiveSession, - type ActiveSessionHandle, type AttemptTimingData, PracticeErrorBoundary, PracticeSubNav, type SessionHudData, - SessionPausedModal, - type PauseInfo, } from '@/components/practice' import type { Player } from '@/db/schema/players' import type { SessionHealth, SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans' @@ -39,16 +36,8 @@ interface PracticeClientProps { export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) { const router = useRouter() - // Ref to control ActiveSession's pause/resume imperatively - // This is needed because the modal is rendered here but needs to trigger - // ActiveSession's internal resume() when dismissed - const sessionRef = useRef(null) - - // Track pause state locally (controlled by callbacks from ActiveSession) - // Never auto-pause - session continues where it left off on load/reload + // Track pause state for HUD display (ActiveSession owns the modal and actual pause logic) const [isPaused, setIsPaused] = useState(false) - // Track pause info for displaying details in the modal - const [pauseInfo, setPauseInfo] = useState(undefined) // Track timing data from ActiveSession for the sub-nav HUD const [timingData, setTimingData] = useState(null) @@ -77,19 +66,13 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl return { totalProblems: total, completedProblems: completed } }, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex]) - // Pause/resume handlers - const handlePause = useCallback((info: PauseInfo) => { - setPauseInfo(info) + // Pause/resume handlers - just update HUD state (ActiveSession owns the modal) + const handlePause = useCallback(() => { setIsPaused(true) }, []) const handleResume = useCallback(() => { - // IMPORTANT: Must call sessionRef.current?.resume() to actually resume - // ActiveSession's internal state. Just setting isPaused=false only hides - // the modal but leaves input blocked. - sessionRef.current?.resume() setIsPaused(false) - setPauseInfo(undefined) }, []) // Handle recording an answer @@ -156,11 +139,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl parts: currentPlan.parts, } : undefined, - onPause: () => - handlePause({ - pausedAt: new Date(), - reason: 'manual', - }), + onPause: handlePause, onResume: handleResume, onEndEarly: () => handleEndEarly('Session ended'), } @@ -180,7 +159,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl - - {/* Session Paused Modal - shown when paused */} - handleEndEarly('Session ended by user')} - /> ) } diff --git a/apps/web/src/components/practice/ActiveSession.stories.tsx b/apps/web/src/components/practice/ActiveSession.stories.tsx index 03b63092..530b832c 100644 --- a/apps/web/src/components/practice/ActiveSession.stories.tsx +++ b/apps/web/src/components/practice/ActiveSession.stories.tsx @@ -16,7 +16,20 @@ import { generateSingleProblem, } from '@/utils/problemGenerator' import { css } from '../../../styled-system/css' -import { ActiveSession } from './ActiveSession' +import { ActiveSession, type StudentInfo } from './ActiveSession' + +/** + * Create a mock student for stories + */ +function createMockStudent(name: string): StudentInfo { + const students: Record = { + Sonia: { name: 'Sonia', emoji: '🌟', color: 'purple' }, + Marcus: { name: 'Marcus', emoji: '🚀', color: 'blue' }, + Luna: { name: 'Luna', emoji: '🌙', color: 'indigo' }, + Kai: { name: 'Kai', emoji: '🌊', color: 'cyan' }, + } + return students[name] ?? { name, emoji: '🎓', color: 'gray' } +} const meta: Meta = { title: 'Practice/ActiveSession', @@ -241,7 +254,7 @@ export const Part1Abacus: Story = { currentPartIndex: 0, currentSlotIndex: 0, })} - studentName="Sonia" + student={createMockStudent('Sonia')} {...defaultHandlers} /> @@ -257,7 +270,7 @@ export const Part2Visualization: Story = { currentPartIndex: 1, currentSlotIndex: 0, })} - studentName="Marcus" + student={createMockStudent('Marcus')} {...defaultHandlers} /> @@ -273,7 +286,7 @@ export const Part3Linear: Story = { currentPartIndex: 2, currentSlotIndex: 0, })} - studentName="Luna" + student={createMockStudent('Luna')} {...defaultHandlers} /> @@ -296,7 +309,7 @@ export const WithHealthIndicator: Story = { avgResponseTimeMs: 3500, }, })} - studentName="Sonia" + student={createMockStudent('Sonia')} {...defaultHandlers} /> @@ -319,7 +332,7 @@ export const Struggling: Story = { avgResponseTimeMs: 8500, }, })} - studentName="Kai" + student={createMockStudent('Kai')} {...defaultHandlers} /> @@ -342,7 +355,7 @@ export const Warning: Story = { avgResponseTimeMs: 5000, }, })} - studentName="Luna" + student={createMockStudent('Luna')} {...defaultHandlers} /> @@ -414,7 +427,7 @@ function InteractiveSessionDemo() { alert(`Ended: ${reason}`)} onComplete={handleComplete} @@ -444,7 +457,7 @@ export const MidSession: Story = { avgResponseTimeMs: 4200, }, })} - studentName="Sonia" + student={createMockStudent('Sonia')} {...defaultHandlers} /> diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 2f435210..90a426ee 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -13,11 +13,20 @@ import type { SlotResult, } from '@/db/schema/session-plans' -import type { AutoPauseStats, PauseInfo } from './SessionPausedModal' +import { SessionPausedModal, type AutoPauseStats, type PauseInfo } from './SessionPausedModal' // Re-export types for consumers export type { AutoPauseStats, PauseInfo } +/** + * Student info needed for the pause modal + */ +export interface StudentInfo { + name: string + emoji: string + color: string +} + // ============================================================================ // Auto-pause threshold calculation // ============================================================================ @@ -107,26 +116,17 @@ export interface AttemptTimingData { accumulatedPauseMs: number } -/** - * Imperative handle for controlling the session from parent - */ -export interface ActiveSessionHandle { - /** Resume the session (call this when external resume trigger occurs) */ - resume: () => void - /** Pause the session (call this when external pause trigger occurs) */ - pause: () => void -} - interface ActiveSessionProps { plan: SessionPlan - studentName: string + /** Student info for display in pause modal */ + student: StudentInfo /** Called when a problem is answered */ onAnswer: (result: Omit) => Promise /** Called when session is ended early */ onEndEarly: (reason?: string) => void - /** Called when session is paused (with info about why) */ + /** Called when session is paused (with info about why) - for external HUD updates */ onPause?: (pauseInfo: PauseInfo) => void - /** Called when session is resumed */ + /** Called when session is resumed - for external HUD updates */ onResume?: () => void /** Called when session completes */ onComplete: () => void @@ -134,8 +134,6 @@ interface ActiveSessionProps { hideHud?: boolean /** Called with timing data when it changes (for external timing display) */ onTimingUpdate?: (timing: AttemptTimingData | null) => void - /** Ref to get imperative handle for controlling the session */ - sessionRef?: React.MutableRefObject } /** @@ -586,7 +584,7 @@ function LinearProblem({ */ export function ActiveSession({ plan, - studentName, + student, onAnswer, onEndEarly, onPause, @@ -594,7 +592,6 @@ export function ActiveSession({ onComplete, hideHud = false, onTimingUpdate, - sessionRef, }: ActiveSessionProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -679,6 +676,12 @@ export function ActiveSession({ // Track when answer is fading out (for dream sequence) const [answerFadingOut, setAnswerFadingOut] = useState(false) + // Track pause info for displaying in the modal (single source of truth) + const [pauseInfo, setPauseInfo] = useState(undefined) + + // Track last resume time to reset auto-pause timer after resuming + const lastResumeTimeRef = useRef(0) + // Reset dismissed states when help context changes (new help session) useEffect(() => { if (helpContext) { @@ -1058,12 +1061,14 @@ export function ActiveSession({ // Calculate the threshold and stats from historical results const { threshold, stats } = calculateAutoPauseInfo(plan.results) - // Calculate remaining time until auto-pause (using actual working time, not total elapsed) - const elapsedMs = Date.now() - attempt.startTime - attempt.accumulatedPauseMs + // Calculate remaining time until auto-pause + // After resume, use the resume time as effective start (resets the auto-pause timer) + const effectiveStartTime = Math.max(attempt.startTime, lastResumeTimeRef.current) + const elapsedMs = Date.now() - effectiveStartTime const remainingMs = threshold - elapsedMs // Create pause info for auto-timeout - const pauseInfo: PauseInfo = { + const autoPauseInfo: PauseInfo = { pausedAt: new Date(), reason: 'auto-timeout', autoPauseStats: stats, @@ -1071,17 +1076,19 @@ export function ActiveSession({ // If already over threshold, pause immediately if (remainingMs <= 0) { + setPauseInfo(autoPauseInfo) pause() - onPause?.(pauseInfo) + onPause?.(autoPauseInfo) return } // Set timeout to trigger pause when threshold is reached const timeoutId = setTimeout(() => { // Update pausedAt to actual pause time - pauseInfo.pausedAt = new Date() + autoPauseInfo.pausedAt = new Date() + setPauseInfo(autoPauseInfo) pause() - onPause?.(pauseInfo) + onPause?.(autoPauseInfo) }, remainingMs) return () => clearTimeout(timeoutId) @@ -1095,39 +1102,26 @@ export function ActiveSession({ onPause, ]) - const handlePause = useCallback(() => { - const pauseInfo: PauseInfo = { - pausedAt: new Date(), - reason: 'manual', - } - pause() - onPause?.(pauseInfo) - }, [pause, onPause]) + const handlePause = useCallback( + (info?: PauseInfo) => { + const newPauseInfo: PauseInfo = info ?? { + pausedAt: new Date(), + reason: 'manual', + } + setPauseInfo(newPauseInfo) + pause() + onPause?.(newPauseInfo) + }, + [pause, onPause] + ) const handleResume = useCallback(() => { + setPauseInfo(undefined) + lastResumeTimeRef.current = Date.now() // Reset auto-pause timer resume() onResume?.() }, [resume, onResume]) - // Expose imperative handle for parent to control pause/resume - // This is needed because the modal is rendered in the parent and needs - // to trigger the internal resume() when dismissed - // IMPORTANT: We expose the raw `resume`/`pause` functions, NOT `handleResume`/`handlePause` - // which would call `onResume`/`onPause` callbacks and cause an infinite loop - useEffect(() => { - if (sessionRef) { - sessionRef.current = { - resume, - pause, - } - } - return () => { - if (sessionRef) { - sessionRef.current = null - } - } - }, [sessionRef, resume, pause]) - const getHealthColor = (health: SessionHealth['overall']) => { switch (health) { case 'good': @@ -1220,7 +1214,7 @@ export function ActiveSession({