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' 'use client'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav' import { PageWithNav } from '@/components/PageWithNav'
@ -9,6 +10,7 @@ import {
type CurrentPhaseInfo, type CurrentPhaseInfo,
PracticeSubNav, PracticeSubNav,
ProgressDashboard, ProgressDashboard,
SessionModeBanner,
StartPracticeModal, StartPracticeModal,
type StudentWithProgress, type StudentWithProgress,
} from '@/components/practice' } from '@/components/practice'
@ -28,6 +30,7 @@ import {
import type { Player } from '@/db/schema/players' import type { Player } from '@/db/schema/players'
import type { PracticeSession } from '@/db/schema/practice-sessions' import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans' import type { SessionPlan } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum' import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum'
import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan' import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
import { import {
@ -1369,9 +1372,11 @@ function SkillsTab({
function HistoryTab({ function HistoryTab({
isDark, isDark,
recentSessions, recentSessions,
studentId,
}: { }: {
isDark: boolean isDark: boolean
recentSessions: PracticeSession[] recentSessions: PracticeSession[]
studentId: string
}) { }) {
return ( return (
<div data-tab-content="history"> <div data-tab-content="history">
@ -1420,14 +1425,26 @@ function HistoryTab({
})} })}
> >
{recentSessions.slice(0, 10).map((session) => ( {recentSessions.slice(0, 10).map((session) => (
<div <Link
key={session.id} key={session.id}
href={`/practice/${studentId}/session/${session.id}`}
data-element="session-history-item"
data-session-id={session.id}
className={css({ className={css({
display: 'block',
padding: '1rem', padding: '1rem',
borderRadius: '8px', borderRadius: '8px',
backgroundColor: isDark ? 'gray.700' : 'white', backgroundColor: isDark ? 'gray.700' : 'white',
border: '1px solid', border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200', 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 <div
@ -1488,7 +1505,7 @@ function HistoryTab({
% accuracy % accuracy
</span> </span>
</div> </div>
</div> </Link>
))} ))}
</div> </div>
)} )}
@ -1753,6 +1770,9 @@ export function DashboardClient({
const setMasteredSkillsMutation = useSetMasteredSkills() const setMasteredSkillsMutation = useSetMasteredSkills()
const refreshSkillMutation = useRefreshSkillRecency() 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 // Tab state - sync with URL
const [activeTab, setActiveTab] = useState<TabId>(initialTab) const [activeTab, setActiveTab] = useState<TabId>(initialTab)
@ -1916,6 +1936,18 @@ export function DashboardClient({
})} })}
> >
<div className={css({ maxWidth: '900px', margin: '0 auto' })}> <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} /> <TabNavigation activeTab={activeTab} onTabChange={handleTabChange} isDark={isDark} />
{activeTab === 'overview' && ( {activeTab === 'overview' && (
@ -1945,7 +1977,7 @@ export function DashboardClient({
)} )}
{activeTab === 'history' && ( {activeTab === 'history' && (
<HistoryTab isDark={isDark} recentSessions={recentSessions} /> <HistoryTab isDark={isDark} recentSessions={recentSessions} studentId={studentId} />
)} )}
{activeTab === 'notes' && ( {activeTab === 'notes' && (
@ -1980,11 +2012,12 @@ export function DashboardClient({
/> />
</main> </main>
{showStartPracticeModal && ( {showStartPracticeModal && sessionMode && (
<StartPracticeModal <StartPracticeModal
studentId={studentId} studentId={studentId}
studentName={player.name} studentName={player.name}
focusDescription={currentPhase.phaseName} focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem} avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={activeSession} existingPlan={activeSession}
problemHistory={problemHistory} problemHistory={problemHistory}

View File

@ -5,6 +5,7 @@ import { useCallback, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav' import { PageWithNav } from '@/components/PageWithNav'
import { import {
PracticeSubNav, PracticeSubNav,
SessionModeBanner,
SessionOverview, SessionOverview,
SessionSummary, SessionSummary,
StartPracticeModal, StartPracticeModal,
@ -12,6 +13,7 @@ import {
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players' import type { Player } from '@/db/schema/players'
import type { SessionPlan } from '@/db/schema/session-plans' import type { SessionPlan } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner' import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { css } from '../../../../../styled-system/css' import { css } from '../../../../../styled-system/css'
@ -48,6 +50,9 @@ export function SummaryClient({
const [showStartPracticeModal, setShowStartPracticeModal] = useState(false) const [showStartPracticeModal, setShowStartPracticeModal] = useState(false)
const [viewMode, setViewMode] = useState<'summary' | 'debug'>('summary') 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 const isInProgress = session?.startedAt && !session?.completedAt
// Handle practice again - show the start practice modal // Handle practice again - show the start practice modal
@ -117,6 +122,18 @@ export function SummaryClient({
</p> </p>
</header> </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) */} {/* View Mode Toggle (only show when there's a session) */}
{session && ( {session && (
<div <div
@ -230,11 +247,12 @@ export function SummaryClient({
</main> </main>
{/* Start Practice Modal */} {/* Start Practice Modal */}
{showStartPracticeModal && ( {showStartPracticeModal && sessionMode && (
<StartPracticeModal <StartPracticeModal
studentId={studentId} studentId={studentId}
studentName={player.name} studentName={player.name}
focusDescription="Continue practicing" focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem} avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={null} existingPlan={null}
problemHistory={problemHistory} 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 type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@/contexts/ThemeContext' import { ThemeProvider } from '@/contexts/ThemeContext'
import type {
MaintenanceMode,
ProgressionMode,
RemediationMode,
} from '@/lib/curriculum/session-mode'
import { StartPracticeModal } from './StartPracticeModal' import { StartPracticeModal } from './StartPracticeModal'
import { css } from '../../../styled-system/css' import { css } from '../../../styled-system/css'
@ -67,11 +72,48 @@ const meta: Meta<typeof StartPracticeModal> = {
export default meta export default meta
type Story = StoryObj<typeof StartPracticeModal> 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 // Default props
const defaultProps = { const defaultProps = {
studentId: 'test-student-1', studentId: 'test-student-1',
studentName: 'Sonia', studentName: 'Sonia',
focusDescription: 'Five Complements Addition', focusDescription: 'Mixed practice',
sessionMode: mockMaintenanceMode,
secondsPerTerm: 4, secondsPerTerm: 4,
onClose: () => console.log('Modal closed'), onClose: () => console.log('Modal closed'),
onStarted: () => console.log('Practice started'), 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: () => ( render: () => (
<StoryWrapper> <StoryWrapper>
<StartPracticeModal <StartPracticeModal
{...defaultProps} {...defaultProps}
studentName="Alex" studentName="Alex"
secondsPerTerm={8} sessionMode={mockRemediationMode}
focusDescription="Ten Complements Addition" focusDescription={mockRemediationMode.focusDescription}
/> />
</StoryWrapper> </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: () => ( render: () => (
<StoryWrapper> <StoryWrapper>
<StartPracticeModal <StartPracticeModal
{...defaultProps} {...defaultProps}
studentName="Maya" studentName="Maya"
secondsPerTerm={2} sessionMode={mockProgressionMode}
focusDescription="Basic Addition" focusDescription={mockProgressionMode.focusDescription}
/> />
</StoryWrapper> </StoryWrapper>
), ),
} }
/** /**
* Note: The tutorial gate feature requires the useNextSkillToLearn hook * Documentation note about the SessionMode system
* 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
*/ */
export const DocumentationNote: Story = { export const DocumentationNote: Story = {
render: () => ( render: () => (
@ -199,21 +230,26 @@ export const DocumentationNote: Story = {
})} })}
> >
<h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' })}> <h2 className={css({ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' })}>
Tutorial Gate Feature Session Mode System
</h2> </h2>
<p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}> <p className={css({ marginBottom: '1rem', lineHeight: 1.6 })}>
The StartPracticeModal includes a <strong>tutorial gate</strong> that appears when a The StartPracticeModal receives a <strong>sessionMode</strong> prop that determines the
student has a new skill ready to learn. This feature: type of session:
</p> </p>
<ul className={css({ paddingLeft: '1.5rem', marginBottom: '1rem', lineHeight: 1.8 })}> <ul className={css({ paddingLeft: '1.5rem', marginBottom: '1rem', lineHeight: 1.8 })}>
<li>Shows a green banner with the skill name</li> <li>
<li>Offers "Learn Now" to start the tutorial</li> <strong>Maintenance:</strong> All skills are strong, mixed practice
<li>Offers "Practice without it" to skip</li> </li>
<li>Tracks skip count for teacher visibility</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> </ul>
<p className={css({ fontSize: '0.875rem', color: 'gray.600', fontStyle: 'italic' })}> <p className={css({ fontSize: '0.875rem', color: 'gray.600', fontStyle: 'italic' })}>
Note: This feature requires API mocking to demonstrate in Storybook. See The sessionMode is fetched via useSessionMode() hook and passed to the modal. See
SkillTutorialLauncher stories for the tutorial UI itself. SessionModeBanner stories for the dashboard banner component.
</p> </p>
</div> </div>
</StoryWrapper> </StoryWrapper>

View File

@ -15,15 +15,14 @@ import {
useGenerateSessionPlan, useGenerateSessionPlan,
useStartSessionPlan, useStartSessionPlan,
} from '@/hooks/useSessionPlan' } from '@/hooks/useSessionPlan'
import { nextSkillKeys, useNextSkillToLearn } from '@/hooks/useNextSkillToLearn' import { sessionModeKeys } from '@/hooks/useSessionMode'
import type { SessionMode } from '@/lib/curriculum/session-mode'
import { import {
convertSecondsPerProblemToSpt, convertSecondsPerProblemToSpt,
estimateSessionProblemCount, estimateSessionProblemCount,
TIME_ESTIMATION_DEFAULTS, TIME_ESTIMATION_DEFAULTS,
} from '@/lib/curriculum/time-estimation' } from '@/lib/curriculum/time-estimation'
import { getSkillTutorialConfig, getSkillDisplayName } from '@/lib/curriculum/skill-tutorial-config' import { getSkillTutorialConfig } from '@/lib/curriculum/skill-tutorial-config'
import { computeBktFromHistory } from '@/lib/curriculum/bkt'
import { WEAK_SKILL_THRESHOLDS } from '@/lib/curriculum/config/bkt-integration'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner' import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { css } from '../../../styled-system/css' import { css } from '../../../styled-system/css'
import { SkillTutorialLauncher } from '../tutorial/SkillTutorialLauncher' import { SkillTutorialLauncher } from '../tutorial/SkillTutorialLauncher'
@ -32,6 +31,8 @@ interface StartPracticeModalProps {
studentId: string studentId: string
studentName: string studentName: string
focusDescription: string focusDescription: string
/** Session mode - single source of truth for what type of session to run */
sessionMode: SessionMode
/** Seconds per term - the primary time metric from the estimation utility */ /** Seconds per term - the primary time metric from the estimation utility */
secondsPerTerm?: number secondsPerTerm?: number
/** @deprecated Use secondsPerTerm instead. This will be converted automatically. */ /** @deprecated Use secondsPerTerm instead. This will be converted automatically. */
@ -56,6 +57,7 @@ export function StartPracticeModal({
studentId, studentId,
studentName, studentName,
focusDescription, focusDescription,
sessionMode,
secondsPerTerm: secondsPerTermProp, secondsPerTerm: secondsPerTermProp,
avgSecondsPerProblem, avgSecondsPerProblem,
existingPlan, existingPlan,
@ -72,18 +74,18 @@ export function StartPracticeModal({
// Tutorial gate state // Tutorial gate state
const [showTutorial, setShowTutorial] = useState(false) const [showTutorial, setShowTutorial] = useState(false)
// Fetch next skill to learn // Derive tutorial info from sessionMode (no separate hook needed)
const { data: nextSkill, isLoading: isLoadingNextSkill } = useNextSkillToLearn(studentId)
// Get the tutorial config if there's a skill ready to learn
const tutorialConfig = useMemo(() => { const tutorialConfig = useMemo(() => {
if (!nextSkill || nextSkill.tutorialReady) return null if (sessionMode.type !== 'progression' || !sessionMode.tutorialRequired) return null
return getSkillTutorialConfig(nextSkill.skillId) return getSkillTutorialConfig(sessionMode.nextSkill.skillId)
}, [nextSkill]) }, [sessionMode])
// Whether to show the tutorial gate prompt // Whether to show the tutorial gate prompt
const showTutorialGate = !!tutorialConfig && !showTutorial const showTutorialGate = !!tutorialConfig && !showTutorial
// Get skill info for tutorial from sessionMode
const nextSkill = sessionMode.type === 'progression' ? sessionMode.nextSkill : null
// Derive secondsPerTerm: prefer direct prop, fall back to converting avgSecondsPerProblem, then default // Derive secondsPerTerm: prefer direct prop, fall back to converting avgSecondsPerProblem, then default
const secondsPerTerm = useMemo(() => { const secondsPerTerm = useMemo(() => {
if (secondsPerTermProp !== undefined) return secondsPerTermProp if (secondsPerTermProp !== undefined) return secondsPerTermProp
@ -160,41 +162,18 @@ export function StartPracticeModal({
} }
}, [enabledParts]) }, [enabledParts])
// Compute weak skills from BKT - these are the ACTUAL skills that get plugged into // Derive target skills from sessionMode (no duplicate BKT computation)
// the problem generator's targetSkills. Only skills with pKnown < 0.5 AND confidence >= 0.3
// are targeted. Skills with 0.5-0.8 pKnown are NOT targeted - they just appear naturally
// in the even distribution across all practicing skills.
const targetSkillsInfo = useMemo(() => { const targetSkillsInfo = useMemo(() => {
if (!problemHistory || problemHistory.length === 0) { if (sessionMode.type === 'remediation') {
return { targetedSkills: [], hasData: false } // In remediation mode, we have the weak skills to target
} return {
targetedSkills: sessionMode.weakSkills,
const bktResult = computeBktFromHistory(problemHistory, { hasData: true,
confidenceThreshold: WEAK_SKILL_THRESHOLDS.confidenceThreshold,
})
const targetedSkills: Array<{ skillId: string; displayName: string; pKnown: number }> = []
for (const skill of bktResult.skills) {
// Only skills with confidence >= 0.3 AND pKnown < 0.5 get TARGETED
// This matches identifyWeakSkills() in session-planner.ts exactly
if (
skill.confidence >= WEAK_SKILL_THRESHOLDS.confidenceThreshold &&
skill.pKnown < WEAK_SKILL_THRESHOLDS.pKnownThreshold
) {
targetedSkills.push({
skillId: skill.skillId,
displayName: getSkillDisplayName(skill.skillId),
pKnown: skill.pKnown,
})
} }
} }
// In progression or maintenance mode, no specific targeting
// Sort by pKnown ascending (weakest first) return { targetedSkills: [], hasData: true }
targetedSkills.sort((a, b) => a.pKnown - b.pKnown) }, [sessionMode])
return { targetedSkills, hasData: true }
}, [problemHistory])
const generatePlan = useGenerateSessionPlan() const generatePlan = useGenerateSessionPlan()
const approvePlan = useApproveSessionPlan() const approvePlan = useApproveSessionPlan()
@ -264,11 +243,11 @@ export function StartPracticeModal({
onStarted, onStarted,
]) ])
// Handle tutorial completion - refresh next skill query and proceed to practice // Handle tutorial completion - refresh session mode query and proceed to practice
const handleTutorialComplete = useCallback(() => { const handleTutorialComplete = useCallback(() => {
setShowTutorial(false) setShowTutorial(false)
// Invalidate the next skill query to refresh state // Invalidate the session mode query to refresh state
queryClient.invalidateQueries({ queryKey: nextSkillKeys.forPlayer(studentId) }) queryClient.invalidateQueries({ queryKey: sessionModeKeys.forPlayer(studentId) })
// Proceed with starting practice // Proceed with starting practice
handleStart() handleStart()
}, [queryClient, studentId, handleStart]) }, [queryClient, studentId, handleStart])
@ -907,7 +886,6 @@ export function StartPracticeModal({
}, },
})} })}
> >
{/* Duration options */} {/* Duration options */}
<div data-setting="duration"> <div data-setting="duration">
<div <div
@ -927,12 +905,19 @@ export function StartPracticeModal({
> >
Duration Duration
</div> </div>
<div data-element="duration-options" className={css({ display: 'flex', gap: '0.375rem', '@media (max-height: 700px)': { gap: '0.25rem' } })}> <div
data-element="duration-options"
className={css({
display: 'flex',
gap: '0.375rem',
'@media (max-height: 700px)': { gap: '0.25rem' },
})}
>
{[5, 10, 15, 20].map((min) => { {[5, 10, 15, 20].map((min) => {
// Estimate problems for this duration using current settings // Estimate problems for this duration using current settings
const enabledPartTypes = PART_TYPES.filter((p) => enabledParts[p.type]).map( const enabledPartTypes = PART_TYPES.filter(
(p) => p.type (p) => enabledParts[p.type]
) ).map((p) => p.type)
const minutesPerPart = const minutesPerPart =
enabledPartTypes.length > 0 ? min / enabledPartTypes.length : min enabledPartTypes.length > 0 ? min / enabledPartTypes.length : min
let problems = 0 let problems = 0
@ -1040,7 +1025,14 @@ export function StartPracticeModal({
> >
Practice Modes Practice Modes
</div> </div>
<div data-element="modes-options" className={css({ display: 'flex', gap: '0.375rem', '@media (max-height: 700px)': { gap: '0.25rem' } })}> <div
data-element="modes-options"
className={css({
display: 'flex',
gap: '0.375rem',
'@media (max-height: 700px)': { gap: '0.25rem' },
})}
>
{PART_TYPES.map(({ type, emoji, label }) => { {PART_TYPES.map(({ type, emoji, label }) => {
const isEnabled = enabledParts[type] const isEnabled = enabledParts[type]
const problemCount = problemsPerType[type] const problemCount = problemsPerType[type]
@ -1118,13 +1110,15 @@ export function StartPracticeModal({
</span> </span>
)} )}
{/* Emoji */} {/* Emoji */}
<span className={css({ <span
className={css({
fontSize: '1.5rem', fontSize: '1.5rem',
lineHeight: 1, lineHeight: 1,
'@media (max-height: 700px)': { '@media (max-height: 700px)': {
fontSize: '1.25rem', fontSize: '1.25rem',
}, },
})}> })}
>
{emoji} {emoji}
</span> </span>
<span <span
@ -1164,7 +1158,14 @@ export function StartPracticeModal({
> >
Numbers per problem Numbers per problem
</div> </div>
<div data-element="terms-options" className={css({ display: 'flex', gap: '0.25rem', '@media (max-height: 700px)': { gap: '0.125rem' } })}> <div
data-element="terms-options"
className={css({
display: 'flex',
gap: '0.25rem',
'@media (max-height: 700px)': { gap: '0.125rem' },
})}
>
{[3, 4, 5, 6, 7, 8].map((terms) => { {[3, 4, 5, 6, 7, 8].map((terms) => {
const isSelected = abacusMaxTerms === terms const isSelected = abacusMaxTerms === terms
return ( return (
@ -1298,9 +1299,8 @@ export function StartPracticeModal({
)} )}
</div> </div>
)} )}
</div>
</div>{/* End settings-grid */} {/* End settings-grid */}
</div> </div>
</div> </div>
</div> </div>
@ -1541,7 +1541,8 @@ export function StartPracticeModal({
)} )}
</button> </button>
)} )}
</div>{/* End config-and-action wrapper */} </div>
{/* End config-and-action wrapper */}
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>

View File

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