From 11ecb385ad5ce693e1c65a40c74303835a62c37e Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 11 Dec 2025 16:46:06 -0600 Subject: [PATCH] feat(practice): redesign paused modal with kid-friendly statistics UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign SessionPausedModal to be approachable for children while maintaining high-fidelity statistical information: - New visual components: - SpeedMeter: shows average response time vs variation range - SampleDots: visualizes progress toward learning user's rhythm (5 samples) - Educational framing: - "We Know Your Rhythm!" when we have enough samples - "Learning Your Rhythm..." when collecting data - "Taking a Thinking Break!" instead of clinical "paused" language - Friendly UI improvements: - Contextual emoji thought bubbles (🤔 for auto-pause, ☕ for manual) - Encouraging messages ("Smart thinking to take a break!") - "Keep Going!" button instead of "Resume" - Progress bar with gradient styling - Statistical transparency: - Shows "Usually you take about X seconds" for mean - Visual representation of standard deviation as "wiggle room" - Explains why the pause happened in child-friendly terms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../practice/SessionPausedModal.tsx | 662 +++++++++++++----- 1 file changed, 474 insertions(+), 188 deletions(-) diff --git a/apps/web/src/components/practice/SessionPausedModal.tsx b/apps/web/src/components/practice/SessionPausedModal.tsx index 4b4d140b..9008cde7 100644 --- a/apps/web/src/components/practice/SessionPausedModal.tsx +++ b/apps/web/src/components/practice/SessionPausedModal.tsx @@ -56,29 +56,195 @@ function getPartTypeEmoji(type: SessionPart['type']): string { } /** - * Format milliseconds as a human-readable duration + * Format milliseconds as a human-readable duration for kids */ -function formatDuration(ms: number): string { +function formatDurationFriendly(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 + if (remainingMinutes === 0) { + return hours === 1 ? '1 hour' : `${hours} hours` + } return `${hours}h ${remainingMinutes}m` } if (minutes > 0) { const remainingSeconds = seconds % 60 + if (remainingSeconds === 0) { + return minutes === 1 ? '1 minute' : `${minutes} minutes` + } return `${minutes}m ${remainingSeconds}s` } - return `${seconds}s` + return seconds === 1 ? '1 second' : `${seconds} seconds` } /** - * Format milliseconds as seconds with one decimal place + * Format seconds in a kid-friendly way */ -function formatSeconds(ms: number): string { - return `${(ms / 1000).toFixed(1)}s` +function formatSecondsFriendly(ms: number): string { + const seconds = ms / 1000 + if (seconds < 1) return 'less than a second' + if (seconds < 2) return 'about 1 second' + if (seconds < 10) return `about ${Math.round(seconds)} seconds` + return `about ${Math.round(seconds)} seconds` +} + +/** + * Sample dots visualization - shows the problems used to learn the rhythm + */ +function SampleDots({ count, needed, isDark }: { count: number; needed: number; isDark: boolean }) { + const total = Math.max(count, needed) + const dots = [] + + for (let i = 0; i < total; i++) { + const isFilled = i < count + dots.push( +
+ ) + } + + return ( +
+ {dots} +
+ ) +} + +/** + * Speed visualization - shows average speed vs variation + */ +function SpeedMeter({ + meanMs, + stdDevMs, + thresholdMs, + isDark, +}: { + meanMs: number + stdDevMs: number + thresholdMs: number + isDark: boolean +}) { + // Normalize values for display (0-100 scale based on threshold) + const meanPercent = Math.min(100, (meanMs / thresholdMs) * 100) + const variationPercent = Math.min(50, (stdDevMs / thresholdMs) * 100) + + return ( +
+ {/* Speed bar container */} +
+ {/* Variation range (the "wiggle room") */} +
+ + {/* Average marker */} +
+ + {/* Threshold marker */} +
+
+ + {/* Labels */} +
+ Fast + + Your usual speed + + Pause +
+
+ ) } export interface SessionPausedModalProps { @@ -103,11 +269,9 @@ export interface SessionPausedModalProps { /** * Session Paused Modal * - * A unified modal shown when: - * 1. User pauses an active session (via the HUD controls) - * 2. User navigates back to a paused session - * - * Shows progress info and options to resume or end the session. + * A kid-friendly modal shown when a session is paused. + * Features educational explanations of statistics concepts + * like averages and variation in an approachable way. */ export function SessionPausedModal({ isOpen, @@ -150,10 +314,9 @@ 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 + // Determine greeting based on pause reason + const isAutoTimeout = pauseInfo?.reason === 'auto-timeout' + const stats = pauseInfo?.autoPauseStats return (
{ - // Don't close on backdrop click - must use buttons - e.stopPropagation() - }} + onClick={(e) => e.stopPropagation()} >
e.stopPropagation()} > - {/* Paused indicator */} -
- ⏸️ -
- - {/* Avatar and greeting */} + {/* Hero section with avatar */}
+ {/* Thinking character */}
- {student.emoji} +
+ {student.emoji} +
+ {/* Thought bubble */} +
+ {isAutoTimeout ? '🤔' : '☕'} +
+
+ + {/* Greeting - contextual based on reason */} +
+

+ {isAutoTimeout ? 'Taking a Thinking Break!' : 'Break Time!'} +

+

+ {isAutoTimeout + ? `This one's a thinker, ${student.name}!` + : `Nice pause, ${student.name}!`} +

-

- Session Paused -

-

- Take a break, {student.name}! Tap Resume when ready. -

- {/* Pause details */} + {/* Break timer - make it feel positive */} {pauseInfo && (
+ ⏱️ + + Resting for{' '} + + {formatDurationFriendly(pauseDuration)} + + +
+ )} + + {/* Auto-pause explanation - educational and friendly */} + {isAutoTimeout && stats && ( +
- {/* Pause timing */} -
- - Paused at {pauseTimeStr} - - - {formatDuration(pauseDuration)} - -
+ {stats.usedStatistics ? ( + <> + {/* We know their rhythm */} +
+ 🎵 + + We Know Your Rhythm! + +
- {/* Auto-pause reason */} - {pauseInfo.reason === 'auto-timeout' && ( -

- Auto-paused: Taking longer than usual + We watched you solve{' '} + + {stats.sampleCount} problems + {' '} + and learned your speed! Usually you take{' '} + + {formatSecondsFriendly(stats.meanMs)} + + .

- {pauseInfo.autoPauseStats && ( -
+ +

+ The blue bar shows your usual range. This problem took longer, so we paused to + check in! +

+ + ) : ( + <> + {/* Still learning their rhythm */} +
+ 📊 + - {pauseInfo.autoPauseStats.usedStatistics ? ( - <> -

- Based on {pauseInfo.autoPauseStats.sampleCount} problems: avg{' '} - {formatSeconds(pauseInfo.autoPauseStats.meanMs)} ±{' '} - {formatSeconds(pauseInfo.autoPauseStats.stdDevMs)} -

-

- Timeout threshold: {formatSeconds(pauseInfo.autoPauseStats.thresholdMs)}{' '} - (avg + 2×std dev) -

- - ) : ( -

- Using default {formatSeconds(pauseInfo.autoPauseStats.thresholdMs)} timeout - (need {5 - pauseInfo.autoPauseStats.sampleCount} more problems for - personalized timing) -

- )} -
- )} -
- )} + Learning Your Rhythm... + +
- {/* Manual pause */} - {pauseInfo.reason === 'manual' && ( -

- Session paused manually -

+

+ We need to watch you solve a few problems to learn how fast you usually go! +

+ + {/* Sample dots showing progress */} +
+

+ Problems solved: {stats.sampleCount} of 5 needed +

+ +
+ +

+ {5 - stats.sampleCount} more problem{5 - stats.sampleCount !== 1 ? 's' : ''} until + we know your rhythm! +

+ )}
)} - {/* Progress summary */} -
-

- Problem {completedProblems + 1} of {totalProblems} -

+ +

+ Smart thinking to take a break when you need one! +

+
+ )} - {/* Progress bar */} + {/* Progress summary - celebratory */} +
+
+ + Progress + + + {completedProblems} of {totalProblems} done! + +
+ + {/* Progress bar with sparkle */}
- {getPartTypeEmoji(currentPart.type)} Part {session.currentPartIndex + 1}:{' '} - {getPartTypeLabel(currentPart.type)} + {getPartTypeEmoji(currentPart.type)} {getPartTypeLabel(currentPart.type)}

)}
@@ -414,9 +702,8 @@ export function SessionPausedModal({ className={css({ display: 'flex', flexDirection: 'column', - gap: '0.75rem', + gap: '0.5rem', width: '100%', - marginTop: '0.5rem', })} >