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:
Thomas Hallock
2025-12-11 16:38:17 -06:00
parent 9c1fd85ed5
commit 826c8490ba
5 changed files with 1064 additions and 24 deletions

View File

@@ -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')}
/>

View File

@@ -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(() => {

View 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>
)
},
}

View File

@@ -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

View File

@@ -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'