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