fix(practice): move SessionPausedModal into ActiveSession for single pause state

- Move SessionPausedModal inside ActiveSession component (single source of truth)
- Replace studentName prop with student: StudentInfo for modal display
- Remove sessionRef imperative handle (no longer needed)
- Fix auto-pause re-triggering immediately after resume by resetting timer
- Update PracticeClient to remove duplicate modal and pause state
- Update stories to use new student prop

Previously, there were two pause states that could get out of sync:
1. PracticeClient's isPaused (controlled modal visibility)
2. ActiveSession's internal phase.phase === 'paused' (controlled input)

Now ActiveSession owns both the modal and the pause state, eliminating the sync issue.

🤖 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-13 08:30:41 -06:00
parent 55e5c121f1
commit f0a9608a6b
4 changed files with 85 additions and 100 deletions

View File

@ -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<ActiveSessionHandle | null>(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<PauseInfo | undefined>(undefined)
// Track timing data from ActiveSession for the sub-nav HUD
const [timingData, setTimingData] = useState<AttemptTimingData | null>(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
<PracticeErrorBoundary studentName={player.name}>
<ActiveSession
plan={currentPlan}
studentName={player.name}
student={{ name: player.name, emoji: player.emoji, color: player.color }}
onAnswer={handleAnswer}
onEndEarly={handleEndEarly}
onPause={handlePause}
@ -188,20 +167,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onComplete={handleSessionComplete}
onTimingUpdate={setTimingData}
hideHud={true}
sessionRef={sessionRef}
/>
</PracticeErrorBoundary>
</main>
{/* Session Paused Modal - shown when paused */}
<SessionPausedModal
isOpen={isPaused}
student={player}
session={currentPlan}
pauseInfo={pauseInfo}
onResume={handleResume}
onEndSession={() => handleEndEarly('Session ended by user')}
/>
</PageWithNav>
)
}

View File

@ -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<string, StudentInfo> = {
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<typeof ActiveSession> = {
title: 'Practice/ActiveSession',
@ -241,7 +254,7 @@ export const Part1Abacus: Story = {
currentPartIndex: 0,
currentSlotIndex: 0,
})}
studentName="Sonia"
student={createMockStudent('Sonia')}
{...defaultHandlers}
/>
</SessionWrapper>
@ -257,7 +270,7 @@ export const Part2Visualization: Story = {
currentPartIndex: 1,
currentSlotIndex: 0,
})}
studentName="Marcus"
student={createMockStudent('Marcus')}
{...defaultHandlers}
/>
</SessionWrapper>
@ -273,7 +286,7 @@ export const Part3Linear: Story = {
currentPartIndex: 2,
currentSlotIndex: 0,
})}
studentName="Luna"
student={createMockStudent('Luna')}
{...defaultHandlers}
/>
</SessionWrapper>
@ -296,7 +309,7 @@ export const WithHealthIndicator: Story = {
avgResponseTimeMs: 3500,
},
})}
studentName="Sonia"
student={createMockStudent('Sonia')}
{...defaultHandlers}
/>
</SessionWrapper>
@ -319,7 +332,7 @@ export const Struggling: Story = {
avgResponseTimeMs: 8500,
},
})}
studentName="Kai"
student={createMockStudent('Kai')}
{...defaultHandlers}
/>
</SessionWrapper>
@ -342,7 +355,7 @@ export const Warning: Story = {
avgResponseTimeMs: 5000,
},
})}
studentName="Luna"
student={createMockStudent('Luna')}
{...defaultHandlers}
/>
</SessionWrapper>
@ -414,7 +427,7 @@ function InteractiveSessionDemo() {
<SessionWrapper>
<ActiveSession
plan={plan}
studentName="Interactive Demo"
student={createMockStudent('Interactive Demo')}
onAnswer={handleAnswer}
onEndEarly={(reason) => alert(`Ended: ${reason}`)}
onComplete={handleComplete}
@ -444,7 +457,7 @@ export const MidSession: Story = {
avgResponseTimeMs: 4200,
},
})}
studentName="Sonia"
student={createMockStudent('Sonia')}
{...defaultHandlers}
/>
</SessionWrapper>

View File

@ -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<SlotResult, 'timestamp' | 'partNumber'>) => Promise<void>
/** 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<ActiveSessionHandle | null>
}
/**
@ -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<PauseInfo | undefined>(undefined)
// Track last resume time to reset auto-pause timer after resuming
const lastResumeTimeRef = useRef<number>(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({
<button
type="button"
data-action={isPaused ? 'resume' : 'pause'}
onClick={isPaused ? handleResume : handlePause}
onClick={isPaused ? handleResume : () => handlePause()}
className={css({
width: '48px',
height: '48px',
@ -1836,6 +1830,16 @@ export function ActiveSession({
phaseName={phase.phase}
/>
)}
{/* Session Paused Modal - rendered here as single source of truth */}
<SessionPausedModal
isOpen={isPaused}
student={student}
session={plan}
pauseInfo={pauseInfo}
onResume={handleResume}
onEndSession={() => onEndEarly('Session ended by user')}
/>
</div>
)
}

View File

@ -9,7 +9,7 @@
*/
export { ActiveSession } from './ActiveSession'
export type { ActiveSessionHandle, AttemptTimingData } from './ActiveSession'
export type { AttemptTimingData, StudentInfo } from './ActiveSession'
export { ContinueSessionCard } from './ContinueSessionCard'
// Hooks
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'