diff --git a/apps/web/.claude/plans/DECOMPOSITION_CONTEXT_FACTORING.md b/apps/web/.claude/plans/DECOMPOSITION_CONTEXT_FACTORING.md new file mode 100644 index 00000000..2418a259 --- /dev/null +++ b/apps/web/.claude/plans/DECOMPOSITION_CONTEXT_FACTORING.md @@ -0,0 +1,560 @@ +# Plan: Factor Out DecompositionContext + +## Goal + +Create a standalone `DecompositionContext` that can be used anywhere in the app where we want to show an interactive decomposition display for an abacus problem. The context only needs `startValue` and `targetValue` as inputs and provides all the derived data and interaction handlers. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DecompositionProvider │ +│ Input: startValue, targetValue │ +│ Optional: currentStepIndex, onSegmentChange, onTermHover │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ generateUnifiedInstructionSequence(start, target) │ │ +│ │ → fullDecomposition, segments, steps, termPositions │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Derived Functions │ │ +│ │ • getColumnFromTermIndex(termIndex) │ │ +│ │ • getTermIndicesFromColumn(columnIndex) │ │ +│ │ • getGroupTermIndicesFromTermIndex(termIndex) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Interactive State │ │ +│ │ • activeTermIndices: Set │ │ +│ │ • activeIndividualTermIndex: number | null │ │ +│ │ • handleTermHover, handleColumnHover │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ +┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ TutorialPlayer │ │ Practice Help │ │ Future Uses │ +│ │ │ Panel │ │ (Flashcards, │ +│ Wraps with │ │ │ │ Games, etc.) │ +│ Provider, │ │ Wraps term help │ │ │ +│ syncs step │ │ with Provider │ │ │ +└───────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## Implementation Steps + +### Step 1: Create DecompositionContext + +**File:** `src/contexts/DecompositionContext.tsx` + +```typescript +'use client' + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import { + generateUnifiedInstructionSequence, + type PedagogicalSegment, + type UnifiedInstructionSequence, + type UnifiedStepData, +} from '@/utils/unifiedStepGenerator' + +// ============================================================================ +// Types +// ============================================================================ + +export interface DecompositionContextConfig { + /** Starting abacus value */ + startValue: number + /** Target abacus value to reach */ + targetValue: number + /** Current step index for highlighting (optional) */ + currentStepIndex?: number + /** Callback when active segment changes (optional) */ + onSegmentChange?: (segment: PedagogicalSegment | null) => void + /** Callback when a term is hovered (optional) */ + onTermHover?: (termIndex: number | null, columnIndex: number | null) => void + /** Number of abacus columns for column mapping (default: 5) */ + abacusColumns?: number +} + +export interface DecompositionContextType { + // Generated data + sequence: UnifiedInstructionSequence + fullDecomposition: string + isMeaningfulDecomposition: boolean + termPositions: Array<{ startIndex: number; endIndex: number }> + segments: PedagogicalSegment[] + steps: UnifiedStepData[] + + // Configuration + startValue: number + targetValue: number + currentStepIndex: number + abacusColumns: number + + // Highlighting state + activeTermIndices: Set + setActiveTermIndices: (indices: Set) => void + activeIndividualTermIndex: number | null + setActiveIndividualTermIndex: (index: number | null) => void + + // Derived functions + getColumnFromTermIndex: (termIndex: number, useGroupColumn?: boolean) => number | null + getTermIndicesFromColumn: (columnIndex: number) => number[] + getGroupTermIndicesFromTermIndex: (termIndex: number) => number[] + + // Event handlers + handleTermHover: (termIndex: number, isHovering: boolean) => void + handleColumnHover: (columnIndex: number, isHovering: boolean) => void +} + +// ============================================================================ +// Context +// ============================================================================ + +const DecompositionContext = createContext(null) + +export function useDecomposition(): DecompositionContextType { + const context = useContext(DecompositionContext) + if (!context) { + throw new Error('useDecomposition must be used within a DecompositionProvider') + } + return context +} + +// Optional hook that returns null if not in provider (for conditional usage) +export function useDecompositionOptional(): DecompositionContextType | null { + return useContext(DecompositionContext) +} + +// ============================================================================ +// Provider +// ============================================================================ + +interface DecompositionProviderProps extends DecompositionContextConfig { + children: ReactNode +} + +export function DecompositionProvider({ + startValue, + targetValue, + currentStepIndex = 0, + onSegmentChange, + onTermHover, + abacusColumns = 5, + children, +}: DecompositionProviderProps) { + // ------------------------------------------------------------------------- + // Generate sequence (memoized on value changes) + // ------------------------------------------------------------------------- + const sequence = useMemo( + () => generateUnifiedInstructionSequence(startValue, targetValue), + [startValue, targetValue] + ) + + // ------------------------------------------------------------------------- + // Highlighting state + // ------------------------------------------------------------------------- + const [activeTermIndices, setActiveTermIndices] = useState>(new Set()) + const [activeIndividualTermIndex, setActiveIndividualTermIndex] = useState(null) + + // ------------------------------------------------------------------------- + // Derived: term positions from steps + // ------------------------------------------------------------------------- + const termPositions = useMemo( + () => sequence.steps.map((step) => step.termPosition), + [sequence.steps] + ) + + // ------------------------------------------------------------------------- + // Derived function: Get column index from term index + // ------------------------------------------------------------------------- + const getColumnFromTermIndex = useCallback( + (termIndex: number, useGroupColumn = false): number | null => { + const step = sequence.steps[termIndex] + if (!step?.provenance) return null + + // For group highlighting: use rhsPlace (target column of the operation) + // For individual term: use termPlace (specific column this term affects) + const placeValue = useGroupColumn + ? step.provenance.rhsPlace + : (step.provenance.termPlace ?? step.provenance.rhsPlace) + + // Convert place value to column index (rightmost column is highest index) + return abacusColumns - 1 - placeValue + }, + [sequence.steps, abacusColumns] + ) + + // ------------------------------------------------------------------------- + // Derived function: Get term indices that affect a given column + // ------------------------------------------------------------------------- + const getTermIndicesFromColumn = useCallback( + (columnIndex: number): number[] => { + const placeValue = abacusColumns - 1 - columnIndex + return sequence.steps + .map((step, index) => ({ step, index })) + .filter(({ step }) => { + if (!step.provenance) return false + return ( + step.provenance.rhsPlace === placeValue || + step.provenance.termPlace === placeValue + ) + }) + .map(({ index }) => index) + }, + [sequence.steps, abacusColumns] + ) + + // ------------------------------------------------------------------------- + // Derived function: Get all term indices in the same complement group + // ------------------------------------------------------------------------- + const getGroupTermIndicesFromTermIndex = useCallback( + (termIndex: number): number[] => { + const step = sequence.steps[termIndex] + if (!step?.provenance) return [termIndex] + + const groupId = step.provenance.groupId + if (!groupId) return [termIndex] + + // Find all steps with the same groupId + return sequence.steps + .map((s, i) => ({ step: s, index: i })) + .filter(({ step: s }) => s.provenance?.groupId === groupId) + .map(({ index }) => index) + }, + [sequence.steps] + ) + + // ------------------------------------------------------------------------- + // Event handler: Term hover + // ------------------------------------------------------------------------- + const handleTermHover = useCallback( + (termIndex: number, isHovering: boolean) => { + if (isHovering) { + // Set individual term highlight + setActiveIndividualTermIndex(termIndex) + + // Set group highlights + const groupIndices = getGroupTermIndicesFromTermIndex(termIndex) + setActiveTermIndices(new Set(groupIndices)) + + // Notify external listener + if (onTermHover) { + const columnIndex = getColumnFromTermIndex(termIndex, true) + onTermHover(termIndex, columnIndex) + } + } else { + setActiveIndividualTermIndex(null) + setActiveTermIndices(new Set()) + onTermHover?.(null, null) + } + }, + [getGroupTermIndicesFromTermIndex, getColumnFromTermIndex, onTermHover] + ) + + // ------------------------------------------------------------------------- + // Event handler: Column hover (for bidirectional abacus ↔ decomposition) + // ------------------------------------------------------------------------- + const handleColumnHover = useCallback( + (columnIndex: number, isHovering: boolean) => { + if (isHovering) { + const termIndices = getTermIndicesFromColumn(columnIndex) + setActiveTermIndices(new Set(termIndices)) + } else { + setActiveTermIndices(new Set()) + } + }, + [getTermIndicesFromColumn] + ) + + // ------------------------------------------------------------------------- + // Effect: Notify when active segment changes + // ------------------------------------------------------------------------- + useEffect(() => { + if (!onSegmentChange) return + + const segment = sequence.segments.find((seg) => + seg.stepIndices?.includes(currentStepIndex) + ) + onSegmentChange(segment || null) + }, [currentStepIndex, sequence.segments, onSegmentChange]) + + // ------------------------------------------------------------------------- + // Context value + // ------------------------------------------------------------------------- + const value: DecompositionContextType = useMemo( + () => ({ + // Generated data + sequence, + fullDecomposition: sequence.fullDecomposition, + isMeaningfulDecomposition: sequence.isMeaningfulDecomposition, + termPositions, + segments: sequence.segments, + steps: sequence.steps, + + // Configuration + startValue, + targetValue, + currentStepIndex, + abacusColumns, + + // Highlighting state + activeTermIndices, + setActiveTermIndices, + activeIndividualTermIndex, + setActiveIndividualTermIndex, + + // Derived functions + getColumnFromTermIndex, + getTermIndicesFromColumn, + getGroupTermIndicesFromTermIndex, + + // Event handlers + handleTermHover, + handleColumnHover, + }), + [ + sequence, + termPositions, + startValue, + targetValue, + currentStepIndex, + abacusColumns, + activeTermIndices, + activeIndividualTermIndex, + getColumnFromTermIndex, + getTermIndicesFromColumn, + getGroupTermIndicesFromTermIndex, + handleTermHover, + handleColumnHover, + ] + ) + + return ( + + {children} + + ) +} +``` + +### Step 2: Create Standalone DecompositionDisplay Component + +**File:** `src/components/decomposition/DecompositionDisplay.tsx` + +This will be a refactored version of `DecompositionWithReasons` that: +1. Uses `useDecomposition()` instead of `useTutorialContext()` +2. Receives no props (gets everything from context) +3. Can be dropped anywhere inside a `DecompositionProvider` + +```typescript +'use client' + +import { useDecomposition } from '@/contexts/DecompositionContext' +import { ReasonTooltip } from './ReasonTooltip' // Moved here +import './decomposition.css' + +export function DecompositionDisplay() { + const { + fullDecomposition, + termPositions, + segments, + steps, + currentStepIndex, + activeTermIndices, + activeIndividualTermIndex, + handleTermHover, + getGroupTermIndicesFromTermIndex, + } = useDecomposition() + + // ... rendering logic (adapted from DecompositionWithReasons) +} +``` + +### Step 3: Refactor SegmentGroup + +Pass `steps` as a prop instead of reading from TutorialContext: + +```typescript +// Before: +function SegmentGroup({ segment, ... }) { + const { unifiedSteps: steps } = useTutorialContext() + // ... +} + +// After: +function SegmentGroup({ segment, steps, ... }) { + // steps comes from props (DecompositionDisplay passes it from context) +} +``` + +### Step 4: Update ReasonTooltip + +The tooltip already has a conditional import pattern for TutorialUIContext. We keep that but also: +1. Move it to `src/components/decomposition/ReasonTooltip.tsx` +2. Receive `steps` as a prop instead of from context + +### Step 5: Update TutorialPlayer Integration + +**File:** `src/components/tutorial/TutorialPlayer.tsx` + +Wrap the decomposition area with `DecompositionProvider`: + +```typescript +// In TutorialPlayer: + ui.setActiveSegment(segment)} + onTermHover={(termIndex, columnIndex) => { + // Update abacus column highlighting + setHighlightedColumn(columnIndex) + }} +> +
+ +
+
+``` + +### Step 6: Integrate into Practice Help Panel + +**File:** `src/components/practice/ActiveSession.tsx` + +Add decomposition to the help panel: + +```typescript +{/* Per-term help panel */} +{helpTermIndex !== null && helpContext && ( +
+ {/* Header and dismiss button ... */} + + {/* NEW: Decomposition display */} + +
+ +
+
+ + {/* Existing: Provenance breakdown */} + {/* Existing: HelpAbacus */} +
+)} +``` + +## File Structure After Refactoring + +``` +src/ +├── contexts/ +│ └── DecompositionContext.tsx # NEW: Standalone context +│ +├── components/ +│ ├── decomposition/ # NEW: Shared decomposition components +│ │ ├── DecompositionDisplay.tsx +│ │ ├── TermSpan.tsx +│ │ ├── SegmentGroup.tsx +│ │ ├── ReasonTooltip.tsx # MOVED from tutorial/ +│ │ ├── decomposition.css # MOVED from tutorial/ +│ │ └── index.ts # Re-exports +│ │ +│ ├── tutorial/ +│ │ ├── TutorialPlayer.tsx # UPDATED: Uses DecompositionProvider +│ │ ├── TutorialContext.tsx # SIMPLIFIED: Remove decomposition logic +│ │ └── ... +│ │ +│ └── practice/ +│ ├── ActiveSession.tsx # UPDATED: Uses DecompositionProvider +│ └── ... +``` + +## Migration Strategy + +### Phase 1: Create New Context (Non-Breaking) +1. Create `DecompositionContext.tsx` with all logic +2. Create `DecompositionDisplay.tsx` using new context +3. Keep existing `DecompositionWithReasons.tsx` working + +### Phase 2: Update TutorialPlayer +1. Wrap decomposition area with `DecompositionProvider` +2. Update TutorialPlayer to sync state via callbacks +3. Verify tutorial still works identically + +### Phase 3: Integrate into Practice +1. Add `DecompositionProvider` to help panel +2. Render `DecompositionDisplay` +3. Test practice help flow + +### Phase 4: Cleanup (Optional) +1. Remove decomposition logic from `TutorialContext` +2. Delete old `DecompositionWithReasons.tsx` +3. Update imports throughout codebase + +## Testing Checklist + +### Tutorial Mode +- [ ] Decomposition shows correctly for each step +- [ ] Current step is highlighted +- [ ] Term hover shows tooltip +- [ ] Term hover highlights related terms +- [ ] Term hover highlights abacus column +- [ ] Abacus column hover highlights related terms + +### Practice Mode +- [ ] Decomposition shows when help is active +- [ ] Correct decomposition for current term (start → target) +- [ ] Tooltips work on hover +- [ ] Dark mode styling correct +- [ ] No console errors + +### Edge Cases +- [ ] Single-digit addition (no meaningful decomposition) +- [ ] Multi-column carries +- [ ] Complement operations (five/ten complements) +- [ ] Very large numbers +- [ ] Empty/invalid values handled gracefully + +## Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| Breaking tutorial functionality | Phase 2: Keep old code working in parallel during migration | +| Performance: Re-generating sequence | useMemo ensures sequence only regenerates on value changes | +| CSS conflicts | Move CSS to shared location, use consistent naming | +| Missing data in practice context | `usePracticeHelp` already generates sequence - verify compatibility | + +## Notes + +### Why Not Just Pass Props? +We could pass all data as props, but: +1. Deep prop drilling through TermSpan, SegmentGroup, ReasonTooltip +2. Many components need same data +3. Interactive state (hover) needs to be shared +4. Context pattern is cleaner and more React-idiomatic + +### Compatibility with usePracticeHelp +The `usePracticeHelp` hook already calls `generateUnifiedInstructionSequence()` and stores the result. For practice mode, we have two options: +1. **Option A:** Let `DecompositionProvider` regenerate (simple, slightly redundant) +2. **Option B:** Accept pre-generated `sequence` as prop (more efficient) + +Recommend starting with Option A for simplicity, optimize later if needed. diff --git a/apps/web/.claude/plans/progressive-help-overlay.md b/apps/web/.claude/plans/progressive-help-overlay.md new file mode 100644 index 00000000..efdd54fa --- /dev/null +++ b/apps/web/.claude/plans/progressive-help-overlay.md @@ -0,0 +1,64 @@ +# Progressive Help Overlay Feature Plan + +## Executive Summary + +**What:** When kid enters a prefix sum, show interactive abacus covering completed terms with time-based hint escalation. + +**Why:** Makes help discoverable without reading - kid just enters what's on their abacus and help appears. + +**Key insight:** We already have all the coaching/decomposition infrastructure extracted. Only need to: +1. Extract bead tooltip positioning from TutorialPlayer +2. Build new overlay component using existing decomposition system +3. Wire up time-based escalation + +## Visual Layout + +``` + 11 ← covered by abacus + + 1 ← covered by abacus + + 1 ← covered by abacus + ┌─────────────────┐ + │ ABACUS: 13→33 │ ← positioned above next term + └─────────────────┘ + + 20 ← term being added (visible) + + 10 ← remaining terms (visible) + ────────── + … [ 13 ] +``` + +## Time-Based Escalation + +| Time | What appears | +|------|--------------| +| 0s | Abacus with arrows | +| +5s (debug: 1s) | Coach hint (from decomposition system) | +| +10s (debug: 3s) | Bead tooltip pointing at beads | + +## Shared Infrastructure (Already Exists) + +- `generateUnifiedInstructionSequence()` - step/segment data +- `DecompositionProvider` / `DecompositionDisplay` - visual decomposition +- `generateDynamicCoachHint()` - context-aware hints +- `HelpAbacus` - interactive abacus with arrows + +## To Extract from TutorialPlayer + +- `findTopmostBeadWithArrows()` - bead selection +- `calculateTooltipSide()` - smart collision detection +- `createTooltipTarget()` - overlay target creation + +## Files + +| File | Action | +|------|--------| +| `src/utils/beadTooltipUtils.ts` | CREATE - extracted tooltip utils | +| `src/constants/helpTiming.ts` | CREATE - timing config | +| `src/components/practice/PracticeHelpOverlay.tsx` | CREATE - main component | +| `src/components/practice/PracticeHelpOverlay.stories.tsx` | CREATE - stories | +| `src/components/practice/HelpAbacus.tsx` | MODIFY - add overlays prop | +| `src/components/practice/ActiveSession.tsx` | MODIFY - integrate overlay | +| `src/components/tutorial/TutorialPlayer.tsx` | MODIFY - use shared utils | + +## Deferred + +Positioning challenge (fixed abacus height vs variable prefix terms) - handle in follow-up. diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 109a2b7e..b4aa427f 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -138,7 +138,5 @@ "ask": [] }, "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "sqlite" - ] + "enabledMcpjsonServers": ["sqlite"] } diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 25ceccfe..73743c74 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -22,7 +22,7 @@ import { import { css } from '../../../styled-system/css' import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection' import { NumericKeypad } from './NumericKeypad' -import { PracticeHelpPanel } from './PracticeHelpPanel' +import { PracticeHelpOverlay } from './PracticeHelpOverlay' import { VerticalProblem } from './VerticalProblem' interface ActiveSessionProps { @@ -116,6 +116,7 @@ function LinearProblem({ isCompleted, correctAnswer, isDark, + detectedPrefixIndex, }: { terms: number[] userAnswer: string @@ -123,6 +124,8 @@ function LinearProblem({ isCompleted: boolean correctAnswer: number isDark: boolean + /** Detected prefix index - shows "..." instead of "=" for partial sums */ + detectedPrefixIndex?: number }) { // Build the equation string const equation = terms @@ -132,9 +135,14 @@ function LinearProblem({ }) .join('') + // Use "..." for prefix sums (mathematically incomplete), "=" for final answer + const isPrefixSum = detectedPrefixIndex !== undefined + const operator = isPrefixSum ? '…' : '=' + return (
- {equation} = + + {equation}{' '} + + {operator} + + { + // Only auto-trigger if: + // 1. We detected a prefix sum match (buttonState === 'help') + // 2. We're not already showing help for this term + // 3. The matched prefix is the next term they need help with + if ( + buttonState === 'help' && + helpTermIndex === null && + matchedPrefixIndex >= 0 && + matchedPrefixIndex < prefixSums.length - 1 + ) { + const newConfirmedCount = matchedPrefixIndex + 1 + setConfirmedTermCount(newConfirmedCount) + + if (newConfirmedCount < (currentProblem?.problem.terms.length || 0)) { + setHelpTermIndex(newConfirmedCount) + setUserAnswer('') + helpActions.requestHelp(1) + } + } + }, [ + buttonState, + helpTermIndex, + matchedPrefixIndex, + prefixSums.length, + currentProblem?.problem.terms.length, + helpActions, + ]) + // Get current part and slot const parts = plan.parts const currentPartIndex = plan.currentPartIndex @@ -969,96 +1022,91 @@ export function ActiveSession({ {currentSlot?.purpose}
- {/* Problem and Help Abacus - side by side layout */} -
- {/* Problem display - vertical or linear based on part type */} - {currentPart.format === 'vertical' ? ( - = 0 && matchedPrefixIndex < prefixSums.length - 1 - ? matchedPrefixIndex - : undefined - } - autoSubmitPending={autoSubmitTriggered} - rejectedDigit={rejectedDigit} - /> - ) : ( - - )} - - {/* Per-term progressive help - shown when helpTermIndex is set */} - {!isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext && ( -
- {/* Term being helped indicator */} -
+ {/* Problem display - vertical or linear based on part type */} + {currentPart.format === 'vertical' ? ( + = 0 && matchedPrefixIndex < prefixSums.length - 1 + ? matchedPrefixIndex + : undefined + } + autoSubmitPending={autoSubmitTriggered} + rejectedDigit={rejectedDigit} + helpOverlay={ + !isSubmitting && feedback === 'none' && helpTermIndex !== null && helpContext ? (
- Help with: - - {helpContext.term >= 0 ? '+' : ''} - {helpContext.term} - -
-
+ {/* Term being helped indicator */} +
+ Adding: + + {helpContext.term >= 0 ? '+' : ''} + {helpContext.term} + +
- -
- )} -
+ {/* Interactive abacus with arrows - just the abacus, no extra UI */} + {/* Columns = max digits between current and target values (minimum 1) */} + + + ) : undefined + } + /> + ) : ( + = 0 && matchedPrefixIndex < prefixSums.length - 1 + ? matchedPrefixIndex + : undefined + } + /> + )} {/* Feedback message */} {feedback !== 'none' && ( diff --git a/apps/web/src/components/practice/HelpAbacus.tsx b/apps/web/src/components/practice/HelpAbacus.tsx index dda958f2..000cce24 100644 --- a/apps/web/src/components/practice/HelpAbacus.tsx +++ b/apps/web/src/components/practice/HelpAbacus.tsx @@ -2,12 +2,13 @@ import { useTheme } from '@/contexts/ThemeContext' import { + type AbacusOverlay, AbacusReact, calculateBeadDiffFromValues, type StepBeadHighlight, useAbacusDisplay, } from '@soroban/abacus-react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { css } from '../../../styled-system/css' /** Bead change from calculateBeadDiffFromValues */ @@ -19,7 +20,7 @@ interface BeadChange { order?: number } -interface HelpAbacusProps { +export interface HelpAbacusProps { /** Initial value to start the abacus at */ currentValue: number /** Target value we want to reach */ @@ -34,6 +35,16 @@ interface HelpAbacusProps { onValueChange?: (value: number) => void /** Whether the abacus is interactive (default: false for help mode) */ interactive?: boolean + /** Optional overlays (e.g., tooltips pointing at beads) */ + overlays?: AbacusOverlay[] + /** Whether to show the summary instruction above abacus (default: true) */ + showSummary?: boolean + /** Whether to show the value labels below abacus (default: true) */ + showValueLabels?: boolean + /** Whether to show the target reached message (default: true) */ + showTargetReached?: boolean + /** Callback to receive bead highlights for tooltip positioning */ + onBeadHighlightsChange?: (highlights: StepBeadHighlight[] | undefined) => void } /** @@ -51,11 +62,17 @@ export function HelpAbacus({ onTargetReached, onValueChange, interactive = false, + overlays, + showSummary = true, + showValueLabels = true, + showTargetReached = true, + onBeadHighlightsChange, }: HelpAbacusProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' const { config: abacusConfig } = useAbacusDisplay() const [currentStep] = useState(0) + const onBeadHighlightsChangeRef = useRef(onBeadHighlightsChange) // Track the displayed value for bead diff calculations // This is updated via onValueChange from AbacusReact @@ -113,6 +130,14 @@ export function HelpAbacus({ } }, [displayedValue, targetValue, columns]) + // Keep callback ref up to date + onBeadHighlightsChangeRef.current = onBeadHighlightsChange + + // Notify parent when bead highlights change + useEffect(() => { + onBeadHighlightsChangeRef.current?.(stepBeadHighlights) + }, [stepBeadHighlights]) + // Custom styles for help mode - highlight the arrows more prominently const customStyles = useMemo(() => { return { @@ -151,7 +176,7 @@ export function HelpAbacus({ })} > {/* Summary instruction */} - {summary && ( + {showSummary && summary && (
{/* Value labels */} -
+ {showValueLabels && (
- Current:{' '} - - {displayedValue} - -
-
- Target:{' '} - + {displayedValue} + +
+
- {targetValue} - + Target:{' '} + + {targetValue} + +
- + )} {/* Success feedback when target reached */} - {isAtTarget && ( + {showTargetReached && isAtTarget && (
Created: {new Date(plan.createdAt).toLocaleString()}
-
+
{parts.map((part: SessionPart) => (
diff --git a/apps/web/src/components/practice/PracticeHelpOverlay.stories.tsx b/apps/web/src/components/practice/PracticeHelpOverlay.stories.tsx new file mode 100644 index 00000000..99c95d72 --- /dev/null +++ b/apps/web/src/components/practice/PracticeHelpOverlay.stories.tsx @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useCallback, useState } from 'react' +import { css } from '../../../styled-system/css' +import { PracticeHelpOverlay } from './PracticeHelpOverlay' + +const meta: Meta = { + title: 'Practice/PracticeHelpOverlay', + component: PracticeHelpOverlay, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +/** + * Interactive demo showing the help overlay with time-based escalation + */ +function InteractiveDemo() { + const [currentValue, setCurrentValue] = useState(13) + const [targetValue] = useState(33) // 13 + 20 + const [completed, setCompleted] = useState(false) + + const handleTargetReached = useCallback(() => { + setCompleted(true) + console.log('Target reached!') + // After celebration, could reset or advance + setTimeout(() => { + setCompleted(false) + setCurrentValue(33) + }, 1000) + }, []) + + return ( +
+
+

+ Progressive Help Overlay Demo +

+

+ Just the abacus with arrows. Bead tooltip appears after 3s (debug timing). +

+

+ Tooltip uses same system as TutorialPlayer. +

+
+ + {/* Simulating how it appears in ActiveSession - with "Adding: +20" badge above */} +
+
+ Adding: + +20 +
+ + {!completed && ( + + )} +
+ + {completed && ( +
+ 🎉 +

+ Target Reached! +

+
+ )} +
+ ) +} + +export const Interactive: Story = { + render: () => , +} + +/** + * Shows just the abacus overlay without any surrounding UI + */ +function MinimalDemo() { + return ( +
+
+

+ Minimal: Just the Abacus +

+ +
+
+ ) +} + +export const Minimal: Story = { + render: () => , +} + +// Static examples +export const Simple: Story = { + args: { + currentValue: 0, + targetValue: 5, + columns: 3, + debugTiming: true, + }, +} + +export const TwoDigit: Story = { + args: { + currentValue: 23, + targetValue: 68, + columns: 3, + debugTiming: true, + }, +} + +export const ThreeDigit: Story = { + args: { + currentValue: 123, + targetValue: 456, + columns: 3, + debugTiming: true, + }, +} + +export const ProductionTiming: Story = { + args: { + currentValue: 13, + targetValue: 33, + columns: 3, + debugTiming: false, // Use production timing (10s for tooltip) + }, + parameters: { + docs: { + description: { + story: 'Uses production timing: Bead tooltip at 10s', + }, + }, + }, +} diff --git a/apps/web/src/components/practice/PracticeHelpOverlay.tsx b/apps/web/src/components/practice/PracticeHelpOverlay.tsx new file mode 100644 index 00000000..11123ad5 --- /dev/null +++ b/apps/web/src/components/practice/PracticeHelpOverlay.tsx @@ -0,0 +1,296 @@ +'use client' + +/** + * PracticeHelpOverlay - Simplified help overlay for practice sessions + * + * Shows just the interactive abacus with bead arrows and tooltip. + * Uses the same tooltip system as TutorialPlayer for consistency. + * + * Time-based escalation: + * - 0s: Abacus with arrows + * - +5s: Coach hint (shown in parent component) + * - +10s: Bead tooltip appears + */ + +import type { AbacusOverlay, StepBeadHighlight } from '@soroban/abacus-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTheme } from '@/contexts/ThemeContext' +import { getHelpTiming, shouldUseDebugTiming } from '@/constants/helpTiming' +import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator' +import { calculateTooltipPositioning } from '@/utils/beadTooltipUtils' +import { calculateBeadDiffFromValues } from '@/utils/beadDiff' +import { BeadTooltipContent } from '../shared/BeadTooltipContent' +import { HelpAbacus } from './HelpAbacus' + +export interface PracticeHelpOverlayProps { + /** Current abacus value (prefix sum the kid entered) */ + currentValue: number + /** Target value to reach (current prefix sum + next term) */ + targetValue: number + /** Number of columns in the abacus */ + columns?: number + /** Called when kid reaches the target value */ + onTargetReached?: () => void + /** Called when abacus value changes */ + onValueChange?: (value: number) => void + /** Whether to show debug timing */ + debugTiming?: boolean +} + +/** + * Help escalation phases + */ +type HelpPhase = 'abacus' | 'bead-tooltip' + +/** + * PracticeHelpOverlay - Progressive help overlay for practice sessions + * + * Shows just the interactive abacus with bead arrows and optional tooltip. + * No extra UI (no current/target labels, no dismiss button). + */ +export function PracticeHelpOverlay({ + currentValue, + targetValue, + columns = 3, + onTargetReached, + onValueChange, + debugTiming, +}: PracticeHelpOverlayProps) { + const { resolvedTheme } = useTheme() + const theme = resolvedTheme === 'dark' ? 'dark' : 'light' + + // Determine timing config + const timing = useMemo(() => { + const useDebug = debugTiming ?? shouldUseDebugTiming() + return getHelpTiming(useDebug) + }, [debugTiming]) + + // Track current help phase and escalation + const [currentPhase, setCurrentPhase] = useState('abacus') + const [abacusValue, setAbacusValue] = useState(currentValue) + const [beadHighlights, setBeadHighlights] = useState() + + // Refs for timers + const beadTooltipTimerRef = useRef(null) + const celebrationTimerRef = useRef(null) + + // Calculate if at target + const isAtTarget = abacusValue === targetValue + + // Generate the decomposition steps + const sequence = useMemo(() => { + return generateUnifiedInstructionSequence(currentValue, targetValue) + }, [currentValue, targetValue]) + + // Calculate current step summary using bead diff (same as TutorialPlayer) + const currentStepSummary = useMemo(() => { + if (isAtTarget) return null + + try { + const beadDiff = calculateBeadDiffFromValues(abacusValue, targetValue) + return beadDiff.hasChanges ? beadDiff.summary : null + } catch { + return null + } + }, [abacusValue, targetValue, isAtTarget]) + + // Check if decomposition is meaningful (same as TutorialPlayer) + const isMeaningfulDecomposition = sequence?.isMeaningfulDecomposition ?? false + + // Calculate current step index based on abacus value + const currentStepIndex = useMemo(() => { + if (!sequence || sequence.steps.length === 0) return 0 + + // If abacus is still at start value, we're at step 0 + if (abacusValue === currentValue) return 0 + + // Find which step we're on + for (let i = 0; i < sequence.steps.length; i++) { + const step = sequence.steps[i] + if (abacusValue < step.expectedValue) { + return i + } + if (abacusValue === step.expectedValue) { + return Math.min(i + 1, sequence.steps.length - 1) + } + } + + return sequence.steps.length - 1 + }, [sequence, abacusValue, currentValue]) + + // Generate highlighted decomposition (same pattern as TutorialPlayer) + const renderHighlightedDecomposition = useCallback(() => { + if (!sequence?.fullDecomposition || sequence.steps.length === 0) return null + + const currentStep = sequence.steps[currentStepIndex] + if (!currentStep?.mathematicalTerm) return null + + const mathTerm = currentStep.mathematicalTerm + + // Try to use precise position first + if (currentStep.termPosition) { + const { startIndex, endIndex } = currentStep.termPosition + const highlighted = sequence.fullDecomposition.substring(startIndex, endIndex) + + // Validate that the highlighted text makes sense + if (highlighted.includes(mathTerm.replace('-', '')) || highlighted === mathTerm) { + return { + before: sequence.fullDecomposition.substring(0, startIndex), + highlighted, + after: sequence.fullDecomposition.substring(endIndex), + } + } + } + + // Fallback: search for the mathematical term in the decomposition + const searchTerm = mathTerm.startsWith('-') ? mathTerm.substring(1) : mathTerm + const searchIndex = sequence.fullDecomposition.indexOf(searchTerm) + + if (searchIndex !== -1) { + const startIndex = mathTerm.startsWith('-') ? Math.max(0, searchIndex - 1) : searchIndex + const endIndex = mathTerm.startsWith('-') + ? searchIndex + searchTerm.length + : searchIndex + mathTerm.length + + return { + before: sequence.fullDecomposition.substring(0, startIndex), + highlighted: sequence.fullDecomposition.substring(startIndex, endIndex), + after: sequence.fullDecomposition.substring(endIndex), + } + } + + // Final fallback: highlight the first occurrence of just the number part + const numberMatch = mathTerm.match(/\d+/) + if (numberMatch) { + const number = numberMatch[0] + const numberIndex = sequence.fullDecomposition.indexOf(number) + if (numberIndex !== -1) { + return { + before: sequence.fullDecomposition.substring(0, numberIndex), + highlighted: sequence.fullDecomposition.substring( + numberIndex, + numberIndex + number.length + ), + after: sequence.fullDecomposition.substring(numberIndex + number.length), + } + } + } + + return null + }, [sequence, currentStepIndex]) + + // Calculate tooltip positioning using shared utility + const tooltipPositioning = useMemo(() => { + if (currentPhase !== 'bead-tooltip' || isAtTarget) return null + return calculateTooltipPositioning(abacusValue, beadHighlights, columns) + }, [currentPhase, isAtTarget, abacusValue, beadHighlights, columns]) + + // Create tooltip overlay for HelpAbacus using shared BeadTooltipContent + const tooltipOverlay: AbacusOverlay | undefined = useMemo(() => { + // Show tooltip in bead-tooltip phase with instructions, or when celebrating + const showCelebration = isAtTarget + const showInstructions = + !showCelebration && currentPhase === 'bead-tooltip' && currentStepSummary + + if (!showCelebration && !showInstructions) return undefined + if (!tooltipPositioning && !showCelebration) return undefined + + // For celebration, use a default position if no bead highlights + const side = tooltipPositioning?.side ?? 'top' + const target = tooltipPositioning?.target ?? { + type: 'bead' as const, + columnIndex: columns - 1, // rightmost column + beadType: 'heaven' as const, + beadPosition: 0, + } + + return { + id: 'practice-help-tooltip', + type: 'tooltip', + target, + content: ( + + ), + offset: { x: 0, y: 0 }, + visible: true, + } + }, [ + tooltipPositioning, + isAtTarget, + currentPhase, + currentStepSummary, + isMeaningfulDecomposition, + renderHighlightedDecomposition, + columns, + theme, + ]) + + // Start time-based escalation when mounted + useEffect(() => { + // Start bead tooltip timer + beadTooltipTimerRef.current = setTimeout(() => { + setCurrentPhase('bead-tooltip') + }, timing.beadTooltipDelayMs) + + return () => { + if (beadTooltipTimerRef.current) clearTimeout(beadTooltipTimerRef.current) + if (celebrationTimerRef.current) clearTimeout(celebrationTimerRef.current) + } + }, [timing]) + + // Handle target reached - show celebration then notify + useEffect(() => { + if (isAtTarget) { + celebrationTimerRef.current = setTimeout(() => { + onTargetReached?.() + }, timing.celebrationDurationMs) + } + }, [isAtTarget, timing, onTargetReached]) + + // Handle abacus value change + const handleValueChange = useCallback( + (newValue: number) => { + setAbacusValue(newValue) + onValueChange?.(newValue) + }, + [onValueChange] + ) + + // Handle bead highlights change from HelpAbacus + const handleBeadHighlightsChange = useCallback((highlights: StepBeadHighlight[] | undefined) => { + setBeadHighlights(highlights) + }, []) + + return ( +
+ {/* Interactive abacus with bead arrows - just the abacus, no extra UI */} + +
+ ) +} + +export default PracticeHelpOverlay diff --git a/apps/web/src/components/practice/PracticeHelpPanel.tsx b/apps/web/src/components/practice/PracticeHelpPanel.tsx index d922833d..b1ec5dc5 100644 --- a/apps/web/src/components/practice/PracticeHelpPanel.tsx +++ b/apps/web/src/components/practice/PracticeHelpPanel.tsx @@ -9,6 +9,81 @@ import { css } from '../../../styled-system/css' import { DecompositionDisplay, DecompositionProvider } from '../decomposition' import { HelpAbacus } from './HelpAbacus' +/** + * Generate a dynamic coach hint based on the current step + */ +function generateDynamicCoachHint( + sequence: ReturnType | null, + currentStepIndex: number, + abacusValue: number, + targetValue: number +): string { + if (!sequence || sequence.steps.length === 0) { + return 'Take your time and think through each step.' + } + + // Check if we're done + if (abacusValue === targetValue) { + return 'You did it! Move on to the next step.' + } + + // Get the current step + const currentStep = sequence.steps[currentStepIndex] + if (!currentStep) { + return 'Take your time and think through each step.' + } + + // Find the segment this step belongs to + const segment = sequence.segments.find((s) => s.id === currentStep.segmentId) + + // Use the segment's readable summary if available + if (segment?.readable?.summary) { + return segment.readable.summary + } + + // Fall back to generating from the rule + if (segment) { + const rule = segment.plan[0]?.rule + switch (rule) { + case 'Direct': + return `Add ${segment.digit} directly to the ${getPlaceName(segment.place)} column.` + case 'FiveComplement': + return `Think about friends of 5. What plus ${5 - segment.digit} makes 5?` + case 'TenComplement': + return `Think about friends of 10. What plus ${10 - segment.digit} makes 10?` + case 'Cascade': + return 'This will carry through multiple columns. Start from the left.' + default: + break + } + } + + // Fall back to english instruction from the step + if (currentStep.englishInstruction) { + return currentStep.englishInstruction + } + + return 'Think about which beads need to move.' +} + +/** + * Get place name from place value + */ +function getPlaceName(place: number): string { + switch (place) { + case 0: + return 'ones' + case 1: + return 'tens' + case 2: + return 'hundreds' + case 3: + return 'thousands' + default: + return `10^${place}` + } +} + interface PracticeHelpPanelProps { /** Current help state from usePracticeHelp hook */ helpState: PracticeHelpState @@ -106,19 +181,27 @@ export function PracticeHelpPanel({ return sequence.steps.length - 1 }, [sequence, abacusValue, currentValue]) + // Generate dynamic coach hint based on current step + const dynamicCoachHint = useMemo(() => { + return generateDynamicCoachHint(sequence, currentStepIndex, abacusValue, targetValue ?? 0) + }, [sequence, currentStepIndex, abacusValue, targetValue]) + // Handle abacus value changes const handleAbacusValueChange = useCallback((newValue: number) => { setAbacusValue(newValue) }, []) + // Calculate effective level here so handleRequestHelp can use it + // (effectiveLevel treats L0 as L1 since we auto-show help on prefix sum detection) + const effectiveLevel = currentLevel === 0 ? 1 : currentLevel + const handleRequestHelp = useCallback(() => { - if (currentLevel === 0) { - onRequestHelp(1) + // Always request the next level above effectiveLevel + if (effectiveLevel < 3) { + onRequestHelp((effectiveLevel + 1) as HelpLevel) setIsExpanded(true) - } else if (currentLevel < 3) { - onRequestHelp((currentLevel + 1) as HelpLevel) } - }, [currentLevel, onRequestHelp]) + }, [effectiveLevel, onRequestHelp]) const handleDismiss = useCallback(() => { onDismissHelp() @@ -130,55 +213,11 @@ export function PracticeHelpPanel({ return null } - // Level 0: Just show the help request button - if (currentLevel === 0) { - return ( -
- -
- ) - } - - // Levels 1-3: Show the help content + // Levels 1-3: Show the help content (effectiveLevel is calculated above) return (
- {HELP_LEVEL_ICONS[currentLevel]} + {HELP_LEVEL_ICONS[effectiveLevel]} - {HELP_LEVEL_LABELS[currentLevel]} + {HELP_LEVEL_LABELS[effectiveLevel]} {/* Help level indicator dots */}
))} @@ -259,10 +298,11 @@ export function PracticeHelpPanel({
- {/* Level 1: Coach hint */} - {currentLevel >= 1 && content?.coachHint && ( + {/* Level 1: Coach hint - uses dynamic hint that updates with abacus progress */} + {effectiveLevel >= 1 && dynamicCoachHint && (
- {content.coachHint} + {dynamicCoachHint}

)} {/* Level 2: Decomposition */} - {currentLevel >= 2 && + {effectiveLevel >= 2 && content?.decomposition && content.decomposition.isMeaningful && currentValue !== undefined && @@ -332,7 +372,7 @@ export function PracticeHelpPanel({ )} {/* Level 3: Visual abacus with bead arrows */} - {currentLevel >= 3 && currentValue !== undefined && targetValue !== undefined && ( + {effectiveLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
= 3 && + {effectiveLevel >= 3 && (currentValue === undefined || targetValue === undefined) && content?.beadSteps && content.beadSteps.length > 0 && ( @@ -447,7 +487,7 @@ export function PracticeHelpPanel({ )} {/* More help button (if not at max level) */} - {currentLevel < 3 && ( + {effectiveLevel < 3 && ( )} diff --git a/apps/web/src/components/practice/ProgressDashboard.tsx b/apps/web/src/components/practice/ProgressDashboard.tsx index 8898fc8c..7845544b 100644 --- a/apps/web/src/components/practice/ProgressDashboard.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.tsx @@ -73,9 +73,7 @@ function getMasteryColor(level: MasteryLevel, isDark: boolean): { bg: string; te : { bg: 'yellow.100', text: 'yellow.700' } default: // 'learning' and any unknown values use gray - return isDark - ? { bg: 'gray.700', text: 'gray.300' } - : { bg: 'gray.100', text: 'gray.600' } + return isDark ? { bg: 'gray.700', text: 'gray.300' } : { bg: 'gray.100', text: 'gray.600' } } } diff --git a/apps/web/src/components/practice/StudentSelector.tsx b/apps/web/src/components/practice/StudentSelector.tsx index a9f2e5ff..898109bc 100644 --- a/apps/web/src/components/practice/StudentSelector.tsx +++ b/apps/web/src/components/practice/StudentSelector.tsx @@ -42,7 +42,13 @@ function StudentCard({ student, isSelected, onSelect }: StudentCardProps) { borderRadius: '12px', border: isSelected ? '3px solid' : '2px solid', borderColor: isSelected ? 'blue.500' : isDark ? 'gray.600' : 'gray.200', - backgroundColor: isSelected ? (isDark ? 'blue.900' : 'blue.50') : isDark ? 'gray.800' : 'white', + backgroundColor: isSelected + ? isDark + ? 'blue.900' + : 'blue.50' + : isDark + ? 'gray.800' + : 'white', cursor: 'pointer', transition: 'all 0.2s ease', minWidth: '100px', @@ -258,7 +264,11 @@ export function StudentSelector({ marginBottom: '1rem', })} > - Selected: {selectedStudent.name} {selectedStudent.emoji} + Selected:{' '} + + {selectedStudent.name} + {' '} + {selectedStudent.emoji}

) })} @@ -325,19 +352,27 @@ export function VerticalProblem({ Perfect!
)} - {/* Equals sign column */} + {/* Equals sign column - show "..." for prefix sums (mathematically incomplete), "=" for final answer */}
- = + {detectedPrefixIndex !== undefined ? '…' : '='}
{/* Answer digit cells - show maxDigits cells total */} @@ -351,55 +386,74 @@ export function VerticalProblem({ const digit = paddedValue[index] || '' const isEmpty = digit === '' + // Check if this is the cell where a rejected digit should show + // Digits are entered left-to-right, filling from left side of the answer area + // So the next digit position is right after the current answer length + const nextDigitIndex = userAnswer.length + const isRejectedCell = rejectedDigit && isEmpty && index === nextDigitIndex + return (
- {digit} + {isRejectedCell ? ( + + ✕ + + ) : ( + digit + )}
) })} diff --git a/apps/web/src/components/shared/BeadTooltipContent.tsx b/apps/web/src/components/shared/BeadTooltipContent.tsx new file mode 100644 index 00000000..e3ea5d27 --- /dev/null +++ b/apps/web/src/components/shared/BeadTooltipContent.tsx @@ -0,0 +1,132 @@ +'use client' + +/** + * BeadTooltipContent - Shared tooltip content for bead instruction overlays + * + * Extracted from TutorialPlayer for reuse in practice help overlay. + * Ensures consistent tooltip display across tutorial and practice modes. + */ + +import * as Tooltip from '@radix-ui/react-tooltip' +import { PedagogicalDecompositionDisplay } from '../tutorial/PedagogicalDecompositionDisplay' + +export interface BeadTooltipContentProps { + /** Whether to show celebration state (step completed) */ + showCelebration?: boolean + /** The bead diff summary (e.g., "add 2 earth beads in tens") */ + currentStepSummary: string | null + /** Whether the decomposition is pedagogically meaningful */ + isMeaningfulDecomposition?: boolean + /** Rendered decomposition with highlighted term (from renderHighlightedDecomposition) */ + decomposition?: { + before: string + highlighted: string + after: string + } | null + /** Which side the tooltip appears on */ + side: 'top' | 'left' + /** Theme: 'light' or 'dark' */ + theme?: 'light' | 'dark' +} + +/** + * Tooltip content that matches TutorialPlayer's exact implementation + * + * Shows either: + * - Celebration state with 🎉 emoji + * - Instructions with optional decomposition display and bead diff summary + */ +export function BeadTooltipContent({ + showCelebration = false, + currentStepSummary, + isMeaningfulDecomposition = false, + decomposition, + side, + theme = 'light', +}: BeadTooltipContentProps) { + const isDark = theme === 'dark' + + return ( + + + +
+ + + { + e.currentTarget.style.opacity = '1' + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '0.85' + }} + > +
+ {showCelebration ? ( +
+ 🎉 + Excellent work! +
+ ) : ( + <> + {isMeaningfulDecomposition && decomposition && ( + + )} + 💡 {currentStepSummary} + + )} +
+ +
+
+ + + ) +} + +export default BeadTooltipContent diff --git a/apps/web/src/components/tutorial/TutorialPlayer.tsx b/apps/web/src/components/tutorial/TutorialPlayer.tsx index ea95fedc..ba28340c 100644 --- a/apps/web/src/components/tutorial/TutorialPlayer.tsx +++ b/apps/web/src/components/tutorial/TutorialPlayer.tsx @@ -1,6 +1,5 @@ 'use client' -import * as Tooltip from '@radix-ui/react-tooltip' import { type AbacusOverlay, AbacusReact, @@ -19,55 +18,15 @@ import type { TutorialStep, UIState, } from '../../types/tutorial' +import { findTopmostBeadWithArrows, hasActiveBeadsToLeft } from '../../utils/beadTooltipUtils' import { generateUnifiedInstructionSequence } from '../../utils/unifiedStepGenerator' import { CoachBar } from './CoachBar/CoachBar' import { DecompositionDisplay, DecompositionProvider } from '../decomposition' -import { PedagogicalDecompositionDisplay } from './PedagogicalDecompositionDisplay' +import { BeadTooltipContent } from '../shared/BeadTooltipContent' import { TutorialProvider, useTutorialContext } from './TutorialContext' import { TutorialUIProvider } from './TutorialUIContext' import './CoachBar/coachbar.css' -// Helper function to find the topmost bead with arrows -function findTopmostBeadWithArrows( - stepBeadHighlights: StepBeadHighlight[] | undefined -): StepBeadHighlight | null { - if (!stepBeadHighlights || stepBeadHighlights.length === 0) return null - - // Filter only beads that have direction arrows (should have highlights) - const beadsWithArrows = stepBeadHighlights.filter( - (bead) => bead.direction && bead.direction !== 'none' - ) - - if (beadsWithArrows.length === 0) { - console.warn('No beads with arrows found in step highlights:', stepBeadHighlights) - return null - } - - // Sort by place value (highest first, since place value 4 = leftmost = highest value) - // Then by bead type (heaven beads are higher than earth beads) - // Then by position for earth beads (lower position = higher on abacus) - const sortedBeads = [...beadsWithArrows].sort((a, b) => { - // First sort by place value (higher place value = more significant = topmost priority) - if (a.placeValue !== b.placeValue) { - return b.placeValue - a.placeValue - } - - // If same place value, heaven beads come before earth beads - if (a.beadType !== b.beadType) { - return a.beadType === 'heaven' ? -1 : 1 - } - - // If both earth beads in same column, lower position number = higher on abacus - if (a.beadType === 'earth' && b.beadType === 'earth') { - return (a.position || 0) - (b.position || 0) - } - - return 0 - }) - - return sortedBeads[0] || null -} - // Reducer state and actions interface TutorialPlayerState { currentStepIndex: number @@ -534,49 +493,17 @@ function TutorialPlayerContent({ return null } - // Smart positioning logic: avoid covering active beads - // Convert placeValue to columnIndex based on actual number of columns + // Smart positioning logic: avoid covering active beads using shared utility const targetColumnIndex = abacusColumns - 1 - topmostBead.placeValue - - // Check if there are any active beads (against reckoning bar OR with arrows) in columns to the left - const hasActiveBeadsToLeft = (() => { - // Get current abacus state - we need to check which beads are against the reckoning bar - const abacusDigits = currentValue - .toString() - .padStart(abacusColumns, '0') - .split('') - .map(Number) - - for (let col = 0; col < targetColumnIndex; col++) { - const placeValue = abacusColumns - 1 - col // Convert columnIndex back to placeValue - const digitValue = abacusDigits[col] - - // Check if any beads are active (against reckoning bar) in this column - if (digitValue >= 5) { - // Heaven bead is active - return true - } - if (digitValue % 5 > 0) { - // Earth beads are active - return true - } - - // Also check if this column has beads with direction arrows (from current step) - const hasArrowsInColumn = - currentStepBeads?.some((bead) => { - const beadColumnIndex = abacusColumns - 1 - bead.placeValue - return beadColumnIndex === col && bead.direction && bead.direction !== 'none' - }) ?? false - if (hasArrowsInColumn) { - return true - } - } - - return false - })() + const activeToLeft = hasActiveBeadsToLeft( + currentValue, + currentStepBeads, + abacusColumns, + targetColumnIndex + ) // Determine tooltip position and target - const shouldPositionAbove = hasActiveBeadsToLeft + const shouldPositionAbove = activeToLeft const tooltipSide = shouldPositionAbove ? 'top' : 'left' const tooltipTarget = shouldPositionAbove ? { @@ -600,85 +527,14 @@ function TutorialPlayerContent({ type: 'tooltip', target: tooltipTarget, content: ( - - - -
- - - { - e.currentTarget.style.opacity = '1' - }} - onMouseLeave={(e) => { - e.currentTarget.style.opacity = '0.85' - }} - > -
- {showCelebration ? ( -
- 🎉 - Excellent work! -
- ) : ( - <> - {isMeaningfulDecomposition && ( - - )} - 💡 {currentStepSummary} - - )} -
- -
-
- - + ), offset: { x: 0, y: 0 }, visible: true, @@ -695,6 +551,7 @@ function TutorialPlayerContent({ currentStep, isMeaningfulDecomposition, abacusColumns, + theme, ]) // Timer for smart help detection diff --git a/apps/web/src/constants/helpTiming.ts b/apps/web/src/constants/helpTiming.ts new file mode 100644 index 00000000..798b8ab8 --- /dev/null +++ b/apps/web/src/constants/helpTiming.ts @@ -0,0 +1,62 @@ +/** + * Timing configuration for progressive help system + * + * Production values give the kid time to try on their own before hints appear. + * Debug values allow fast iteration during development. + */ +export const HELP_TIMING = { + production: { + /** Delay before showing coach hint */ + coachHintDelayMs: 5000, + /** Delay before showing bead tooltip */ + beadTooltipDelayMs: 10000, + /** Duration of celebration animation */ + celebrationDurationMs: 800, + /** Duration of fade-out transition */ + transitionDurationMs: 300, + }, + debug: { + /** Delay before showing coach hint */ + coachHintDelayMs: 1000, + /** Delay before showing bead tooltip */ + beadTooltipDelayMs: 3000, + /** Duration of celebration animation */ + celebrationDurationMs: 500, + /** Duration of fade-out transition */ + transitionDurationMs: 200, + }, +} as const + +export type HelpTimingConfig = { + readonly coachHintDelayMs: number + readonly beadTooltipDelayMs: number + readonly celebrationDurationMs: number + readonly transitionDurationMs: number +} + +/** + * Get timing configuration based on debug mode + */ +export function getHelpTiming(debug: boolean): HelpTimingConfig { + return debug ? HELP_TIMING.debug : HELP_TIMING.production +} + +/** + * Check if we should use debug timing + * - Always false in production builds + * - True in development if localStorage flag is set or storybook + */ +export function shouldUseDebugTiming(): boolean { + if (typeof window === 'undefined') return false + if (process.env.NODE_ENV === 'production') return false + + // Check for storybook + if (window.location?.href?.includes('storybook')) return true + + // Check for localStorage flag + try { + return localStorage.getItem('helpDebugTiming') === 'true' + } catch { + return false + } +} diff --git a/apps/web/src/utils/beadTooltipUtils.ts b/apps/web/src/utils/beadTooltipUtils.ts new file mode 100644 index 00000000..2f20ea67 --- /dev/null +++ b/apps/web/src/utils/beadTooltipUtils.ts @@ -0,0 +1,188 @@ +/** + * Bead Tooltip Utilities + * + * Extracted from TutorialPlayer for reuse in practice help overlay. + * Handles smart tooltip positioning to avoid covering active beads. + */ + +import type { StepBeadHighlight } from '@soroban/abacus-react' + +/** + * Target specification for tooltip overlay + */ +export interface TooltipTarget { + type: 'bead' + columnIndex: number + beadType: 'heaven' | 'earth' + beadPosition: number | undefined +} + +/** + * Result of tooltip positioning calculation + */ +export interface TooltipPositioning { + /** Which side to show tooltip on */ + side: 'top' | 'left' + /** Target bead for the tooltip */ + target: TooltipTarget + /** The topmost bead that was selected */ + topmostBead: StepBeadHighlight + /** Column index of the target */ + targetColumnIndex: number +} + +/** + * Find the topmost bead with arrows in a step + * + * Priority order: + * 1. Higher place value (leftmost columns = more significant) + * 2. Heaven beads before earth beads + * 3. Lower position number for earth beads (higher on abacus) + * + * @param stepBeadHighlights - Array of bead highlights for the current step + * @returns The topmost bead, or null if none have arrows + */ +export function findTopmostBeadWithArrows( + stepBeadHighlights: StepBeadHighlight[] | undefined +): StepBeadHighlight | null { + if (!stepBeadHighlights || stepBeadHighlights.length === 0) return null + + // Filter only beads that have direction arrows + const beadsWithArrows = stepBeadHighlights.filter( + (bead) => bead.direction && bead.direction !== 'none' + ) + + if (beadsWithArrows.length === 0) { + return null + } + + // Sort by priority + const sortedBeads = [...beadsWithArrows].sort((a, b) => { + // First sort by place value (higher place value = more significant = topmost priority) + if (a.placeValue !== b.placeValue) { + return b.placeValue - a.placeValue + } + + // If same place value, heaven beads come before earth beads + if (a.beadType !== b.beadType) { + return a.beadType === 'heaven' ? -1 : 1 + } + + // If both earth beads in same column, lower position number = higher on abacus + if (a.beadType === 'earth' && b.beadType === 'earth') { + return (a.position || 0) - (b.position || 0) + } + + return 0 + }) + + return sortedBeads[0] || null +} + +/** + * Check if there are active beads to the left of a target column + * + * Active beads are: + * - Beads against the reckoning bar (based on current value) + * - Beads with direction arrows in the current step + * + * @param currentValue - Current abacus value + * @param stepBeadHighlights - Bead highlights for current step + * @param abacusColumns - Number of columns on the abacus + * @param targetColumnIndex - Column index to check left of + * @returns True if there are active beads to the left + */ +export function hasActiveBeadsToLeft( + currentValue: number, + stepBeadHighlights: StepBeadHighlight[] | undefined, + abacusColumns: number, + targetColumnIndex: number +): boolean { + // Get current abacus state - check which beads are against the reckoning bar + const abacusDigits = currentValue.toString().padStart(abacusColumns, '0').split('').map(Number) + + for (let col = 0; col < targetColumnIndex; col++) { + const digitValue = abacusDigits[col] + + // Check if any beads are active (against reckoning bar) in this column + if (digitValue >= 5) { + // Heaven bead is active + return true + } + if (digitValue % 5 > 0) { + // Earth beads are active + return true + } + + // Also check if this column has beads with direction arrows + const hasArrowsInColumn = + stepBeadHighlights?.some((bead) => { + const beadColumnIndex = abacusColumns - 1 - bead.placeValue + return beadColumnIndex === col && bead.direction && bead.direction !== 'none' + }) ?? false + + if (hasArrowsInColumn) { + return true + } + } + + return false +} + +/** + * Calculate smart tooltip positioning + * + * Determines which side to show the tooltip on and which bead to target, + * avoiding covering active beads. + * + * @param currentValue - Current abacus value + * @param stepBeadHighlights - Bead highlights for current step + * @param abacusColumns - Number of columns on the abacus + * @returns Positioning info, or null if no beads with arrows + */ +export function calculateTooltipPositioning( + currentValue: number, + stepBeadHighlights: StepBeadHighlight[] | undefined, + abacusColumns: number +): TooltipPositioning | null { + const topmostBead = findTopmostBeadWithArrows(stepBeadHighlights) + if (!topmostBead) return null + + // Convert placeValue to columnIndex + const targetColumnIndex = abacusColumns - 1 - topmostBead.placeValue + + // Check if there are active beads to the left + const activeToLeft = hasActiveBeadsToLeft( + currentValue, + stepBeadHighlights, + abacusColumns, + targetColumnIndex + ) + + // Determine tooltip position and target + const shouldPositionAbove = activeToLeft + const side = shouldPositionAbove ? 'top' : 'left' + + const target: TooltipTarget = shouldPositionAbove + ? { + // Target the heaven bead position for the column + type: 'bead', + columnIndex: targetColumnIndex, + beadType: 'heaven', + beadPosition: 0, // Heaven beads are always at position 0 + } + : { + // Target the actual bead + type: 'bead', + columnIndex: targetColumnIndex, + beadType: topmostBead.beadType, + beadPosition: topmostBead.position, + } + + return { + side, + target, + topmostBead, + targetColumnIndex, + } +} diff --git a/apps/web/src/utils/tutorialConverter.ts b/apps/web/src/utils/tutorialConverter.ts index c8f84f9e..84bc3fa9 100644 --- a/apps/web/src/utils/tutorialConverter.ts +++ b/apps/web/src/utils/tutorialConverter.ts @@ -1,6 +1,6 @@ // Utility to extract and convert the existing GuidedAdditionTutorial data + import type { Tutorial } from '../types/tutorial' -import type { Locale } from '../i18n/messages' import { generateAbacusInstructions } from './abacusInstructionGenerator' // Import the existing tutorial step interface to match the current structure @@ -218,7 +218,9 @@ export const guidedAdditionSteps: ExistingTutorialStep[] = [ ] // Convert the existing tutorial format to our new format -export function convertGuidedAdditionTutorial(tutorialMessages: Record): Tutorial { +export function convertGuidedAdditionTutorial( + tutorialMessages: Record = {} +): Tutorial { // Convert existing static steps to progressive step data const convertedSteps = guidedAdditionSteps.map((step) => { // Generate progressive instruction data @@ -284,7 +286,7 @@ export function convertGuidedAdditionTutorial(tutorialMessages: Record): { +export function validateTutorialConversion(tutorialMessages: Record = {}): { isValid: boolean errors: string[] } { @@ -332,7 +334,7 @@ export function validateTutorialConversion(tutorialMessages: Record } // Helper to export tutorial data for use in the editor -export function getTutorialForEditor(tutorialMessages: Record): Tutorial { +export function getTutorialForEditor(tutorialMessages: Record = {}): Tutorial { const validation = validateTutorialConversion(tutorialMessages) if (!validation.isValid) {