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:
parent
55e5c121f1
commit
f0a9608a6b
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue