From b345baf3c465a745939e0ae159089978be776b14 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 18 Dec 2025 13:56:15 -0600 Subject: [PATCH] feat(practice): add unified SessionMode system for consistent skill targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[playerId]/session-mode/route.ts | 46 + .../[studentId]/dashboard/DashboardClient.tsx | 43 +- .../[studentId]/summary/SummaryClient.tsx | 22 +- .../practice/SessionModeBanner.stories.tsx | 518 +++++ .../components/practice/SessionModeBanner.tsx | 486 +++++ .../practice/StartPracticeModal.stories.tsx | 96 +- .../practice/StartPracticeModal.tsx | 1881 +++++++++-------- apps/web/src/components/practice/index.ts | 2 + apps/web/src/hooks/useSessionMode.ts | 67 + apps/web/src/lib/curriculum/session-mode.ts | 285 +++ 10 files changed, 2469 insertions(+), 977 deletions(-) create mode 100644 apps/web/src/app/api/curriculum/[playerId]/session-mode/route.ts create mode 100644 apps/web/src/components/practice/SessionModeBanner.stories.tsx create mode 100644 apps/web/src/components/practice/SessionModeBanner.tsx create mode 100644 apps/web/src/hooks/useSessionMode.ts create mode 100644 apps/web/src/lib/curriculum/session-mode.ts diff --git a/apps/web/src/app/api/curriculum/[playerId]/session-mode/route.ts b/apps/web/src/app/api/curriculum/[playerId]/session-mode/route.ts new file mode 100644 index 00000000..801f90c7 --- /dev/null +++ b/apps/web/src/app/api/curriculum/[playerId]/session-mode/route.ts @@ -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 }) + } +} diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx index 67bc6df8..0901ddb8 100644 --- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -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 (
@@ -1420,14 +1425,26 @@ function HistoryTab({ })} > {recentSessions.slice(0, 10).map((session) => ( -
-
+ ))}
)} @@ -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(initialTab) @@ -1916,6 +1936,18 @@ export function DashboardClient({ })} >
+ {/* Session mode banner - handles celebration wind-down internally */} + {sessionMode && ( +
+ +
+ )} + {activeTab === 'overview' && ( @@ -1945,7 +1977,7 @@ export function DashboardClient({ )} {activeTab === 'history' && ( - + )} {activeTab === 'notes' && ( @@ -1980,11 +2012,12 @@ export function DashboardClient({ /> - {showStartPracticeModal && ( + {showStartPracticeModal && sessionMode && ( ('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({

+ {/* Session mode banner - handles celebration wind-down internally */} + {sessionMode && ( +
+ +
+ )} + {/* View Mode Toggle (only show when there's a session) */} {session && (
{/* Start Practice Modal */} - {showStartPracticeModal && ( + {showStartPracticeModal && sessionMode && ( = { + title: 'Practice/SessionModeBanner', + component: SessionModeBanner, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + layout: 'centered', + }, + argTypes: { + variant: { + control: 'select', + options: ['dashboard', 'modal'], + }, + isLoading: { + control: 'boolean', + }, + }, +} + +export default meta +type Story = StoryObj + +// ============================================================================ +// 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: () => ( +
+
+

+ Remediation (with blocked promotion) +

+ alert('Starting remediation')} + variant="dashboard" + /> +
+ +
+

+ Progression (tutorial required) +

+ alert('Starting progression')} + variant="dashboard" + /> +
+ +
+

+ Maintenance +

+ alert('Starting maintenance')} + variant="dashboard" + /> +
+
+ ), +} + +export const AllModesModal: Story = { + render: () => ( +
+
+

+ Remediation (with blocked promotion) +

+ alert('Starting remediation')} + variant="modal" + /> +
+ +
+

+ Progression (tutorial required) +

+ alert('Starting progression')} + variant="modal" + /> +
+ +
+

+ Maintenance +

+ alert('Starting maintenance')} + variant="modal" + /> +
+
+ ), +} + +// ============================================================================ +// Dark Mode Stories +// ============================================================================ + +export const DarkModeRemediation: Story = { + parameters: { + backgrounds: { default: 'dark' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + sessionMode: mockRemediationMode, + onAction: () => alert('Starting remediation practice'), + variant: 'dashboard', + }, +} + +export const DarkModeProgression: Story = { + parameters: { + backgrounds: { default: 'dark' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + sessionMode: mockProgressionModeWithTutorial, + onAction: () => alert('Starting tutorial'), + variant: 'dashboard', + }, +} + +export const DarkModeMaintenance: Story = { + parameters: { + backgrounds: { default: 'dark' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + 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', + }, +} diff --git a/apps/web/src/components/practice/SessionModeBanner.tsx b/apps/web/src/components/practice/SessionModeBanner.tsx new file mode 100644 index 00000000..b2956061 --- /dev/null +++ b/apps/web/src/components/practice/SessionModeBanner.tsx @@ -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 ( +
+ {/* Info section */} +
+ + {hasBlockedPromotion ? '🔒' : '💪'} + +
+

+ {hasBlockedPromotion ? 'Almost there!' : 'Strengthening skills'} +

+

+ {hasBlockedPromotion ? ( + <> + Strengthen{' '} + + {weakSkillNames.join(' and ')} + {' '} + to unlock{' '} + + {mode.blockedPromotion!.nextSkill.displayName} + + + ) : ( + <> + Targeting:{' '} + + {weakSkillNames.join(', ')} + + + )} +

+ + {/* Progress bar for blocked promotion */} + {hasBlockedPromotion && ( +
+
+
+
+ + {progressPercent}% + +
+ )} +
+
+ + {/* Action button */} + +
+ ) +} + +interface ProgressionBannerProps { + mode: ProgressionMode + onAction: () => void + isLoading: boolean + variant: 'dashboard' | 'modal' + isDark: boolean +} + +function ProgressionBanner({ mode, onAction, isLoading, variant, isDark }: ProgressionBannerProps) { + return ( +
+ {/* Info section */} +
+ + 🌟 + +
+

+ {mode.tutorialRequired ? "You've unlocked: " : 'Ready to practice: '} + {mode.nextSkill.displayName} +

+

+ {mode.tutorialRequired ? 'Start with a quick tutorial' : 'Continue building mastery'} +

+
+
+ + {/* Action button */} + +
+ ) +} + +interface MaintenanceBannerProps { + mode: MaintenanceMode + onAction: () => void + isLoading: boolean + variant: 'dashboard' | 'modal' + isDark: boolean +} + +function MaintenanceBanner({ mode, onAction, isLoading, variant, isDark }: MaintenanceBannerProps) { + return ( +
+ {/* Info section */} +
+ + ✨ + +
+

+ All skills strong! +

+

+ Keep practicing to maintain mastery ({mode.skillCount} skills) +

+
+
+ + {/* Action button */} + +
+ ) +} + +// ============================================================================ +// 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 ( + + ) + 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 ( + + ) + } + return ( + + ) + case 'maintenance': + return ( + + ) + } +} + +export default SessionModeBanner diff --git a/apps/web/src/components/practice/StartPracticeModal.stories.tsx b/apps/web/src/components/practice/StartPracticeModal.stories.tsx index 5dac1cb6..ea4989bc 100644 --- a/apps/web/src/components/practice/StartPracticeModal.stories.tsx +++ b/apps/web/src/components/practice/StartPracticeModal.stories.tsx @@ -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 = { export default meta type Story = StoryObj +// 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: () => ( ), } /** - * 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: () => ( ), } /** - * 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 = { })} >

- Tutorial Gate Feature + Session Mode System

- The StartPracticeModal includes a tutorial gate that appears when a - student has a new skill ready to learn. This feature: + The StartPracticeModal receives a sessionMode prop that determines the + type of session:

    -
  • Shows a green banner with the skill name
  • -
  • Offers "Learn Now" to start the tutorial
  • -
  • Offers "Practice without it" to skip
  • -
  • Tracks skip count for teacher visibility
  • +
  • + Maintenance: All skills are strong, mixed practice +
  • +
  • + Remediation: Weak skills need strengthening (shown in targeting info) +
  • +
  • + Progression: Ready to learn new skill, may include tutorial gate +

- 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.

diff --git a/apps/web/src/components/practice/StartPracticeModal.tsx b/apps/web/src/components/practice/StartPracticeModal.tsx index 3bd3b81e..a0e7ec13 100644 --- a/apps/web/src/components/practice/StartPracticeModal.tsx +++ b/apps/web/src/components/practice/StartPracticeModal.tsx @@ -15,15 +15,14 @@ import { useGenerateSessionPlan, useStartSessionPlan, } from '@/hooks/useSessionPlan' -import { nextSkillKeys, useNextSkillToLearn } from '@/hooks/useNextSkillToLearn' +import { sessionModeKeys } from '@/hooks/useSessionMode' +import type { SessionMode } from '@/lib/curriculum/session-mode' import { convertSecondsPerProblemToSpt, estimateSessionProblemCount, TIME_ESTIMATION_DEFAULTS, } from '@/lib/curriculum/time-estimation' -import { getSkillTutorialConfig, getSkillDisplayName } from '@/lib/curriculum/skill-tutorial-config' -import { computeBktFromHistory } from '@/lib/curriculum/bkt' -import { WEAK_SKILL_THRESHOLDS } from '@/lib/curriculum/config/bkt-integration' +import { getSkillTutorialConfig } from '@/lib/curriculum/skill-tutorial-config' import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner' import { css } from '../../../styled-system/css' import { SkillTutorialLauncher } from '../tutorial/SkillTutorialLauncher' @@ -32,6 +31,8 @@ interface StartPracticeModalProps { studentId: string studentName: 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 */ secondsPerTerm?: number /** @deprecated Use secondsPerTerm instead. This will be converted automatically. */ @@ -56,6 +57,7 @@ export function StartPracticeModal({ studentId, studentName, focusDescription, + sessionMode, secondsPerTerm: secondsPerTermProp, avgSecondsPerProblem, existingPlan, @@ -72,18 +74,18 @@ export function StartPracticeModal({ // Tutorial gate state const [showTutorial, setShowTutorial] = useState(false) - // Fetch next skill to learn - const { data: nextSkill, isLoading: isLoadingNextSkill } = useNextSkillToLearn(studentId) - - // Get the tutorial config if there's a skill ready to learn + // Derive tutorial info from sessionMode (no separate hook needed) const tutorialConfig = useMemo(() => { - if (!nextSkill || nextSkill.tutorialReady) return null - return getSkillTutorialConfig(nextSkill.skillId) - }, [nextSkill]) + if (sessionMode.type !== 'progression' || !sessionMode.tutorialRequired) return null + return getSkillTutorialConfig(sessionMode.nextSkill.skillId) + }, [sessionMode]) // Whether to show the tutorial gate prompt 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 const secondsPerTerm = useMemo(() => { if (secondsPerTermProp !== undefined) return secondsPerTermProp @@ -160,41 +162,18 @@ export function StartPracticeModal({ } }, [enabledParts]) - // Compute weak skills from BKT - these are the ACTUAL skills that get plugged into - // 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. + // Derive target skills from sessionMode (no duplicate BKT computation) const targetSkillsInfo = useMemo(() => { - if (!problemHistory || problemHistory.length === 0) { - return { targetedSkills: [], hasData: false } - } - - const bktResult = computeBktFromHistory(problemHistory, { - 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, - }) + if (sessionMode.type === 'remediation') { + // In remediation mode, we have the weak skills to target + return { + targetedSkills: sessionMode.weakSkills, + hasData: true, } } - - // Sort by pKnown ascending (weakest first) - targetedSkills.sort((a, b) => a.pKnown - b.pKnown) - - return { targetedSkills, hasData: true } - }, [problemHistory]) + // In progression or maintenance mode, no specific targeting + return { targetedSkills: [], hasData: true } + }, [sessionMode]) const generatePlan = useGenerateSessionPlan() const approvePlan = useApproveSessionPlan() @@ -264,11 +243,11 @@ export function StartPracticeModal({ 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(() => { setShowTutorial(false) - // Invalidate the next skill query to refresh state - queryClient.invalidateQueries({ queryKey: nextSkillKeys.forPlayer(studentId) }) + // Invalidate the session mode query to refresh state + queryClient.invalidateQueries({ queryKey: sessionModeKeys.forPlayer(studentId) }) // Proceed with starting practice handleStart() }, [queryClient, studentId, handleStart]) @@ -542,1006 +521,1028 @@ export function StartPracticeModal({ borderRadius: '8px', }, })} - style={{ - background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', - border: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`, - }} - > - {/* Summary view (collapses when expanded) */} -
- + + {/* Target skills summary */} + {targetSkillsInfo.hasData && ( +
+ {targetSkillsInfo.targetedSkills.length > 0 ? ( + <> + + Targeting: + + {targetSkillsInfo.targetedSkills.slice(0, 3).map((skill, i) => ( + + {skill.displayName} + {i < Math.min(targetSkillsInfo.targetedSkills.length, 3) - 1 && ','} + + ))} + {targetSkillsInfo.targetedSkills.length > 3 && ( + + +{targetSkillsInfo.targetedSkills.length - 3} more + + )} + + ) : ( + + Even distribution across all practicing skills + + )} +
+ )} +
+ + {/* Expanded config panel */} +
- {PART_TYPES.filter((p) => enabledParts[p.type]).map(({ type, emoji }) => ( -
+ - - {emoji} - - + +
+ + {/* Settings grid - 2 columns in landscape */} +
+ {/* Duration options */} +
+
- {problemsPerType[type]} - -
- ))} -
- - {/* Expand indicator */} -
- ▼ -
- - - {/* Target skills summary */} - {targetSkillsInfo.hasData && ( -
- {targetSkillsInfo.targetedSkills.length > 0 ? ( - <> - +
- Targeting: - - {targetSkillsInfo.targetedSkills.slice(0, 3).map((skill, i) => ( - - {skill.displayName} - {i < Math.min(targetSkillsInfo.targetedSkills.length, 3) - 1 && ','} - - ))} - {targetSkillsInfo.targetedSkills.length > 3 && ( - - +{targetSkillsInfo.targetedSkills.length - 3} more - - )} - - ) : ( - - Even distribution across all practicing skills - - )} + {[5, 10, 15, 20].map((min) => { + // Estimate problems for this duration using current settings + const enabledPartTypes = PART_TYPES.filter( + (p) => enabledParts[p.type] + ).map((p) => p.type) + const minutesPerPart = + enabledPartTypes.length > 0 ? min / enabledPartTypes.length : min + let problems = 0 + for (const partType of enabledPartTypes) { + problems += estimateSessionProblemCount( + minutesPerPart, + avgTermsPerProblem, + secondsPerTerm, + partType + ) + } + const isSelected = durationMinutes === min + return ( + + ) + })} +
+
+ + {/* Modes */} +
+
+ Practice Modes +
+
+ {PART_TYPES.map(({ type, emoji, label }) => { + const isEnabled = enabledParts[type] + const problemCount = problemsPerType[type] + return ( + + ) + })} +
+
+ + {/* Numbers per problem */} +
+
+ Numbers per problem +
+
+ {[3, 4, 5, 6, 7, 8].map((terms) => { + const isSelected = abacusMaxTerms === terms + return ( + + ) + })} +
+
+ + {/* Target skills info - in the grid for landscape layout */} + {targetSkillsInfo.hasData && ( +
0 + ? 'rgba(245, 158, 11, 0.08)' + : 'rgba(100, 116, 139, 0.08)' + : targetSkillsInfo.targetedSkills.length > 0 + ? 'rgba(245, 158, 11, 0.06)' + : 'rgba(100, 116, 139, 0.06)', + border: `1px solid ${ + isDark + ? targetSkillsInfo.targetedSkills.length > 0 + ? 'rgba(245, 158, 11, 0.2)' + : 'rgba(100, 116, 139, 0.2)' + : targetSkillsInfo.targetedSkills.length > 0 + ? 'rgba(245, 158, 11, 0.15)' + : 'rgba(100, 116, 139, 0.15)' + }`, + })} + > + {targetSkillsInfo.targetedSkills.length > 0 ? ( + <> +
+ Focusing on weak skills: +
+
+ {targetSkillsInfo.targetedSkills.map((skill) => ( + + {skill.displayName}{' '} + + ({Math.round(skill.pKnown * 100)}%) + + + ))} +
+ + ) : ( +
+ ✓ On track! Problems will be evenly distributed across all skills. +
+ )} +
+ )} +
+ {/* End settings-grid */}
- )} +
- {/* Expanded config panel */} -
+ {/* Tutorial CTA - New skill unlocked with integrated start button */} + {showTutorialGate && tutorialConfig && nextSkill && (
- {/* Expanded header with collapse button */} + {/* Info section */}
- Session Settings + 🌟 - +
+

+ You've unlocked: {tutorialConfig.title} +

+

+ Start with a quick tutorial +

+
- - {/* Settings grid - 2 columns in landscape */} -
setShowTutorial(true)} + disabled={isStarting} className={css({ + width: '100%', + padding: '0.875rem', + fontSize: '1rem', + fontWeight: 'bold', + color: 'white', + border: 'none', + borderRadius: '0 0 10px 10px', + cursor: isStarting ? 'not-allowed' : 'pointer', + transition: 'all 0.2s ease', display: 'flex', - flexDirection: 'column', - gap: '0.875rem', - '@media (max-height: 700px)': { - gap: '0.375rem', + alignItems: 'center', + justifyContent: 'center', + gap: '0.5rem', + _hover: { + filter: isStarting ? 'none' : 'brightness(1.05)', }, - '@media (max-height: 500px) and (min-width: 500px)': { - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: '0.5rem', + '@media (max-height: 700px)': { + padding: '0.75rem', + fontSize: '0.9375rem', }, })} + style={{ + background: isStarting + ? '#9ca3af' + : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', + boxShadow: isStarting ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)', + }} > - - {/* Duration options */} -
-
- Duration -
-
- {[5, 10, 15, 20].map((min) => { - // Estimate problems for this duration using current settings - const enabledPartTypes = PART_TYPES.filter((p) => enabledParts[p.type]).map( - (p) => p.type - ) - const minutesPerPart = - enabledPartTypes.length > 0 ? min / enabledPartTypes.length : min - let problems = 0 - for (const partType of enabledPartTypes) { - problems += estimateSessionProblemCount( - minutesPerPart, - avgTermsPerProblem, - secondsPerTerm, - partType - ) - } - const isSelected = durationMinutes === min - return ( - - ) - })} -
-
- - {/* Modes */} -
-
- Practice Modes -
-
- {PART_TYPES.map(({ type, emoji, label }) => { - const isEnabled = enabledParts[type] - const problemCount = problemsPerType[type] - return ( - - ) - })} -
-
- - {/* Numbers per problem */} -
-
- Numbers per problem -
-
- {[3, 4, 5, 6, 7, 8].map((terms) => { - const isSelected = abacusMaxTerms === terms - return ( - - ) - })} -
-
- - {/* Target skills info - in the grid for landscape layout */} - {targetSkillsInfo.hasData && ( -
0 - ? 'rgba(245, 158, 11, 0.08)' - : 'rgba(100, 116, 139, 0.08)' - : targetSkillsInfo.targetedSkills.length > 0 - ? 'rgba(245, 158, 11, 0.06)' - : 'rgba(100, 116, 139, 0.06)', - border: `1px solid ${ - isDark - ? targetSkillsInfo.targetedSkills.length > 0 - ? 'rgba(245, 158, 11, 0.2)' - : 'rgba(100, 116, 139, 0.2)' - : targetSkillsInfo.targetedSkills.length > 0 - ? 'rgba(245, 158, 11, 0.15)' - : 'rgba(100, 116, 139, 0.15)' - }`, - })} - > - {targetSkillsInfo.targetedSkills.length > 0 ? ( - <> -
- Focusing on weak skills: -
-
- {targetSkillsInfo.targetedSkills.map((skill) => ( - - {skill.displayName}{' '} - - ({Math.round(skill.pKnown * 100)}%) - - - ))} -
- - ) : ( -
- ✓ On track! Problems will be evenly distributed across all skills. -
- )} -
- )} - -
{/* End settings-grid */} - + {isStarting ? ( + 'Starting...' + ) : ( + <> + 🎓 + Begin Tutorial + + + )} +
-
-
+ )} - {/* Tutorial CTA - New skill unlocked with integrated start button */} - {showTutorialGate && tutorialConfig && nextSkill && ( -
- {/* Info section */} + {/* Error display */} + {displayError && (
- - 🌟 - -
+ {isNoSkillsError ? ( + <> +

+ ⚠️ No skills enabled +

+

+ Please enable at least one skill in the skill selector before starting a + session. +

+ + ) : (

- You've unlocked: {tutorialConfig.title} + {displayError.message || 'Something went wrong. Please try again.'}

-

- Start with a quick tutorial -

-
+ )}
- {/* Integrated start button */} + )} + + {/* Start button - only shown when no tutorial is pending */} + {!showTutorialGate && ( -
- )} - - {/* Error display */} - {displayError && ( -
- {isNoSkillsError ? ( - <> -

- ⚠️ No skills enabled -

-

- Please enable at least one skill in the skill selector before starting a - session. -

- - ) : ( -

- {displayError.message || 'Something went wrong. Please try again.'} -

- )} -
- )} - - {/* Start button - only shown when no tutorial is pending */} - {!showTutorialGate && ( - - )} - {/* End config-and-action wrapper */} + )} + + {/* End config-and-action wrapper */} diff --git a/apps/web/src/components/practice/index.ts b/apps/web/src/components/practice/index.ts index 26ee7585..180e98f1 100644 --- a/apps/web/src/components/practice/index.ts +++ b/apps/web/src/components/practice/index.ts @@ -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' diff --git a/apps/web/src/hooks/useSessionMode.ts b/apps/web/src/hooks/useSessionMode.ts new file mode 100644 index 00000000..29f9aeb4 --- /dev/null +++ b/apps/web/src/hooks/useSessionMode.ts @@ -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 { + 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), + } +} diff --git a/apps/web/src/lib/curriculum/session-mode.ts b/apps/web/src/lib/curriculum/session-mode.ts new file mode 100644 index 00000000..a1212eb4 --- /dev/null +++ b/apps/web/src/lib/curriculum/session-mode.ts @@ -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 { + // 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 [] +}