diff --git a/apps/web/src/components/GuidedAdditionTutorial.tsx b/apps/web/src/components/GuidedAdditionTutorial.tsx index 53e43269..3a7faf20 100644 --- a/apps/web/src/components/GuidedAdditionTutorial.tsx +++ b/apps/web/src/components/GuidedAdditionTutorial.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useEffect } from 'react' import { css } from '../../styled-system/css' import { stack, hstack } from '../../styled-system/patterns' import { AbacusReact } from '@soroban/abacus-react' @@ -31,6 +31,22 @@ interface TutorialStep { multiStepInstructions?: string[] } +interface PracticeStep { + id: string + title: string + description: string + skillLevel: 'basic' | 'heaven' | 'five-complements' | 'mixed' + problemCount: number + maxTerms: number // max numbers to add in a single problem +} + +interface Problem { + id: string + terms: number[] + userAnswer?: number + isCorrect?: boolean +} + const tutorialSteps: TutorialStep[] = [ // Phase 1: Basic Addition (1-4) { @@ -212,32 +228,32 @@ const tutorialSteps: TutorialStep[] = [ } }, { - id: 'complement-3', - title: 'Five Complement: 1 + 3', + id: 'direct-addition-3', + title: 'Direct Addition: 1 + 3', problem: '1 + 3', - description: 'Add 3 when you have 3 spaces. Use complement: 3 = 5 - 2', + description: 'Add 3 to 1. You have space, so add directly.', startValue: 1, targetValue: 4, highlightBeads: [ - { columnIndex: 0, beadType: 'heaven' }, - { columnIndex: 0, beadType: 'earth', position: 0 }, - { columnIndex: 0, beadType: 'earth', position: 1 } + { columnIndex: 0, beadType: 'earth', position: 1 }, + { columnIndex: 0, beadType: 'earth', position: 2 }, + { columnIndex: 0, beadType: 'earth', position: 3 } ], expectedAction: 'multi-step', - actionDescription: 'Add heaven bead (5), then remove 2 earth beads', + actionDescription: 'Add 3 earth beads one by one', multiStepInstructions: [ - 'Click the heaven bead to add 5', - 'Click the first earth bead to remove it', - 'Click the second earth bead to remove it' + 'Click the second earth bead to add it', + 'Click the third earth bead to add it', + 'Click the fourth earth bead to add it' ], tooltip: { - content: 'Five Complement: 3 = 5 - 2', - explanation: 'To add 3: add 5 (heaven bead), then subtract 2 (remove 2 earth beads)' + content: 'Direct Addition - Check Your Space', + explanation: 'You have 1 earth bead up and need to add 3 more. Since there are 4 earth positions total, you have 3 spaces available - perfect!' }, errorMessages: { - wrongBead: 'Use complement method: add heaven, remove 2 earth beads', - wrongAction: 'Add 5, then subtract 2', - hint: 'Complement: 3 = 5 - 2, so add 5 and take away 2' + wrongBead: 'Add the earth beads directly - you have space!', + wrongAction: 'Move the earth beads UP to add them', + hint: 'No complement needed! Just add the remaining 3 earth beads directly.' } }, { @@ -307,20 +323,20 @@ const tutorialSteps: TutorialStep[] = [ id: 'mixed-1', title: 'Practice: 2 + 3', problem: '2 + 3', - description: 'Add 3 to 2. You can add directly or use complement (3 = 5 - 2)', + description: 'Add 3 to 2. You have space, so add directly.', startValue: 2, targetValue: 5, highlightBeads: [{ columnIndex: 0, beadType: 'earth', position: 2 }], expectedAction: 'add', - actionDescription: 'Add the third earth bead directly, or use complement method', + actionDescription: 'Add the third earth bead to complete 5', tooltip: { - content: 'Choice: Direct or Complement', - explanation: 'You have space to add directly, but complement (3 = 5 - 2) is also valid' + content: 'Direct Addition', + explanation: 'Since you have space (only 2 earth beads are up), simply add the third earth bead' }, errorMessages: { - wrongBead: 'Either add the third earth bead or use complement method', - wrongAction: 'Add the earth bead UP or use heaven bead + remove 2', - hint: 'For 3: add earth bead OR use complement 3 = 5 - 2' + wrongBead: 'Click the highlighted third earth bead', + wrongAction: 'Move the earth bead UP to add it', + hint: 'You have space for one more earth bead - no complement needed!' } }, { @@ -352,9 +368,225 @@ const tutorialSteps: TutorialStep[] = [ } ] +const practiceSteps: PracticeStep[] = [ + { + id: 'practice-basic', + title: 'Practice: Basic Addition (1-4)', + description: 'Practice adding numbers 1-4 using only earth beads', + skillLevel: 'basic', + problemCount: 12, + maxTerms: 3 + }, + { + id: 'practice-heaven', + title: 'Practice: Heaven Bead & Simple Combinations', + description: 'Practice using the heaven bead (5) and combining it with earth beads', + skillLevel: 'heaven', + problemCount: 15, + maxTerms: 3 + }, + { + id: 'practice-complements', + title: 'Practice: Five Complements', + description: 'Practice using five complements when you run out of space', + skillLevel: 'five-complements', + problemCount: 20, + maxTerms: 4 + }, + { + id: 'practice-mixed', + title: 'Practice: Mixed Problems', + description: 'Practice all techniques together with varied problem types', + skillLevel: 'mixed', + problemCount: 18, + maxTerms: 5 + } +] + +// Problem generation functions +function generateBasicProblems(count: number, maxTerms: number): Problem[] { + const problems: Problem[] = [] + + for (let i = 0; i < count; i++) { + const termCount = Math.floor(Math.random() * (maxTerms - 1)) + 2 // 2-maxTerms terms + const terms: number[] = [] + + for (let j = 0; j < termCount; j++) { + // Only use 1-4 for basic problems + terms.push(Math.floor(Math.random() * 4) + 1) + } + + // Ensure the sum doesn't exceed 9 (single digit) + const sum = terms.reduce((a, b) => a + b, 0) + if (sum <= 9) { + problems.push({ + id: `basic-${i}`, + terms + }) + } else { + i-- // Try again if sum is too large + } + } + + return problems +} + +function generateHeavenProblems(count: number, maxTerms: number): Problem[] { + const problems: Problem[] = [] + + for (let i = 0; i < count; i++) { + const termCount = Math.floor(Math.random() * (maxTerms - 1)) + 2 + const terms: number[] = [] + + for (let j = 0; j < termCount; j++) { + // Use 1-9, but ensure at least one term needs heaven bead (5 or more) + const num = Math.floor(Math.random() * 9) + 1 + terms.push(num) + } + + // Ensure we have at least one 5+ or the sum involves 5+ + const hasLargeNum = terms.some(t => t >= 5) + const sum = terms.reduce((a, b) => a + b, 0) + + if ((hasLargeNum || sum >= 5) && sum <= 9) { + problems.push({ + id: `heaven-${i}`, + terms + }) + } else { + i-- // Try again + } + } + + return problems +} + +function generateComplementProblems(count: number, maxTerms: number): Problem[] { + const problems: Problem[] = [] + + for (let i = 0; i < count; i++) { + const termCount = Math.floor(Math.random() * (maxTerms - 1)) + 2 + const terms: number[] = [] + + // Generate problems that specifically require complements + // This means having a situation where direct addition won't work + const firstTerm = Math.floor(Math.random() * 4) + 1 // 1-4 + terms.push(firstTerm) + + // Add a term that forces complement usage + const secondTerm = Math.floor(Math.random() * 3) + 3 // 3-5 + terms.push(secondTerm) + + // Maybe add more terms + for (let j = 2; j < termCount; j++) { + const num = Math.floor(Math.random() * 4) + 1 // 1-4 + terms.push(num) + } + + const sum = terms.reduce((a, b) => a + b, 0) + if (sum <= 9) { + problems.push({ + id: `complement-${i}`, + terms + }) + } else { + i-- // Try again + } + } + + return problems +} + +function generateMixedProblems(count: number, maxTerms: number): Problem[] { + const problems: Problem[] = [] + + for (let i = 0; i < count; i++) { + const termCount = Math.floor(Math.random() * (maxTerms - 1)) + 2 + const terms: number[] = [] + + for (let j = 0; j < termCount; j++) { + // Use full range 1-9 + terms.push(Math.floor(Math.random() * 9) + 1) + } + + const sum = terms.reduce((a, b) => a + b, 0) + if (sum <= 9) { + problems.push({ + id: `mixed-${i}`, + terms + }) + } else { + i-- // Try again + } + } + + return problems +} + +function generateProblems(step: PracticeStep): Problem[] { + switch (step.skillLevel) { + case 'basic': + return generateBasicProblems(step.problemCount, step.maxTerms) + case 'heaven': + return generateHeavenProblems(step.problemCount, step.maxTerms) + case 'five-complements': + return generateComplementProblems(step.problemCount, step.maxTerms) + case 'mixed': + return generateMixedProblems(step.problemCount, step.maxTerms) + default: + return [] + } +} + +// Combined tutorial flow with practice steps interspersed +const combinedSteps = [ + // Basic addition tutorial steps + tutorialSteps[0], // basic-1 + tutorialSteps[1], // basic-2 + tutorialSteps[2], // basic-3 + tutorialSteps[3], // basic-4 + + // Practice basic addition + practiceSteps[0], // practice-basic + + // Heaven bead tutorial steps + tutorialSteps[4], // heaven-intro + tutorialSteps[5], // heaven-plus-earth + + // Practice heaven bead + practiceSteps[1], // practice-heaven + + // Five complements tutorial steps + tutorialSteps[6], // complement-intro + tutorialSteps[7], // complement-2 + tutorialSteps[8], // complement-3 + tutorialSteps[9], // complement-4 + tutorialSteps[10], // complement-5 + + // Practice five complements + practiceSteps[2], // practice-complements + + // Final mixed tutorial steps + tutorialSteps[11], // mixed-1 + tutorialSteps[12], // mixed-2 + + // Final mixed practice + practiceSteps[3] // practice-mixed +] + +type StepType = TutorialStep | PracticeStep + +function isTutorialStep(step: StepType): step is TutorialStep { + return 'problem' in step && 'startValue' in step +} + +function isPracticeStep(step: StepType): step is PracticeStep { + return 'skillLevel' in step && 'problemCount' in step +} + export function GuidedAdditionTutorial() { const [currentStepIndex, setCurrentStepIndex] = useState(0) - const [currentValue, setCurrentValue] = useState(tutorialSteps[0].startValue) + const [currentValue, setCurrentValue] = useState(0) const [feedback, setFeedback] = useState(null) const [isCorrect, setIsCorrect] = useState(false) const [multiStepProgress, setMultiStepProgress] = useState(0) @@ -362,8 +594,12 @@ export function GuidedAdditionTutorial() { const transitionTimeoutRef = useRef(null) const lastProcessedValueRef = useRef(null) - const currentStep = tutorialSteps[currentStepIndex] - const isLastStep = currentStepIndex === tutorialSteps.length - 1 + // Practice-specific state + const [currentProblems, setCurrentProblems] = useState([]) + const [showPracticeResults, setShowPracticeResults] = useState(false) + + const currentStep = combinedSteps[currentStepIndex] + const isLastStep = currentStepIndex === combinedSteps.length - 1 const nextStep = useCallback(() => { if (isLastStep || isTransitioning) return @@ -374,7 +610,18 @@ export function GuidedAdditionTutorial() { setTimeout(() => { const nextStepIndex = currentStepIndex + 1 setCurrentStepIndex(nextStepIndex) - setCurrentValue(tutorialSteps[nextStepIndex].startValue) + + const nextStep = combinedSteps[nextStepIndex] + if (isTutorialStep(nextStep)) { + setCurrentValue(nextStep.startValue) + } else { + // Practice step - generate problems + const problems = generateProblems(nextStep) + setCurrentProblems(problems) + setCurrentValue(0) + setShowPracticeResults(false) + } + setFeedback(null) setIsCorrect(false) setMultiStepProgress(0) @@ -384,6 +631,9 @@ export function GuidedAdditionTutorial() { }, [currentStepIndex, isLastStep, isTransitioning]) const checkStep = useCallback((newValue: number) => { + // Only check tutorial steps, not practice steps + if (!isTutorialStep(currentStep)) return + // Prevent processing the same value multiple times if (lastProcessedValueRef.current === newValue) return lastProcessedValueRef.current = newValue @@ -415,7 +665,7 @@ export function GuidedAdditionTutorial() { // Auto-advance to next step after a brief delay transitionTimeoutRef.current = setTimeout(() => { - if (currentStepIndex < tutorialSteps.length - 1) { + if (currentStepIndex < combinedSteps.length - 1) { nextStep() } transitionTimeoutRef.current = null @@ -425,6 +675,80 @@ export function GuidedAdditionTutorial() { } }, [currentStep, multiStepProgress, currentStepIndex, nextStep, isTransitioning]) + // Practice step functions + const updateProblemAnswer = useCallback((problemId: string, answer: number) => { + setCurrentProblems(prev => prev.map(p => + p.id === problemId ? { ...p, userAnswer: answer } : p + )) + }, []) + + const checkPracticeWork = useCallback(() => { + if (!isPracticeStep(currentStep)) return + + const updatedProblems = currentProblems.map(problem => { + const correctAnswer = problem.terms.reduce((sum, term) => sum + term, 0) + return { + ...problem, + isCorrect: problem.userAnswer === correctAnswer + } + }) + + setCurrentProblems(updatedProblems) + setShowPracticeResults(true) + + // Remove correct problems after a delay + setTimeout(() => { + const incorrectProblems = updatedProblems.filter(p => !p.isCorrect) + + if (incorrectProblems.length === 0) { + // All correct - advance to next step + setFeedback('Perfect! All problems completed correctly.') + setTimeout(() => { + nextStep() + }, 2000) + } else { + // Keep only incorrect problems + setCurrentProblems(incorrectProblems.map(p => ({ ...p, userAnswer: undefined, isCorrect: undefined }))) + setShowPracticeResults(false) + setFeedback(`${incorrectProblems.length} problem(s) need correction. Try again!`) + + setTimeout(() => { + setFeedback(null) + }, 3000) + } + }, 2000) + }, [currentStep, currentProblems, nextStep]) + + // Initialize practice problems when entering a practice step + useEffect(() => { + if (isPracticeStep(currentStep)) { + const step = currentStep as PracticeStep + let problems: Problem[] = [] + + switch (step.skillLevel) { + case 'basic': + problems = generateBasicProblems(step.problemCount, step.maxTerms) + break + case 'heaven': + problems = generateHeavenProblems(step.problemCount, step.maxTerms) + break + case 'five-complements': + problems = generateComplementProblems(step.problemCount, step.maxTerms) + break + case 'mixed': + problems = generateMixedProblems(step.problemCount, step.maxTerms) + break + } + + setCurrentProblems(problems) + setShowPracticeResults(false) + } else { + // Reset practice state for tutorial steps + setCurrentProblems([]) + setShowPracticeResults(false) + } + }, [currentStepIndex, currentStep]) + const resetTutorial = useCallback(() => { // Clear any pending transition timeout if (transitionTimeoutRef.current) { @@ -433,11 +757,13 @@ export function GuidedAdditionTutorial() { } setCurrentStepIndex(0) - setCurrentValue(tutorialSteps[0].startValue) + setCurrentValue(combinedSteps[0] && isTutorialStep(combinedSteps[0]) ? combinedSteps[0].startValue : 0) setFeedback(null) setIsCorrect(false) setMultiStepProgress(0) setIsTransitioning(false) + setCurrentProblems([]) + setShowPracticeResults(false) lastProcessedValueRef.current = null // Reset for restart }, []) @@ -454,7 +780,7 @@ export function GuidedAdditionTutorial() { bg: 'blue.500', h: 'full', transition: 'width', - width: `${((currentStepIndex + 1) / tutorialSteps.length) * 100}%` + width: `${((currentStepIndex + 1) / combinedSteps.length) * 100}%` })} /> @@ -462,111 +788,281 @@ export function GuidedAdditionTutorial() {

- Step {currentStepIndex + 1} of {tutorialSteps.length}: {currentStep.title} + Step {currentStepIndex + 1} of {combinedSteps.length}: {isPracticeStep(currentStep) ? currentStep.title : currentStep.title}

-

- Problem: {currentStep.problem} -

-

- {currentStep.description} -

-
- - {/* Tooltip */} -
-
- 💡 {currentStep.tooltip.content} -
-

- {currentStep.tooltip.explanation} -

- {currentStep.multiStepInstructions && ( -
-

- Instructions: + {isPracticeStep(currentStep) ? ( +

+ Complete all problems using the techniques you've learned +

+ ) : ( + <> +

+ Problem: {currentStep.problem}

-
    - {currentStep.multiStepInstructions.map((instruction, index) => ( -
  1. - {index + 1}. {instruction} -
  2. - ))} -
-
+

+ {currentStep.description} +

+ )}
- {/* Interactive Abacus */} -
- { - const key = `${bead.columnIndex}-${bead.beadType}${bead.position !== undefined ? `-${bead.position}` : ''}` - acc[key] = { - stroke: '#3B82F6', - strokeWidth: 3, - filter: 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.6))' - } - return acc - }, {} as Record) - }} - /> -
+ {/* Tutorial tooltip or Practice problems */} + {isPracticeStep(currentStep) ? ( +
+
+ Practice Problems +
+ + {/* Problem grid */} +
+ {currentProblems.map((problem, index) => ( +
+
+ #{index + 1} +
+
+ {problem.terms.join(' + ')} = ? +
+ updateProblemAnswer(problem.id, parseInt(e.target.value) || 0)} + className={css({ + w: 'full', + p: '2', + border: '1px solid', + borderColor: 'gray.300', + rounded: 'md', + textAlign: 'center', + fontSize: 'md' + })} + placeholder="Answer" + /> + {showPracticeResults && problem.isCorrect === false && ( +
+ Incorrect. Try again! +
+ )} +
+ ))} +
+ + {/* Check work button */} + {!showPracticeResults && currentProblems.length > 0 && ( +
+ +
+ )} + + {/* Continue button after all problems correct */} + {showPracticeResults && currentProblems.every(p => p.isCorrect) && ( +
+

+ 🎉 All problems correct! Great job! +

+ +
+ )} + + {/* Practice Abacus */} +
+
+ +
+
+
+ ) : ( +
+
+ 💡 {currentStep.tooltip.content} +
+

+ {currentStep.tooltip.explanation} +

+ {currentStep.multiStepInstructions && ( +
+

+ Instructions: +

+
    + {currentStep.multiStepInstructions.map((instruction, index) => ( +
  1. + {index + 1}. {instruction} +
  2. + ))} +
+
+ )} +
+ )} + + {/* Interactive Abacus - only show for tutorial steps */} + {!isPracticeStep(currentStep) && ( +
+ { + const key = `${bead.columnIndex}-${bead.beadType}${bead.position !== undefined ? `-${bead.position}` : ''}` + acc[key] = { + stroke: '#3B82F6', + strokeWidth: 3, + filter: 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.6))' + } + return acc + }, {} as Record) + }} + /> +
+ )} {/* Feedback */} {feedback && (