feat(practice): add unified SessionMode system for consistent skill targeting

Creates a single source of truth for practice session decisions:
- SessionMode types: remediation, progression, maintenance
- getSessionMode() centralizes BKT computation
- SessionModeBanner component displays context-aware messaging
- useSessionMode() hook for React Query integration

Updates Dashboard, Summary, and StartPracticeModal to use SessionMode,
eliminating the "three-way messaging" problem where different parts of
the UI showed conflicting skill information.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-18 13:56:15 -06:00
parent bb9506b93e
commit b345baf3c4
10 changed files with 2469 additions and 977 deletions

View File

@ -0,0 +1,46 @@
/**
* API route for getting the session mode for a student
*
* GET /api/curriculum/[playerId]/session-mode
*
* Returns the unified session mode that determines:
* - What type of session should be run (remediation/progression/maintenance)
* - What to show in the dashboard banner
* - What CTA to show in the StartPracticeModal
* - What problems the session planner should generate
*
* This is the single source of truth for session planning decisions.
*/
import { NextResponse } from 'next/server'
import { getSessionMode, type SessionMode } from '@/lib/curriculum/session-mode'
interface RouteParams {
params: Promise<{ playerId: string }>
}
export interface SessionModeResponse {
sessionMode: SessionMode
}
/**
* GET - Get the session mode for a student
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
const sessionMode = await getSessionMode(playerId)
return NextResponse.json({
sessionMode,
} satisfies SessionModeResponse)
} catch (error) {
console.error('Error fetching session mode:', error)
return NextResponse.json({ error: 'Failed to fetch session mode' }, { status: 500 })
}
}

View File

@ -1,6 +1,7 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
@ -9,6 +10,7 @@ import {
type CurrentPhaseInfo,
PracticeSubNav,
ProgressDashboard,
SessionModeBanner,
StartPracticeModal,
type StudentWithProgress,
} from '@/components/practice'
@ -28,6 +30,7 @@ import {
import type { Player } from '@/db/schema/players'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum'
import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
import {
@ -1369,9 +1372,11 @@ function SkillsTab({
function HistoryTab({
isDark,
recentSessions,
studentId,
}: {
isDark: boolean
recentSessions: PracticeSession[]
studentId: string
}) {
return (
<div data-tab-content="history">
@ -1420,14 +1425,26 @@ function HistoryTab({
})}
>
{recentSessions.slice(0, 10).map((session) => (
<div
<Link
key={session.id}
href={`/practice/${studentId}/session/${session.id}`}
data-element="session-history-item"
data-session-id={session.id}
className={css({
display: 'block',
padding: '1rem',
borderRadius: '8px',
backgroundColor: isDark ? 'gray.700' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
textDecoration: 'none',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.650' : 'gray.50',
borderColor: isDark ? 'gray.500' : 'gray.300',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
})}
>
<div
@ -1488,7 +1505,7 @@ function HistoryTab({
% accuracy
</span>
</div>
</div>
</Link>
))}
</div>
)}
@ -1753,6 +1770,9 @@ export function DashboardClient({
const setMasteredSkillsMutation = useSetMasteredSkills()
const refreshSkillMutation = useRefreshSkillRecency()
// Session mode - single source of truth for session planning decisions
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
// Tab state - sync with URL
const [activeTab, setActiveTab] = useState<TabId>(initialTab)
@ -1916,6 +1936,18 @@ export function DashboardClient({
})}
>
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
{/* Session mode banner - handles celebration wind-down internally */}
{sessionMode && (
<div className={css({ marginBottom: '1rem' })}>
<SessionModeBanner
sessionMode={sessionMode}
onAction={handleStartPractice}
isLoading={isLoadingSessionMode}
variant="dashboard"
/>
</div>
)}
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} isDark={isDark} />
{activeTab === 'overview' && (
@ -1945,7 +1977,7 @@ export function DashboardClient({
)}
{activeTab === 'history' && (
<HistoryTab isDark={isDark} recentSessions={recentSessions} />
<HistoryTab isDark={isDark} recentSessions={recentSessions} studentId={studentId} />
)}
{activeTab === 'notes' && (
@ -1980,11 +2012,12 @@ export function DashboardClient({
/>
</main>
{showStartPracticeModal && (
{showStartPracticeModal && sessionMode && (
<StartPracticeModal
studentId={studentId}
studentName={player.name}
focusDescription={currentPhase.phaseName}
focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={activeSession}
problemHistory={problemHistory}

View File

@ -5,6 +5,7 @@ import { useCallback, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import {
PracticeSubNav,
SessionModeBanner,
SessionOverview,
SessionSummary,
StartPracticeModal,
@ -12,6 +13,7 @@ import {
import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players'
import type { SessionPlan } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { css } from '../../../../../styled-system/css'
@ -48,6 +50,9 @@ export function SummaryClient({
const [showStartPracticeModal, setShowStartPracticeModal] = useState(false)
const [viewMode, setViewMode] = useState<'summary' | 'debug'>('summary')
// Session mode - single source of truth for session planning decisions
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
const isInProgress = session?.startedAt && !session?.completedAt
// Handle practice again - show the start practice modal
@ -117,6 +122,18 @@ export function SummaryClient({
</p>
</header>
{/* Session mode banner - handles celebration wind-down internally */}
{sessionMode && (
<div className={css({ marginBottom: '1.5rem' })}>
<SessionModeBanner
sessionMode={sessionMode}
onAction={handlePracticeAgain}
isLoading={isLoadingSessionMode}
variant="dashboard"
/>
</div>
)}
{/* View Mode Toggle (only show when there's a session) */}
{session && (
<div
@ -230,11 +247,12 @@ export function SummaryClient({
</main>
{/* Start Practice Modal */}
{showStartPracticeModal && (
{showStartPracticeModal && sessionMode && (
<StartPracticeModal
studentId={studentId}
studentName={player.name}
focusDescription="Continue practicing"
focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={null}
problemHistory={problemHistory}

View File

@ -0,0 +1,518 @@
import type { Meta, StoryObj } from '@storybook/react'
import type {
MaintenanceMode,
ProgressionMode,
RemediationMode,
} from '@/lib/curriculum/session-mode'
import { css } from '../../../styled-system/css'
import { SessionModeBanner } from './SessionModeBanner'
// ============================================================================
// Mock Data
// ============================================================================
const mockRemediationMode: RemediationMode = {
type: 'remediation',
weakSkills: [
{ skillId: 'add-3', displayName: '+3', pKnown: 0.35 },
{ skillId: 'sub-5-complement-2', displayName: '+5 - 2', pKnown: 0.42 },
],
focusDescription: 'Strengthening: +3 and +5 - 2',
blockedPromotion: {
nextSkill: { skillId: 'sub-5-complement-4', displayName: '+5 - 4', pKnown: 0 },
reason: 'Strengthen +3 and +5 - 2 first',
phase: {
id: 'L1.sub.-4.five',
levelId: 1,
operation: 'subtraction',
targetNumber: -4,
usesFiveComplement: true,
usesTenComplement: false,
name: 'Five-Complement Subtraction 4',
description: 'Learn to subtract 4 using five-complement technique',
primarySkillId: 'sub-5-complement-4',
order: 5,
},
tutorialReady: false,
},
}
const mockRemediationModeNoBlockedPromotion: RemediationMode = {
type: 'remediation',
weakSkills: [
{ skillId: 'add-3', displayName: '+3', pKnown: 0.28 },
{ skillId: 'add-4', displayName: '+4', pKnown: 0.31 },
{ skillId: 'sub-5-complement-2', displayName: '+5 - 2', pKnown: 0.38 },
],
focusDescription: 'Strengthening: +3, +4, +5 - 2',
}
const mockProgressionModeWithTutorial: ProgressionMode = {
type: 'progression',
nextSkill: { skillId: 'sub-5-complement-4', displayName: '+5 - 4', pKnown: 0 },
phase: {
id: 'L1.sub.-4.five',
levelId: 1,
operation: 'subtraction',
targetNumber: -4,
usesFiveComplement: true,
usesTenComplement: false,
name: 'Five-Complement Subtraction 4',
description: 'Learn to subtract 4 using five-complement technique',
primarySkillId: 'sub-5-complement-4',
order: 5,
},
tutorialRequired: true,
skipCount: 0,
focusDescription: 'Learning: +5 - 4',
}
const mockProgressionModeNoTutorial: ProgressionMode = {
type: 'progression',
nextSkill: { skillId: 'add-4', displayName: '+4', pKnown: 0 },
phase: {
id: 'L1.add.+4.direct',
levelId: 1,
operation: 'addition',
targetNumber: 4,
usesFiveComplement: false,
usesTenComplement: false,
name: 'Direct Addition 4',
description: 'Learn to add 4 using direct technique',
primarySkillId: 'add-4',
order: 2,
},
tutorialRequired: false,
skipCount: 2,
focusDescription: 'Practice: +4',
}
const mockMaintenanceMode: MaintenanceMode = {
type: 'maintenance',
focusDescription: 'Mixed practice',
skillCount: 8,
}
// ============================================================================
// Meta
// ============================================================================
const meta: Meta<typeof SessionModeBanner> = {
title: 'Practice/SessionModeBanner',
component: SessionModeBanner,
decorators: [
(Story) => (
<div
className={css({
padding: '2rem',
maxWidth: '500px',
margin: '0 auto',
})}
>
<Story />
</div>
),
],
parameters: {
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['dashboard', 'modal'],
},
isLoading: {
control: 'boolean',
},
},
}
export default meta
type Story = StoryObj<typeof SessionModeBanner>
// ============================================================================
// Remediation Mode Stories
// ============================================================================
export const RemediationWithBlockedPromotion: Story = {
args: {
sessionMode: mockRemediationMode,
onAction: () => alert('Starting remediation practice'),
variant: 'dashboard',
},
}
export const RemediationWithBlockedPromotionModal: Story = {
args: {
sessionMode: mockRemediationMode,
onAction: () => alert('Starting remediation practice'),
variant: 'modal',
},
}
export const RemediationNoBlockedPromotion: Story = {
args: {
sessionMode: mockRemediationModeNoBlockedPromotion,
onAction: () => alert('Starting remediation practice'),
variant: 'dashboard',
},
}
export const RemediationLoading: Story = {
args: {
sessionMode: mockRemediationMode,
onAction: () => {},
isLoading: true,
variant: 'dashboard',
},
}
// ============================================================================
// Progression Mode Stories
// ============================================================================
export const ProgressionWithTutorial: Story = {
args: {
sessionMode: mockProgressionModeWithTutorial,
onAction: () => alert('Starting tutorial'),
variant: 'dashboard',
},
}
export const ProgressionWithTutorialModal: Story = {
args: {
sessionMode: mockProgressionModeWithTutorial,
onAction: () => alert('Starting tutorial'),
variant: 'modal',
},
}
export const ProgressionNoTutorial: Story = {
args: {
sessionMode: mockProgressionModeNoTutorial,
onAction: () => alert('Starting practice'),
variant: 'dashboard',
},
}
export const ProgressionLoading: Story = {
args: {
sessionMode: mockProgressionModeWithTutorial,
onAction: () => {},
isLoading: true,
variant: 'dashboard',
},
}
// ============================================================================
// Maintenance Mode Stories
// ============================================================================
export const Maintenance: Story = {
args: {
sessionMode: mockMaintenanceMode,
onAction: () => alert('Starting maintenance practice'),
variant: 'dashboard',
},
}
export const MaintenanceModal: Story = {
args: {
sessionMode: mockMaintenanceMode,
onAction: () => alert('Starting maintenance practice'),
variant: 'modal',
},
}
export const MaintenanceLoading: Story = {
args: {
sessionMode: mockMaintenanceMode,
onAction: () => {},
isLoading: true,
variant: 'dashboard',
},
}
// ============================================================================
// All Modes Comparison
// ============================================================================
export const AllModesDashboard: Story = {
render: () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: 'gray.500',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Remediation (with blocked promotion)
</h3>
<SessionModeBanner
sessionMode={mockRemediationMode}
onAction={() => alert('Starting remediation')}
variant="dashboard"
/>
</div>
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: 'gray.500',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Progression (tutorial required)
</h3>
<SessionModeBanner
sessionMode={mockProgressionModeWithTutorial}
onAction={() => alert('Starting progression')}
variant="dashboard"
/>
</div>
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: 'gray.500',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Maintenance
</h3>
<SessionModeBanner
sessionMode={mockMaintenanceMode}
onAction={() => alert('Starting maintenance')}
variant="dashboard"
/>
</div>
</div>
),
}
export const AllModesModal: Story = {
render: () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: 'gray.500',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Remediation (with blocked promotion)
</h3>
<SessionModeBanner
sessionMode={mockRemediationMode}
onAction={() => alert('Starting remediation')}
variant="modal"
/>
</div>
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: 'gray.500',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Progression (tutorial required)
</h3>
<SessionModeBanner
sessionMode={mockProgressionModeWithTutorial}
onAction={() => alert('Starting progression')}
variant="modal"
/>
</div>
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: 'gray.500',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Maintenance
</h3>
<SessionModeBanner
sessionMode={mockMaintenanceMode}
onAction={() => alert('Starting maintenance')}
variant="modal"
/>
</div>
</div>
),
}
// ============================================================================
// Dark Mode Stories
// ============================================================================
export const DarkModeRemediation: Story = {
parameters: {
backgrounds: { default: 'dark' },
},
decorators: [
(Story) => (
<div
className={css({
padding: '2rem',
maxWidth: '500px',
margin: '0 auto',
backgroundColor: 'gray.900',
borderRadius: '12px',
})}
data-theme="dark"
>
<Story />
</div>
),
],
args: {
sessionMode: mockRemediationMode,
onAction: () => alert('Starting remediation practice'),
variant: 'dashboard',
},
}
export const DarkModeProgression: Story = {
parameters: {
backgrounds: { default: 'dark' },
},
decorators: [
(Story) => (
<div
className={css({
padding: '2rem',
maxWidth: '500px',
margin: '0 auto',
backgroundColor: 'gray.900',
borderRadius: '12px',
})}
data-theme="dark"
>
<Story />
</div>
),
],
args: {
sessionMode: mockProgressionModeWithTutorial,
onAction: () => alert('Starting tutorial'),
variant: 'dashboard',
},
}
export const DarkModeMaintenance: Story = {
parameters: {
backgrounds: { default: 'dark' },
},
decorators: [
(Story) => (
<div
className={css({
padding: '2rem',
maxWidth: '500px',
margin: '0 auto',
backgroundColor: 'gray.900',
borderRadius: '12px',
})}
data-theme="dark"
>
<Story />
</div>
),
],
args: {
sessionMode: mockMaintenanceMode,
onAction: () => alert('Starting maintenance practice'),
variant: 'dashboard',
},
}
// ============================================================================
// Edge Cases
// ============================================================================
export const RemediationManyWeakSkills: Story = {
args: {
sessionMode: {
type: 'remediation',
weakSkills: [
{ skillId: 'add-1', displayName: '+1', pKnown: 0.25 },
{ skillId: 'add-2', displayName: '+2', pKnown: 0.28 },
{ skillId: 'add-3', displayName: '+3', pKnown: 0.31 },
{ skillId: 'add-4', displayName: '+4', pKnown: 0.35 },
{ skillId: 'sub-5-complement-1', displayName: '+5 - 1', pKnown: 0.38 },
],
focusDescription: 'Strengthening: +1, +2, +3 +2 more',
} satisfies RemediationMode,
onAction: () => alert('Starting remediation'),
variant: 'dashboard',
},
}
export const RemediationAlmostDone: Story = {
args: {
sessionMode: {
type: 'remediation',
weakSkills: [{ skillId: 'add-3', displayName: '+3', pKnown: 0.48 }],
focusDescription: 'Strengthening: +3',
blockedPromotion: {
nextSkill: { skillId: 'add-4', displayName: '+4', pKnown: 0 },
reason: 'Strengthen +3 first',
phase: {
id: 'L1.add.+4.direct',
levelId: 1,
operation: 'addition',
targetNumber: 4,
usesFiveComplement: false,
usesTenComplement: false,
name: 'Direct Addition 4',
description: 'Learn to add 4 using direct technique',
primarySkillId: 'add-4',
order: 2,
},
tutorialReady: false,
},
} satisfies RemediationMode,
onAction: () => alert('Starting remediation'),
variant: 'dashboard',
},
}
export const MaintenanceManySkills: Story = {
args: {
sessionMode: {
type: 'maintenance',
focusDescription: 'Mixed practice',
skillCount: 24,
} satisfies MaintenanceMode,
onAction: () => alert('Starting maintenance'),
variant: 'dashboard',
},
}

View File

@ -0,0 +1,486 @@
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import type {
MaintenanceMode,
ProgressionMode,
RemediationMode,
SessionMode,
} from '@/lib/curriculum/session-mode'
import { css } from '../../../styled-system/css'
import { CelebrationProgressionBanner } from './CelebrationProgressionBanner'
// ============================================================================
// Types
// ============================================================================
interface SessionModeBannerProps {
/** The session mode to display */
sessionMode: SessionMode
/** Callback when user clicks the action button */
onAction: () => void
/** Whether an action is in progress */
isLoading?: boolean
/** Variant for different contexts */
variant?: 'dashboard' | 'modal'
}
// ============================================================================
// Sub-components for each mode
// ============================================================================
interface RemediationBannerProps {
mode: RemediationMode
onAction: () => void
isLoading: boolean
variant: 'dashboard' | 'modal'
isDark: boolean
}
function RemediationBanner({ mode, onAction, isLoading, variant, isDark }: RemediationBannerProps) {
const weakSkillNames = mode.weakSkills.slice(0, 3).map((s) => s.displayName)
const hasBlockedPromotion = !!mode.blockedPromotion
// Calculate progress if we have blocked promotion
// Progress is based on how close the weakest skill is to the threshold (0.5)
const weakestPKnown = mode.weakSkills[0]?.pKnown ?? 0
const progressPercent = Math.round((weakestPKnown / 0.5) * 100)
return (
<div
data-element="session-mode-banner"
data-mode="remediation"
data-variant={variant}
className={css({
borderRadius: variant === 'modal' ? '12px' : '16px',
overflow: 'hidden',
border: '2px solid',
borderColor: isDark ? 'amber.600' : 'amber.400',
})}
style={{
background: isDark
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.05) 100%)',
}}
>
{/* Info section */}
<div
className={css({
padding: variant === 'modal' ? '0.875rem 1rem' : '1rem 1.25rem',
display: 'flex',
gap: '0.75rem',
alignItems: 'flex-start',
})}
>
<span
className={css({
fontSize: variant === 'modal' ? '1.5rem' : '2rem',
lineHeight: 1,
})}
>
{hasBlockedPromotion ? '🔒' : '💪'}
</span>
<div className={css({ flex: 1 })}>
<p
className={css({
fontSize: variant === 'modal' ? '0.9375rem' : '1rem',
fontWeight: '600',
marginBottom: '0.25rem',
})}
style={{ color: isDark ? '#fcd34d' : '#b45309' }}
>
{hasBlockedPromotion ? 'Almost there!' : 'Strengthening skills'}
</p>
<p
className={css({
fontSize: variant === 'modal' ? '0.8125rem' : '0.875rem',
marginBottom: hasBlockedPromotion ? '0.5rem' : '0',
})}
style={{ color: isDark ? '#d4d4d4' : '#525252' }}
>
{hasBlockedPromotion ? (
<>
Strengthen{' '}
<strong style={{ color: isDark ? '#fbbf24' : '#d97706' }}>
{weakSkillNames.join(' and ')}
</strong>{' '}
to unlock{' '}
<strong style={{ color: isDark ? '#86efac' : '#166534' }}>
{mode.blockedPromotion!.nextSkill.displayName}
</strong>
</>
) : (
<>
Targeting:{' '}
<strong style={{ color: isDark ? '#fbbf24' : '#d97706' }}>
{weakSkillNames.join(', ')}
</strong>
</>
)}
</p>
{/* Progress bar for blocked promotion */}
{hasBlockedPromotion && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<div
className={css({
flex: 1,
height: '6px',
borderRadius: '3px',
overflow: 'hidden',
})}
style={{
background: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}}
>
<div
className={css({
height: '100%',
borderRadius: '3px',
transition: 'width 0.3s ease',
})}
style={{
width: `${progressPercent}%`,
background: isDark
? 'linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%)'
: 'linear-gradient(90deg, #f59e0b 0%, #d97706 100%)',
}}
/>
</div>
<span
className={css({
fontSize: '0.6875rem',
fontWeight: '600',
})}
style={{ color: isDark ? '#fbbf24' : '#d97706' }}
>
{progressPercent}%
</span>
</div>
)}
</div>
</div>
{/* Action button */}
<button
type="button"
data-action="start-remediation"
onClick={onAction}
disabled={isLoading}
className={css({
width: '100%',
padding: variant === 'modal' ? '0.875rem' : '1rem',
fontSize: variant === 'modal' ? '1rem' : '1.0625rem',
fontWeight: 'bold',
color: 'white',
border: 'none',
borderRadius: '0',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
_hover: {
filter: isLoading ? 'none' : 'brightness(1.05)',
},
})}
style={{
background: isLoading ? '#9ca3af' : 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
boxShadow: isLoading ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)',
}}
>
{isLoading ? 'Starting...' : 'Practice Now →'}
</button>
</div>
)
}
interface ProgressionBannerProps {
mode: ProgressionMode
onAction: () => void
isLoading: boolean
variant: 'dashboard' | 'modal'
isDark: boolean
}
function ProgressionBanner({ mode, onAction, isLoading, variant, isDark }: ProgressionBannerProps) {
return (
<div
data-element="session-mode-banner"
data-mode="progression"
data-variant={variant}
className={css({
borderRadius: variant === 'modal' ? '12px' : '16px',
overflow: 'hidden',
border: '2px solid',
borderColor: isDark ? 'green.500' : 'green.400',
})}
style={{
background: isDark
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(59, 130, 246, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%)',
}}
>
{/* Info section */}
<div
className={css({
padding: variant === 'modal' ? '0.875rem 1rem' : '1rem 1.25rem',
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
})}
>
<span
className={css({
fontSize: variant === 'modal' ? '1.5rem' : '2rem',
lineHeight: 1,
})}
>
🌟
</span>
<div className={css({ flex: 1 })}>
<p
className={css({
fontSize: variant === 'modal' ? '0.9375rem' : '1rem',
fontWeight: '600',
})}
style={{ color: isDark ? '#86efac' : '#166534' }}
>
{mode.tutorialRequired ? "You've unlocked: " : 'Ready to practice: '}
<strong>{mode.nextSkill.displayName}</strong>
</p>
<p
className={css({
fontSize: variant === 'modal' ? '0.75rem' : '0.8125rem',
marginTop: '0.125rem',
})}
style={{ color: isDark ? '#a1a1aa' : '#6b7280' }}
>
{mode.tutorialRequired ? 'Start with a quick tutorial' : 'Continue building mastery'}
</p>
</div>
</div>
{/* Action button */}
<button
type="button"
data-action="start-progression"
onClick={onAction}
disabled={isLoading}
className={css({
width: '100%',
padding: variant === 'modal' ? '0.875rem' : '1rem',
fontSize: variant === 'modal' ? '1rem' : '1.0625rem',
fontWeight: 'bold',
color: 'white',
border: 'none',
borderRadius: '0',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
_hover: {
filter: isLoading ? 'none' : 'brightness(1.05)',
},
})}
style={{
background: isLoading ? '#9ca3af' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
boxShadow: isLoading ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)',
}}
>
{isLoading ? (
'Starting...'
) : mode.tutorialRequired ? (
<>
<span>🎓</span>
<span>Begin Tutorial</span>
<span></span>
</>
) : (
"Let's Go! →"
)}
</button>
</div>
)
}
interface MaintenanceBannerProps {
mode: MaintenanceMode
onAction: () => void
isLoading: boolean
variant: 'dashboard' | 'modal'
isDark: boolean
}
function MaintenanceBanner({ mode, onAction, isLoading, variant, isDark }: MaintenanceBannerProps) {
return (
<div
data-element="session-mode-banner"
data-mode="maintenance"
data-variant={variant}
className={css({
borderRadius: variant === 'modal' ? '12px' : '16px',
overflow: 'hidden',
border: '2px solid',
borderColor: isDark ? 'blue.500' : 'blue.400',
})}
style={{
background: isDark
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(139, 92, 246, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
}}
>
{/* Info section */}
<div
className={css({
padding: variant === 'modal' ? '0.875rem 1rem' : '1rem 1.25rem',
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
})}
>
<span
className={css({
fontSize: variant === 'modal' ? '1.5rem' : '2rem',
lineHeight: 1,
})}
>
</span>
<div className={css({ flex: 1 })}>
<p
className={css({
fontSize: variant === 'modal' ? '0.9375rem' : '1rem',
fontWeight: '600',
})}
style={{ color: isDark ? '#93c5fd' : '#1d4ed8' }}
>
All skills strong!
</p>
<p
className={css({
fontSize: variant === 'modal' ? '0.75rem' : '0.8125rem',
marginTop: '0.125rem',
})}
style={{ color: isDark ? '#a1a1aa' : '#6b7280' }}
>
Keep practicing to maintain mastery ({mode.skillCount} skills)
</p>
</div>
</div>
{/* Action button */}
<button
type="button"
data-action="start-maintenance"
onClick={onAction}
disabled={isLoading}
className={css({
width: '100%',
padding: variant === 'modal' ? '0.875rem' : '1rem',
fontSize: variant === 'modal' ? '1rem' : '1.0625rem',
fontWeight: 'bold',
color: 'white',
border: 'none',
borderRadius: '0',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
_hover: {
filter: isLoading ? 'none' : 'brightness(1.05)',
},
})}
style={{
background: isLoading ? '#9ca3af' : 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
boxShadow: isLoading ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)',
}}
>
{isLoading ? 'Starting...' : 'Practice →'}
</button>
</div>
)
}
// ============================================================================
// Main Component
// ============================================================================
/**
* SessionModeBanner - Unified banner for all session modes
*
* Displays the appropriate banner based on the session mode:
* - Remediation: Shows weak skills + blocked promotion (if any)
* - Progression: Shows next skill to learn + tutorial CTA
* - Maintenance: Shows all-strong message
*
* Used in both the Dashboard and StartPracticeModal.
*/
export function SessionModeBanner({
sessionMode,
onAction,
isLoading = false,
variant = 'dashboard',
}: SessionModeBannerProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
switch (sessionMode.type) {
case 'remediation':
return (
<RemediationBanner
mode={sessionMode}
onAction={onAction}
isLoading={isLoading}
variant={variant}
isDark={isDark}
/>
)
case 'progression':
// Use celebration banner for tutorial-required skills (unlocked new skill)
// This will show confetti + animate down to normal banner over ~60 seconds
if (sessionMode.tutorialRequired) {
return (
<CelebrationProgressionBanner
mode={sessionMode}
onAction={onAction}
isLoading={isLoading}
variant={variant}
isDark={isDark}
/>
)
}
return (
<ProgressionBanner
mode={sessionMode}
onAction={onAction}
isLoading={isLoading}
variant={variant}
isDark={isDark}
/>
)
case 'maintenance':
return (
<MaintenanceBanner
mode={sessionMode}
onAction={onAction}
isLoading={isLoading}
variant={variant}
isDark={isDark}
/>
)
}
}
export default SessionModeBanner

View File

@ -1,6 +1,11 @@
import type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@/contexts/ThemeContext'
import type {
MaintenanceMode,
ProgressionMode,
RemediationMode,
} from '@/lib/curriculum/session-mode'
import { StartPracticeModal } from './StartPracticeModal'
import { css } from '../../../styled-system/css'
@ -67,11 +72,48 @@ const meta: Meta<typeof StartPracticeModal> = {
export default meta
type Story = StoryObj<typeof StartPracticeModal>
// Mock session modes for stories
const mockMaintenanceMode: MaintenanceMode = {
type: 'maintenance',
focusDescription: 'Mixed practice',
skillCount: 8,
}
const mockProgressionMode: ProgressionMode = {
type: 'progression',
nextSkill: { skillId: 'add-5', displayName: '+5', pKnown: 0 },
phase: {
id: 'L1.add.+5.direct',
levelId: 1,
operation: 'addition',
targetNumber: 5,
usesFiveComplement: false,
usesTenComplement: false,
name: 'Direct Addition 5',
description: 'Learn to add 5 using direct technique',
primarySkillId: 'add-5',
order: 3,
},
tutorialRequired: true,
skipCount: 0,
focusDescription: 'Learning: +5',
}
const mockRemediationMode: RemediationMode = {
type: 'remediation',
weakSkills: [
{ skillId: 'add-3', displayName: '+3', pKnown: 0.35 },
{ skillId: 'add-4', displayName: '+4', pKnown: 0.42 },
],
focusDescription: 'Strengthening: +3 and +4',
}
// Default props
const defaultProps = {
studentId: 'test-student-1',
studentName: 'Sonia',
focusDescription: 'Five Complements Addition',
focusDescription: 'Mixed practice',
sessionMode: mockMaintenanceMode,
secondsPerTerm: 4,
onClose: () => console.log('Modal closed'),
onStarted: () => console.log('Practice started'),
@ -141,50 +183,39 @@ export const DarkTheme: Story = {
}
/**
* For a student with slower pace
* Remediation mode - student has weak skills to strengthen
*/
export const SlowerPace: Story = {
export const RemediationMode: Story = {
render: () => (
<StoryWrapper>
<StartPracticeModal
{...defaultProps}
studentName="Alex"
secondsPerTerm={8}
focusDescription="Ten Complements Addition"
sessionMode={mockRemediationMode}
focusDescription={mockRemediationMode.focusDescription}
/>
</StoryWrapper>
),
}
/**
* For a student with faster pace
* Progression mode - student is ready to learn a new skill
*/
export const FasterPace: Story = {
export const ProgressionMode: Story = {
render: () => (
<StoryWrapper>
<StartPracticeModal
{...defaultProps}
studentName="Maya"
secondsPerTerm={2}
focusDescription="Basic Addition"
sessionMode={mockProgressionMode}
focusDescription={mockProgressionMode.focusDescription}
/>
</StoryWrapper>
),
}
/**
* Note: The tutorial gate feature requires the useNextSkillToLearn hook
* to return data. In a real scenario, you would need to mock the API
* response or use MSW (Mock Service Worker) to simulate the API.
*
* The tutorial gate shows when:
* 1. useNextSkillToLearn returns a skill with tutorialReady=false
* 2. getSkillTutorialConfig returns a config for that skill
*
* To test the tutorial gate manually:
* 1. Use the app with a real student who has a new skill to learn
* 2. The green "New skill available!" banner will appear
* 3. Click "Learn Now" to see the SkillTutorialLauncher
* Documentation note about the SessionMode system
*/
export const DocumentationNote: Story = {
render: () => (
@ -199,21 +230,26 @@ export const DocumentationNote: Story = {
})}
>
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' })}>
Tutorial Gate Feature
Session Mode System
</h2>
<p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}>
The StartPracticeModal includes a <strong>tutorial gate</strong> that appears when a
student has a new skill ready to learn. This feature:
The StartPracticeModal receives a <strong>sessionMode</strong> prop that determines the
type of session:
</p>
<ul className={css({ paddingLeft: '1.5rem', marginBottom: '1rem', lineHeight: 1.8 })}>
<li>Shows a green banner with the skill name</li>
<li>Offers "Learn Now" to start the tutorial</li>
<li>Offers "Practice without it" to skip</li>
<li>Tracks skip count for teacher visibility</li>
<li>
<strong>Maintenance:</strong> All skills are strong, mixed practice
</li>
<li>
<strong>Remediation:</strong> Weak skills need strengthening (shown in targeting info)
</li>
<li>
<strong>Progression:</strong> Ready to learn new skill, may include tutorial gate
</li>
</ul>
<p className={css({ fontSize: '0.875rem', color: 'gray.600', fontStyle: 'italic' })}>
Note: This feature requires API mocking to demonstrate in Storybook. See
SkillTutorialLauncher stories for the tutorial UI itself.
The sessionMode is fetched via useSessionMode() hook and passed to the modal. See
SessionModeBanner stories for the dashboard banner component.
</p>
</div>
</StoryWrapper>

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ export { ProgressDashboard } from './ProgressDashboard'
export type { MasteryLevel } from './styles/practiceTheme'
export type { SessionMoodIndicatorProps } from './SessionMoodIndicator'
export { SessionMoodIndicator } from './SessionMoodIndicator'
export { SessionModeBanner } from './SessionModeBanner'
export { SessionOverview } from './SessionOverview'
export type { AutoPauseStats, PauseInfo } from './SessionPausedModal'
export { SessionPausedModal } from './SessionPausedModal'
@ -32,6 +33,7 @@ export type { SessionProgressIndicatorProps } from './SessionProgressIndicator'
export { SessionProgressIndicator } from './SessionProgressIndicator'
export { SessionSummary } from './SessionSummary'
export { SkillPerformanceReports } from './SkillPerformanceReports'
export { SkillUnlockBanner } from './SkillUnlockBanner'
export type { SpeedMeterProps } from './SpeedMeter'
export { SpeedMeter } from './SpeedMeter'
export { StartPracticeModal } from './StartPracticeModal'

View File

@ -0,0 +1,67 @@
/**
* Hook for fetching the session mode for a student
*
* This is the single source of truth for session planning decisions.
* It replaces the separate useNextSkillToLearn hook and local BKT computations.
*
* The session mode determines:
* - Dashboard banner content
* - StartPracticeModal CTA
* - Session planner problem generation
*/
import { useQuery } from '@tanstack/react-query'
import type { SessionMode } from '@/lib/curriculum/session-mode'
import type { SessionModeResponse } from '@/app/api/curriculum/[playerId]/session-mode/route'
export const sessionModeKeys = {
all: ['sessionMode'] as const,
forPlayer: (playerId: string) => [...sessionModeKeys.all, playerId] as const,
}
/**
* Fetch the session mode for a player
*/
async function fetchSessionMode(playerId: string): Promise<SessionMode> {
const response = await fetch(`/api/curriculum/${playerId}/session-mode`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to fetch session mode')
}
const data: SessionModeResponse = await response.json()
return data.sessionMode
}
/**
* Hook to get the session mode for a student.
*
* Returns one of three modes:
* - remediation: Student has weak skills that need strengthening
* - progression: Student is ready to learn a new skill
* - maintenance: All skills are strong, mixed practice
*
* @param playerId - The player ID
* @param enabled - Whether to enable the query (default: true)
* @returns Query result with session mode
*/
export function useSessionMode(playerId: string, enabled = true) {
return useQuery({
queryKey: sessionModeKeys.forPlayer(playerId),
queryFn: () => fetchSessionMode(playerId),
enabled: enabled && !!playerId,
staleTime: 30_000, // 30 seconds - skill state doesn't change frequently
refetchOnWindowFocus: false,
})
}
/**
* Prefetch session mode for SSR
*/
export function prefetchSessionMode(playerId: string) {
return {
queryKey: sessionModeKeys.forPlayer(playerId),
queryFn: () => fetchSessionMode(playerId),
}
}

View File

@ -0,0 +1,285 @@
/**
* Session Mode - Unified session state computation
*
* This module provides a single source of truth for determining what mode
* a student's practice session should be in:
*
* - remediation: Student has weak skills that need strengthening
* - progression: Student is ready to learn a new skill
* - maintenance: All skills are strong, mixed practice
*
* The session mode drives:
* - Dashboard banners
* - StartPracticeModal CTA
* - Session planner problem generation
*/
import { computeBktFromHistory, DEFAULT_BKT_OPTIONS } from '@/lib/curriculum/bkt'
import { BKT_THRESHOLDS } from '@/lib/curriculum/config/bkt-integration'
import { WEAK_SKILL_THRESHOLDS } from '@/lib/curriculum/config'
import { ALL_PHASES, type CurriculumPhase } from '@/lib/curriculum/definitions'
import {
getPracticingSkills,
getSkillTutorialProgress,
isSkillTutorialSatisfied,
} from '@/lib/curriculum/progress-manager'
import { getRecentSessionResults } from '@/lib/curriculum/session-planner'
import { SKILL_TUTORIAL_CONFIGS, getSkillDisplayName } from './skill-tutorial-config'
// ============================================================================
// Types
// ============================================================================
/**
* Information about a skill for display purposes
*/
export interface SkillInfo {
skillId: string
displayName: string
/** P(known) from BKT, 0-1 */
pKnown: number
}
/**
* Information about a blocked promotion (when remediation is needed)
*/
export interface BlockedPromotion {
/** The skill the student would learn if not blocked */
nextSkill: SkillInfo
/** Human-readable reason for the block */
reason: string
/** The curriculum phase of the blocked skill */
phase: CurriculumPhase
/** Whether the tutorial is already satisfied */
tutorialReady: boolean
}
/**
* Remediation mode - student has weak skills that need work
*/
export interface RemediationMode {
type: 'remediation'
/** Skills that need strengthening (sorted by pKnown ascending) */
weakSkills: SkillInfo[]
/** Description for the session header */
focusDescription: string
/** What promotion is being blocked, if any */
blockedPromotion?: BlockedPromotion
}
/**
* Progression mode - student is ready to learn a new skill
*/
export interface ProgressionMode {
type: 'progression'
/** The skill to learn next */
nextSkill: SkillInfo
/** The curriculum phase */
phase: CurriculumPhase
/** Whether a tutorial is required before practicing */
tutorialRequired: boolean
/** Number of times the student has skipped this tutorial */
skipCount: number
/** Description for the session header */
focusDescription: string
}
/**
* Maintenance mode - all skills are strong, mixed practice
*/
export interface MaintenanceMode {
type: 'maintenance'
/** Description for the session header */
focusDescription: string
/** Number of skills being maintained */
skillCount: number
}
/**
* The unified session mode
*/
export type SessionMode = RemediationMode | ProgressionMode | MaintenanceMode
// ============================================================================
// Session Mode Computation
// ============================================================================
/**
* Compute the session mode for a student.
*
* This is the single source of truth for what type of session should be run.
* The result drives dashboard display, modal CTA, and problem generation.
*
* Logic:
* 1. Compute BKT to identify weak and strong skills
* 2. If weak skills exist remediation mode
* 3. Else, find next skill in curriculum:
* - If found progression mode
* - If not found maintenance mode
*
* @param playerId - The player to compute mode for
* @returns The session mode
*/
export async function getSessionMode(playerId: string): Promise<SessionMode> {
// 1. Get BKT results for all practiced skills
const history = await getRecentSessionResults(playerId, 100)
const bktResults = computeBktFromHistory(history, {
...DEFAULT_BKT_OPTIONS,
confidenceThreshold: BKT_THRESHOLDS.confidence,
})
// 2. Identify weak skills (confident that P(known) is low)
const { confidenceThreshold, pKnownThreshold } = WEAK_SKILL_THRESHOLDS
const weakSkills: SkillInfo[] = bktResults.skills
.filter((s) => s.confidence >= confidenceThreshold && s.pKnown < pKnownThreshold)
.sort((a, b) => a.pKnown - b.pKnown) // Weakest first
.map((s) => ({
skillId: s.skillId,
displayName: getSkillDisplayName(s.skillId),
pKnown: s.pKnown,
}))
// 3. Find strong skills for maintenance mode counting
const strongSkillIds = new Set(
bktResults.skills.filter((s) => s.masteryClassification === 'strong').map((s) => s.skillId)
)
// 4. Get currently practicing skills
const practicing = await getPracticingSkills(playerId)
const practicingIds = new Set(practicing.map((s) => s.skillId))
// 5. Find the next skill in curriculum (if any)
let nextSkillInfo: {
skillId: string
phase: CurriculumPhase
tutorialReady: boolean
skipCount: number
} | null = null
for (const phase of ALL_PHASES) {
const skillId = phase.primarySkillId
// Skip if no tutorial config (not a learnable skill)
if (!SKILL_TUTORIAL_CONFIGS[skillId]) {
continue
}
// Strong? Skip - they know it
if (strongSkillIds.has(skillId)) {
continue
}
// Currently practicing? They're working on it
if (practicingIds.has(skillId)) {
break // Stop looking, they're actively working on something
}
// Found first non-strong, unpracticed skill!
const tutorialProgress = await getSkillTutorialProgress(playerId, skillId)
const tutorialReady = await isSkillTutorialSatisfied(playerId, skillId)
nextSkillInfo = {
skillId,
phase,
tutorialReady,
skipCount: tutorialProgress?.skipCount ?? 0,
}
break
}
// 6. Determine mode based on weak skills and next skill
if (weakSkills.length > 0) {
// REMEDIATION MODE
const weakSkillNames = weakSkills.slice(0, 3).map((s) => s.displayName)
const moreCount = weakSkills.length - 3
const skillList =
moreCount > 0
? `${weakSkillNames.join(', ')} +${moreCount} more`
: weakSkillNames.join(weakSkills.length === 2 ? ' and ' : ', ')
const focusDescription = `Strengthening: ${skillList}`
// Check if there's a promotion being blocked
let blockedPromotion: BlockedPromotion | undefined
if (nextSkillInfo) {
const nextSkillDisplay = getSkillDisplayName(nextSkillInfo.skillId)
blockedPromotion = {
nextSkill: {
skillId: nextSkillInfo.skillId,
displayName: nextSkillDisplay,
pKnown: 0, // Not yet practiced
},
reason: `Strengthen ${weakSkillNames.slice(0, 2).join(' and ')} first`,
phase: nextSkillInfo.phase,
tutorialReady: nextSkillInfo.tutorialReady,
}
}
return {
type: 'remediation',
weakSkills,
focusDescription,
blockedPromotion,
}
}
if (nextSkillInfo) {
// PROGRESSION MODE
const nextSkillDisplay = getSkillDisplayName(nextSkillInfo.skillId)
return {
type: 'progression',
nextSkill: {
skillId: nextSkillInfo.skillId,
displayName: nextSkillDisplay,
pKnown: 0, // Not yet practiced
},
phase: nextSkillInfo.phase,
tutorialRequired: !nextSkillInfo.tutorialReady,
skipCount: nextSkillInfo.skipCount,
focusDescription: `Learning: ${nextSkillDisplay}`,
}
}
// MAINTENANCE MODE
return {
type: 'maintenance',
focusDescription: 'Mixed practice',
skillCount: practicingIds.size,
}
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Check if session mode indicates remediation is needed
*/
export function isRemediationMode(mode: SessionMode): mode is RemediationMode {
return mode.type === 'remediation'
}
/**
* Check if session mode indicates progression is available
*/
export function isProgressionMode(mode: SessionMode): mode is ProgressionMode {
return mode.type === 'progression'
}
/**
* Check if session mode indicates maintenance
*/
export function isMaintenanceMode(mode: SessionMode): mode is MaintenanceMode {
return mode.type === 'maintenance'
}
/**
* Get the weak skill IDs from a session mode (for session planner)
*/
export function getWeakSkillIds(mode: SessionMode): string[] {
if (mode.type === 'remediation') {
return mode.weakSkills.map((s) => s.skillId)
}
return []
}