feat(practice): add pause info with response time statistics to paused modal
- Add auto-pause when response time exceeds mean + 2σ (or 5min default) - Track pause reason (manual vs auto-timeout) and timing info - Display live-updating pause duration counter - Show statistical details: sample count, mean, std dev, threshold - For insufficient data, show "need X more problems for personalized timing" - Add comprehensive Storybook stories for all pause scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
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,6 +40,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
// Track pause state locally (controlled by callbacks from ActiveSession)
|
||||
// Never auto-pause - session continues where it left off on load/reload
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
// Track pause info for displaying details in the modal
|
||||
const [pauseInfo, setPauseInfo] = useState<PauseInfo | undefined>(undefined)
|
||||
|
||||
// Session plan mutations
|
||||
const recordResult = useRecordSlotResult()
|
||||
@@ -66,12 +69,14 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
}, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex])
|
||||
|
||||
// Pause/resume handlers
|
||||
const handlePause = useCallback(() => {
|
||||
const handlePause = useCallback((info: PauseInfo) => {
|
||||
setPauseInfo(info)
|
||||
setIsPaused(true)
|
||||
}, [])
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setIsPaused(false)
|
||||
setPauseInfo(undefined)
|
||||
}, [])
|
||||
|
||||
// Handle recording an answer
|
||||
@@ -129,7 +134,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
accuracy: sessionHealth.accuracy,
|
||||
}
|
||||
: undefined,
|
||||
onPause: handlePause,
|
||||
onPause: () =>
|
||||
handlePause({
|
||||
pausedAt: new Date(),
|
||||
reason: 'manual',
|
||||
}),
|
||||
onResume: handleResume,
|
||||
onEndEarly: () => handleEndEarly('Session ended'),
|
||||
}
|
||||
@@ -165,6 +174,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
||||
isOpen={isPaused}
|
||||
student={player}
|
||||
session={currentPlan}
|
||||
pauseInfo={pauseInfo}
|
||||
onResume={handleResume}
|
||||
onEndSession={() => handleEndEarly('Session ended by user')}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,11 @@ import type {
|
||||
SlotResult,
|
||||
} from '@/db/schema/session-plans'
|
||||
|
||||
import type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { AutoPauseStats, PauseInfo }
|
||||
|
||||
// ============================================================================
|
||||
// Auto-pause threshold calculation
|
||||
// ============================================================================
|
||||
@@ -48,23 +53,35 @@ function calculateResponseTimeStats(results: SlotResult[]): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the auto-pause threshold based on response time statistics.
|
||||
* Returns mean + 2 standard deviations if we have enough data,
|
||||
* otherwise returns the default timeout (5 minutes).
|
||||
* Calculate the auto-pause threshold and full stats for display.
|
||||
*/
|
||||
function calculateAutoPauseThreshold(results: SlotResult[]): number {
|
||||
const stats = calculateResponseTimeStats(results)
|
||||
function calculateAutoPauseInfo(results: SlotResult[]): {
|
||||
threshold: number
|
||||
stats: AutoPauseStats
|
||||
} {
|
||||
const { mean, stdDev, count } = calculateResponseTimeStats(results)
|
||||
const usedStatistics = count >= MIN_SAMPLES_FOR_STATISTICS
|
||||
|
||||
if (stats.count < MIN_SAMPLES_FOR_STATISTICS) {
|
||||
return DEFAULT_PAUSE_TIMEOUT_MS
|
||||
let threshold: number
|
||||
if (usedStatistics) {
|
||||
// Use mean + 2 standard deviations
|
||||
threshold = mean + 2 * stdDev
|
||||
// Clamp between 30 seconds and 5 minutes
|
||||
threshold = Math.max(30_000, Math.min(threshold, DEFAULT_PAUSE_TIMEOUT_MS))
|
||||
} else {
|
||||
threshold = DEFAULT_PAUSE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
// Use mean + 2 standard deviations
|
||||
const threshold = stats.mean + 2 * stats.stdDev
|
||||
|
||||
// Ensure threshold is at least 30 seconds (to avoid too-aggressive pausing)
|
||||
// and at most 5 minutes (reasonable upper bound)
|
||||
return Math.max(30_000, Math.min(threshold, DEFAULT_PAUSE_TIMEOUT_MS))
|
||||
return {
|
||||
threshold,
|
||||
stats: {
|
||||
meanMs: mean,
|
||||
stdDevMs: stdDev,
|
||||
thresholdMs: threshold,
|
||||
sampleCount: count,
|
||||
usedStatistics,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
import { css } from '../../../styled-system/css'
|
||||
@@ -86,8 +103,8 @@ interface ActiveSessionProps {
|
||||
onAnswer: (result: Omit<SlotResult, 'timestamp' | 'partNumber'>) => Promise<void>
|
||||
/** Called when session is ended early */
|
||||
onEndEarly: (reason?: string) => void
|
||||
/** Called when session is paused */
|
||||
onPause?: () => void
|
||||
/** Called when session is paused (with info about why) */
|
||||
onPause?: (pauseInfo: PauseInfo) => void
|
||||
/** Called when session is resumed */
|
||||
onResume?: () => void
|
||||
/** Called when session completes */
|
||||
@@ -692,35 +709,48 @@ export function ActiveSession({
|
||||
return
|
||||
}
|
||||
|
||||
// Don't auto-pause if already paused
|
||||
if (isPaused) return
|
||||
// Don't auto-pause if already paused or no attempt yet
|
||||
if (isPaused || !attempt) return
|
||||
|
||||
// Calculate the threshold from historical results
|
||||
const threshold = calculateAutoPauseThreshold(plan.results)
|
||||
// Calculate the threshold and stats from historical results
|
||||
const { threshold, stats } = calculateAutoPauseInfo(plan.results)
|
||||
|
||||
// Calculate remaining time until auto-pause
|
||||
const elapsedMs = Date.now() - attempt.startTime
|
||||
const remainingMs = threshold - elapsedMs
|
||||
|
||||
// Create pause info for auto-timeout
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(),
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: stats,
|
||||
}
|
||||
|
||||
// If already over threshold, pause immediately
|
||||
if (remainingMs <= 0) {
|
||||
pause()
|
||||
onPause?.()
|
||||
onPause?.(pauseInfo)
|
||||
return
|
||||
}
|
||||
|
||||
// Set timeout to trigger pause when threshold is reached
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Update pausedAt to actual pause time
|
||||
pauseInfo.pausedAt = new Date()
|
||||
pause()
|
||||
onPause?.()
|
||||
onPause?.(pauseInfo)
|
||||
}, remainingMs)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [phase.phase, isPaused, attempt?.startTime, plan.results, pause, onPause])
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(),
|
||||
reason: 'manual',
|
||||
}
|
||||
pause()
|
||||
onPause?.()
|
||||
onPause?.(pauseInfo)
|
||||
}, [pause, onPause])
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
|
||||
803
apps/web/src/components/practice/SessionPausedModal.stories.tsx
Normal file
803
apps/web/src/components/practice/SessionPausedModal.stories.tsx
Normal file
@@ -0,0 +1,803 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext'
|
||||
import type {
|
||||
ProblemSlot,
|
||||
SessionPart,
|
||||
SessionPlan,
|
||||
SessionSummary,
|
||||
SlotResult,
|
||||
} from '@/db/schema/session-plans'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { type PauseInfo, SessionPausedModal } from './SessionPausedModal'
|
||||
|
||||
const meta: Meta<typeof SessionPausedModal> = {
|
||||
title: 'Practice/SessionPausedModal',
|
||||
component: SessionPausedModal,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SessionPausedModal>
|
||||
|
||||
/**
|
||||
* Create mock slots for a session part
|
||||
*/
|
||||
function createMockSlots(count: number, purpose: ProblemSlot['purpose']): ProblemSlot[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
index: i,
|
||||
purpose,
|
||||
constraints: {},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock session plan at various stages of progress
|
||||
*/
|
||||
function createMockSessionPlan(config: {
|
||||
currentPartIndex: number
|
||||
currentSlotIndex: number
|
||||
completedCount: number
|
||||
}): SessionPlan {
|
||||
const { currentPartIndex, currentSlotIndex, completedCount } = config
|
||||
|
||||
const parts: SessionPart[] = [
|
||||
{
|
||||
partNumber: 1,
|
||||
type: 'abacus',
|
||||
format: 'vertical',
|
||||
useAbacus: true,
|
||||
slots: createMockSlots(5, 'focus'),
|
||||
estimatedMinutes: 5,
|
||||
},
|
||||
{
|
||||
partNumber: 2,
|
||||
type: 'visualization',
|
||||
format: 'vertical',
|
||||
useAbacus: false,
|
||||
slots: createMockSlots(5, 'reinforce'),
|
||||
estimatedMinutes: 4,
|
||||
},
|
||||
{
|
||||
partNumber: 3,
|
||||
type: 'linear',
|
||||
format: 'linear',
|
||||
useAbacus: false,
|
||||
slots: createMockSlots(5, 'review'),
|
||||
estimatedMinutes: 3,
|
||||
},
|
||||
]
|
||||
|
||||
const summary: SessionSummary = {
|
||||
focusDescription: 'Basic Addition',
|
||||
totalProblemCount: 15,
|
||||
estimatedMinutes: 12,
|
||||
parts: parts.map((p) => ({
|
||||
partNumber: p.partNumber,
|
||||
type: p.type,
|
||||
description:
|
||||
p.type === 'abacus'
|
||||
? 'Use Abacus'
|
||||
: p.type === 'visualization'
|
||||
? 'Mental Math (Visualization)'
|
||||
: 'Mental Math (Linear)',
|
||||
problemCount: p.slots.length,
|
||||
estimatedMinutes: p.estimatedMinutes,
|
||||
})),
|
||||
}
|
||||
|
||||
// Generate mock results for completed problems
|
||||
const results: SlotResult[] = Array.from({ length: completedCount }, (_, i) => ({
|
||||
partNumber: (i < 5 ? 1 : i < 10 ? 2 : 3) as 1 | 2 | 3,
|
||||
slotIndex: i % 5,
|
||||
problem: {
|
||||
terms: [3, 4, 2],
|
||||
answer: 9,
|
||||
skillsRequired: ['basic.directAddition'],
|
||||
},
|
||||
studentAnswer: 9,
|
||||
isCorrect: true,
|
||||
responseTimeMs: 3000 + Math.random() * 2000,
|
||||
skillsExercised: ['basic.directAddition'],
|
||||
usedOnScreenAbacus: i < 5,
|
||||
timestamp: new Date(Date.now() - (completedCount - i) * 30000),
|
||||
helpLevelUsed: 0,
|
||||
incorrectAttempts: 0,
|
||||
}))
|
||||
|
||||
return {
|
||||
id: 'plan-123',
|
||||
playerId: 'player-1',
|
||||
targetDurationMinutes: 12,
|
||||
estimatedProblemCount: 15,
|
||||
avgTimePerProblemSeconds: 45,
|
||||
parts,
|
||||
summary,
|
||||
status: 'in_progress',
|
||||
currentPartIndex,
|
||||
currentSlotIndex,
|
||||
sessionHealth: {
|
||||
overall: 'good',
|
||||
accuracy: 0.85,
|
||||
pacePercent: 100,
|
||||
currentStreak: 3,
|
||||
avgResponseTimeMs: 3500,
|
||||
},
|
||||
adjustments: [],
|
||||
results,
|
||||
createdAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
approvedAt: new Date(Date.now() - 14 * 60 * 1000),
|
||||
startedAt: new Date(Date.now() - 10 * 60 * 1000),
|
||||
completedAt: null,
|
||||
masteredSkillIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const mockStudent = {
|
||||
name: 'Sonia',
|
||||
emoji: '🦄',
|
||||
color: '#E879F9',
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
onResume: () => alert('Resume clicked!'),
|
||||
onEndSession: () => alert('End Session clicked!'),
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for consistent styling
|
||||
*/
|
||||
function ModalWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'gray.100',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Manual Pause Stories
|
||||
// =============================================================================
|
||||
|
||||
export const ManualPause: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 30 * 1000), // 30 seconds ago
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 3,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ManualPauseLong: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 7,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auto-Pause with Statistics Stories
|
||||
// =============================================================================
|
||||
|
||||
export const AutoPauseWithStatistics: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 15 * 1000), // 15 seconds ago
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 4200,
|
||||
stdDevMs: 1800,
|
||||
thresholdMs: 7800, // mean + 2*stdDev = 4200 + 3600 = 7800
|
||||
sampleCount: 8,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 8,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AutoPauseHighVariance: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 45 * 1000), // 45 seconds ago
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 5500,
|
||||
stdDevMs: 4200, // High variance
|
||||
thresholdMs: 13900,
|
||||
sampleCount: 12,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Marcus', emoji: '🚀', color: '#60A5FA' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 2,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 12,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AutoPauseFastStudent: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 10 * 1000), // 10 seconds ago
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 2100, // Very fast student
|
||||
stdDevMs: 600, // Consistent
|
||||
thresholdMs: 30000, // Clamped to minimum 30s
|
||||
sampleCount: 15,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Luna', emoji: '⚡', color: '#FBBF24' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 2,
|
||||
currentSlotIndex: 4,
|
||||
completedCount: 14,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auto-Pause without Statistics (Default Timeout)
|
||||
// =============================================================================
|
||||
|
||||
export const AutoPauseDefaultTimeout: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 20 * 1000), // 20 seconds ago
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 3500,
|
||||
stdDevMs: 1500,
|
||||
thresholdMs: 300000, // 5 minute default
|
||||
sampleCount: 3, // Not enough for statistics
|
||||
usedStatistics: false,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 3,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AutoPauseNeedsTwoMoreProblems: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 5 * 1000), // 5 seconds ago
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 4000,
|
||||
stdDevMs: 2000,
|
||||
thresholdMs: 300000, // 5 minute default
|
||||
sampleCount: 3, // Need 5-3=2 more
|
||||
usedStatistics: false,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Kai', emoji: '🌟', color: '#34D399' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 3,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AutoPauseNeedsOneMoreProblem: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 8 * 1000), // 8 seconds ago
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 3200,
|
||||
stdDevMs: 1100,
|
||||
thresholdMs: 300000, // 5 minute default
|
||||
sampleCount: 4, // Need 5-4=1 more
|
||||
usedStatistics: false,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Nova', emoji: '✨', color: '#F472B6' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 4,
|
||||
completedCount: 4,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Progress State Stories
|
||||
// =============================================================================
|
||||
|
||||
export const EarlyInSession: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 15 * 1000),
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 1,
|
||||
completedCount: 1,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const MidSession: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 30 * 1000),
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 7,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const NearEnd: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 20 * 1000),
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 2,
|
||||
currentSlotIndex: 4,
|
||||
completedCount: 14,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Different Part Types
|
||||
// =============================================================================
|
||||
|
||||
export const InAbacusPart: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 25 * 1000),
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 5000,
|
||||
stdDevMs: 2000,
|
||||
thresholdMs: 9000,
|
||||
sampleCount: 6,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Alex', emoji: '🧮', color: '#818CF8' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 2,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const InVisualizationPart: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 40 * 1000),
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 4500,
|
||||
stdDevMs: 1500,
|
||||
thresholdMs: 7500,
|
||||
sampleCount: 8,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Maya', emoji: '🧠', color: '#FB923C' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 8,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const InLinearPart: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 55 * 1000),
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 3200,
|
||||
stdDevMs: 900,
|
||||
thresholdMs: 5000,
|
||||
sampleCount: 11,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'River', emoji: '💭', color: '#2DD4BF' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 2,
|
||||
currentSlotIndex: 1,
|
||||
completedCount: 11,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Long Pause Durations
|
||||
// =============================================================================
|
||||
|
||||
export const LongPause: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 5 * 60 * 1000), // 5 minutes ago
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 0,
|
||||
completedCount: 5,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const VeryLongPause: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 25 * 60 * 1000), // 25 minutes ago
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 2,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const HourLongPause: Story = {
|
||||
render: () => {
|
||||
const pauseInfo: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 72 * 60 * 1000), // 1h 12m ago
|
||||
reason: 'manual',
|
||||
}
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Sleepy', emoji: '😴', color: '#94A3B8' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 1,
|
||||
completedCount: 6,
|
||||
})}
|
||||
pauseInfo={pauseInfo}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Legacy (No Pause Info)
|
||||
// =============================================================================
|
||||
|
||||
export const NoPauseInfo: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={mockStudent}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 7,
|
||||
})}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// All Cases Comparison
|
||||
// =============================================================================
|
||||
|
||||
export const AllPauseTypes: Story = {
|
||||
render: () => {
|
||||
const manualPause: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 30 * 1000),
|
||||
reason: 'manual',
|
||||
}
|
||||
|
||||
const autoWithStats: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 15 * 1000),
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 4200,
|
||||
stdDevMs: 1800,
|
||||
thresholdMs: 7800,
|
||||
sampleCount: 8,
|
||||
usedStatistics: true,
|
||||
},
|
||||
}
|
||||
|
||||
const autoWithoutStats: PauseInfo = {
|
||||
pausedAt: new Date(Date.now() - 20 * 1000),
|
||||
reason: 'auto-timeout',
|
||||
autoPauseStats: {
|
||||
meanMs: 3500,
|
||||
stdDevMs: 1500,
|
||||
thresholdMs: 300000,
|
||||
sampleCount: 3,
|
||||
usedStatistics: false,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2rem' })}>
|
||||
<div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
padding: '0 2rem',
|
||||
})}
|
||||
>
|
||||
Manual Pause
|
||||
</h3>
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Manual', emoji: '✋', color: '#60A5FA' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 2,
|
||||
completedCount: 7,
|
||||
})}
|
||||
pauseInfo={manualPause}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
padding: '0 2rem',
|
||||
})}
|
||||
>
|
||||
Auto-Pause with Statistics
|
||||
</h3>
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Stats', emoji: '📊', color: '#34D399' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 1,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 8,
|
||||
})}
|
||||
pauseInfo={autoWithStats}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
padding: '0 2rem',
|
||||
})}
|
||||
>
|
||||
Auto-Pause (Default Timeout)
|
||||
</h3>
|
||||
<ModalWrapper>
|
||||
<SessionPausedModal
|
||||
isOpen={true}
|
||||
student={{ name: 'Default', emoji: '⏱️', color: '#FBBF24' }}
|
||||
session={createMockSessionPlan({
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: 3,
|
||||
completedCount: 3,
|
||||
})}
|
||||
pauseInfo={autoWithoutStats}
|
||||
{...handlers}
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,9 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { SessionPart, SessionPlan } from '@/db/schema/session-plans'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
/**
|
||||
* Statistics about response times used for auto-pause threshold
|
||||
*/
|
||||
export interface AutoPauseStats {
|
||||
/** Mean response time in milliseconds */
|
||||
meanMs: number
|
||||
/** Standard deviation of response times in milliseconds */
|
||||
stdDevMs: number
|
||||
/** Calculated threshold (mean + 2*stdDev) in milliseconds */
|
||||
thresholdMs: number
|
||||
/** Number of samples used to calculate stats */
|
||||
sampleCount: number
|
||||
/** Whether statistical calculation was used (vs default timeout) */
|
||||
usedStatistics: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about why and when the session was paused
|
||||
*/
|
||||
export interface PauseInfo {
|
||||
/** When the pause occurred */
|
||||
pausedAt: Date
|
||||
/** Why the session was paused */
|
||||
reason: 'manual' | 'auto-timeout'
|
||||
/** Auto-pause statistics (only present for auto-timeout) */
|
||||
autoPauseStats?: AutoPauseStats
|
||||
}
|
||||
|
||||
function getPartTypeLabel(type: SessionPart['type']): string {
|
||||
switch (type) {
|
||||
case 'abacus':
|
||||
@@ -26,6 +55,32 @@ function getPartTypeEmoji(type: SessionPart['type']): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds as a human-readable duration
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}m ${remainingSeconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds as seconds with one decimal place
|
||||
*/
|
||||
function formatSeconds(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
export interface SessionPausedModalProps {
|
||||
/** Whether the modal is visible */
|
||||
isOpen: boolean
|
||||
@@ -37,6 +92,8 @@ export interface SessionPausedModalProps {
|
||||
}
|
||||
/** Current session plan (for progress info) */
|
||||
session: SessionPlan
|
||||
/** Information about the pause (optional for backwards compatibility) */
|
||||
pauseInfo?: PauseInfo
|
||||
/** Called when user clicks Resume */
|
||||
onResume: () => void
|
||||
/** Called when user clicks End Session */
|
||||
@@ -56,12 +113,33 @@ export function SessionPausedModal({
|
||||
isOpen,
|
||||
student,
|
||||
session,
|
||||
pauseInfo,
|
||||
onResume,
|
||||
onEndSession,
|
||||
}: SessionPausedModalProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Live-updating pause duration
|
||||
const [pauseDuration, setPauseDuration] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pauseInfo?.pausedAt) {
|
||||
setPauseDuration(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Update immediately
|
||||
setPauseDuration(Date.now() - pauseInfo.pausedAt.getTime())
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(() => {
|
||||
setPauseDuration(Date.now() - pauseInfo.pausedAt.getTime())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isOpen, pauseInfo?.pausedAt])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Calculate progress
|
||||
@@ -72,6 +150,11 @@ export function SessionPausedModal({
|
||||
|
||||
const currentPart = session.parts[session.currentPartIndex]
|
||||
|
||||
// Format pause time
|
||||
const pauseTimeStr = pauseInfo?.pausedAt
|
||||
? pauseInfo.pausedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="session-paused-modal"
|
||||
@@ -164,6 +247,119 @@ export function SessionPausedModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pause details */}
|
||||
{pauseInfo && (
|
||||
<div
|
||||
data-element="pause-details"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{/* Pause timing */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Paused at {pauseTimeStr}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
})}
|
||||
>
|
||||
{formatDuration(pauseDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Auto-pause reason */}
|
||||
{pauseInfo.reason === 'auto-timeout' && (
|
||||
<div
|
||||
data-element="auto-pause-reason"
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'yellow.900' : 'yellow.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'yellow.700' : 'yellow.200',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'yellow.300' : 'yellow.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Auto-paused: Taking longer than usual
|
||||
</p>
|
||||
{pauseInfo.autoPauseStats && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{pauseInfo.autoPauseStats.usedStatistics ? (
|
||||
<>
|
||||
<p>
|
||||
Based on {pauseInfo.autoPauseStats.sampleCount} problems: avg{' '}
|
||||
{formatSeconds(pauseInfo.autoPauseStats.meanMs)} ±{' '}
|
||||
{formatSeconds(pauseInfo.autoPauseStats.stdDevMs)}
|
||||
</p>
|
||||
<p>
|
||||
Timeout threshold: {formatSeconds(pauseInfo.autoPauseStats.thresholdMs)}{' '}
|
||||
(avg + 2×std dev)
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Using default {formatSeconds(pauseInfo.autoPauseStats.thresholdMs)} timeout
|
||||
(need {5 - pauseInfo.autoPauseStats.sampleCount} more problems for
|
||||
personalized timing)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual pause */}
|
||||
{pauseInfo.reason === 'manual' && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
Session paused manually
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress summary */}
|
||||
<div className={css({ width: '100%', textAlign: 'center' })}>
|
||||
<p
|
||||
|
||||
@@ -18,6 +18,7 @@ export type { SessionHudData } from './PracticeSubNav'
|
||||
export { PracticeSubNav } from './PracticeSubNav'
|
||||
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
|
||||
export { ProgressDashboard } from './ProgressDashboard'
|
||||
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
|
||||
export { SessionPausedModal } from './SessionPausedModal'
|
||||
export { SessionSummary } from './SessionSummary'
|
||||
export { StartPracticeModal } from './StartPracticeModal'
|
||||
|
||||
Reference in New Issue
Block a user