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:
parent
bb9506b93e
commit
b345baf3c4
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 []
|
||||
}
|
||||
Loading…
Reference in New Issue