Compare commits

..

117 Commits

Author SHA1 Message Date
semantic-release-bot
8f8af92286 chore(abacus-react): release v2.16.0 [skip ci]
# [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.15.0...abacus-react-v2.16.0) (2025-12-19)

### Bug Fixes

* **blog:** correct misleading claim about BKT feeding problem generation ([184cba0](184cba0ec8))
* **blog:** regenerate trajectory data from correct snapshot ([ce85565](ce85565f06))
* **dashboard:** make student dashboard responsive for small screens ([129907f](129907fcc6))
* **dashboard:** use React Query mutations instead of direct fetch ([ff7554b](ff7554b005))
* **migration:** add statement-breakpoint between CREATE and INSERT ([ba68cfc](ba68cfc75d))
* **practice:** add comprehensive logging and validation in recordSlotResult ([85d36c8](85d36c80a2))
* **practice:** add defensive checks in recordSlotResult ([a33e3e6](a33e3e6d2b))
* **practice:** add responsive styles to SessionModeBanner for small screens ([be08efe](be08efe06f))
* **practice:** check all later prefix sums for ambiguity, not just final answer ([43e7db4](43e7db4e88))
* **practice:** correct five complement skill detection for addition and subtraction ([1139c4d](1139c4d1a1))
* **practice:** correct pause phrase attribution ([cc5bb47](cc5bb479c6))
* **practice:** correct route path for resume session ([1a7945d](1a7945dd0b))
* **practice:** disable auto-scroll and add modern PWA meta tag ([8a9afa8](8a9afa86bc))
* **practice:** ensure badges are never taller than wide ([5730bd6](5730bd6112))
* **practice:** ensure keypad spans full screen width ([4b8cbdf](4b8cbdf83c))
* **practice:** ensure speed meter bar is always visible ([0c40dd5](0c40dd5c42))
* **practice:** fix circular import causing REINFORCEMENT_CONFIG.creditMultipliers to be undefined ([147974a](147974a9f0))
* **practice:** fix invisible resume button by using inline styles ([dd3dd45](dd3dd4507c))
* **practice:** handle paused state transitions and add complete phase ([36c9ec3](36c9ec3301))
* **practice:** improve dark mode contrast for sub-nav buttons ([59f574c](59f574c178))
* **practice:** improve mobile layout + floating abacus positioning ([3c9406a](3c9406afc5))
* **practice:** include endEarly.data in currentPlan priority chain ([28b3b30](28b3b30da6))
* **practice:** make session plan page self-sufficient for data loading ([7243502](7243502873))
* **practice:** move SessionPausedModal into ActiveSession for single pause state ([f0a9608](f0a9608a6b))
* **practice:** only show landscape keypad on phone-sized screens ([6c09976](6c09976d4b))
* **practice:** prevent keypad from covering nav and content ([839171c](839171c0ff))
* **practice:** prevent stray "0" rendering in problem area ([7a2390b](7a2390bd1b))
* **practice:** remove empty spacer button from keypad layout ([1058f41](1058f411c6))
* **practice:** remove fallback random problem generation ([f95456d](f95456dadc))
* **practice:** size answer boxes for intermediate prefix sums ([5cfbeeb](5cfbeeb8df))
* **practice:** state-aware complexity selection with graceful fallback ([6c88dcf](6c88dcfdc5))
* **practice:** update pun to "We pressed paws!" ([4800a48](4800a48128))
* **practice:** use inline styles for progress bar ([f45428e](f45428ed82))
* **practice:** use raw CSS media query for landscape keypad visibility ([31fbf80](31fbf80b8f))
* **practice:** use React Query cache for /resume page session data ([ae1a0a8](ae1a0a8e2d))
* **StartPracticeModal:** responsive improvements + integrated tutorial CTA ([56742c5](56742c511d))
* sync pause state between modal and ActiveSession ([55e5c12](55e5c121f1))

### Features

* **abacus:** add dockable abacus feature for practice sessions ([5fb4751](5fb4751728))
* **abacus:** add smooth animated transitions for dock/undock ([2c832c7](2c832c7944))
* **bkt:** add adaptive-bkt mode with unified BKT architecture ([7085a4b](7085a4b3df))
* **bkt:** implement adaptive skill targeting with validated convergence ([354ada5](354ada596d))
* **blog:** add Bayesian blame attribution validation and address reviewer feedback ([ceadd9d](ceadd9de67))
* **blog:** add interactive ECharts for BKT validation blog post ([6a4dd69](6a4dd694a2))
* **blog:** add layered skill trajectory visualization ([b227162](b227162da6))
* **blog:** add session 0, line thickness, and category averages to charts ([c40baee](c40baee43f))
* **blog:** show adaptive vs classic comparison on same chart ([b0c0f5c](b0c0f5c2da))
* **blog:** simplify All Skills chart to show average comparison ([6ef329d](6ef329dd60))
* **practice:** add "Press paws!" pun to auto-pause phrases ([8405f64](8405f64486))
* **practice:** add /resume route for "Welcome back" experience ([7b476e8](7b476e80c1))
* **practice:** add 30 and 45 minute session duration options ([e42766c](e42766c893))
* **practice:** add auto-pause and improve docked abacus sizing ([9c1fd85](9c1fd85ed5))
* **practice:** add browse mode navigation and improve SpeedMeter timing display ([3c52e60](3c52e607b3))
* **practice:** add cascading regrouping skills and improve help UX ([7cf689c](7cf689c3d9))
* **practice:** add celebration progression banner with smooth transitions ([bb9506b](bb9506b93e))
* **practice:** add complexity budget system and toggleable session parts ([5d61de4](5d61de4bf6))
* **practice:** add inline practice panel for browse mode debugging ([c0764cc](c0764ccd85))
* **practice:** add pause info with response time statistics to paused modal ([826c849](826c8490ba))
* **practice:** add play emoji to Keep Going button ([80a33bc](80a33bcae2))
* **practice:** add prefix sum disambiguation and debug panel ([46ff5f5](46ff5f528a))
* **practice:** add projecting SessionModeBanner with slot-based animation ([0f84ede](0f84edec0a))
* **practice:** add Remediation CTA for weak skill focus sessions ([7d8bb2f](7d8bb2f525))
* **practice:** add response time tracking and live timing display ([18ce1f4](18ce1f41af))
* **practice:** add SkillUnlockBanner + session summary improvements ([4daf7b7](4daf7b7433))
* **practice:** add student notes with animated modal + BKT improvements ([2702ec5](2702ec585f))
* **practice:** add subtraction support to problem generator ([4f7a9d7](4f7a9d76cd))
* **practice:** add unified SessionMode system for consistent skill targeting ([b345baf](b345baf3c4))
* **practice:** consolidate nav with transport dropdown and mood indicator ([8851be5](8851be5948))
* **practice:** improve docked abacus UX and submit button behavior ([60fc81b](60fc81bc2d))
* **practice:** improve help mode UX with crossfade and dismiss behaviors ([bcb1c7a](bcb1c7a173))
* **practice:** improve modal UI with problem counts and time estimation ([34d0232](34d0232451))
* **practice:** improve session summary UI ([a27fb0c](a27fb0c9a4))
* **practice:** inline emoji with random pause phrases ([c13fedd](c13feddfbb))
* **practice:** integrate timing display into sub-nav with mobile support ([2fca17a](2fca17a58b))
* **practice:** migrate mastery model to isPracticing + computed fluency ([b2e7268](b2e7268e7a))
* **practice:** redesign paused modal with kid-friendly statistics UX ([11ecb38](11ecb385ad))
* **practice:** reduce term count for visualization part ([9159608](9159608dcd))
* **practice:** refactor disambiguation into state machine with comprehensive tests ([ed277ef](ed277ef745))
* **practice:** responsive mobile keypad and unified skill detection ([ee8dccd](ee8dccd83a))
* **practice:** separate phrase sets for manual vs auto pause ([652519f](652519f219))
* **practice:** unify dashboard with session-aware progress display ([c40543a](c40543ac64))
* **practice:** use student's actual mastered skills for problem generation ([245cc26](245cc269fe))
* **session-planner:** integrate SessionMode for single source of truth targeting ([9851c01](9851c01026))
* **skills-modal:** add spring animations and UX improvements ([b94f533](b94f5338e5))
* **skills:** add Skills Dashboard with honest skill assessment framing ([bf4334b](bf4334b281))
* **test:** add journey simulator for BKT A/B testing ([86cd518](86cd518c39))
* **tutorial:** implement subtraction in unified step generator ([e5c697b](e5c697b7a8))
2025-12-19 01:51:28 +00:00
Thomas Hallock
3c9406afc5 fix(practice): improve mobile layout + floating abacus positioning
- Add bottomOffset/rightOffset to MyAbacusContext for virtual keyboard avoidance
- NumericKeypad sets offsets when mounted (48px bottom, 100px right)
- Floating abacus repositions above/beside keyboard in portrait/landscape
- PracticeSubNav: fix horizontal overflow with minWidth: 0 on flex children
- SessionProgressIndicator: allow proper flex shrinking
- ActiveSession: reduce padding/gaps, use flex layout to fill available space
- PracticeClient: use fixed positioning with proper insets for all orientations
  - Portrait: bottom 48px for keypad
  - Landscape: right 100px for keypad
  - Desktop: no offsets needed
- Prevent viewport scrolling during practice sessions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:49:59 -06:00
Thomas Hallock
0f84edec0a feat(practice): add projecting SessionModeBanner with slot-based animation
Implements a unified banner that seamlessly animates between positions:
- Full banner in content area (Dashboard, Summary pages)
- Compact banner in nav slot (other practice pages)
- Smooth React Spring animation when navigating between pages

New files:
- SessionModeBannerContext: manages slot registration and bounds tracking
- ProjectingBanner: animated portal banner using React Spring
- CompactBanner: condensed single-line variant for nav slot
- PracticeLayout: wrapper component with provider
- ProjectingBanner.stories: interactive demos showcasing transitions

Modified:
- PracticeSubNav: removed Start Practice button, added NavBannerSlot
- DashboardClient/SummaryClient: wrapped with provider, use ContentBannerSlot
- zIndex constants: added SESSION_MODE_BANNER layer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 17:02:31 -06:00
Thomas Hallock
4daf7b7433 feat(practice): add SkillUnlockBanner + session summary improvements
This commit includes accumulated work from the SessionMode system:

- Add SkillUnlockBanner component for celebrating skill mastery
- Improve SessionSummary to show skill unlock celebrations
- Add session detail page at /practice/[studentId]/session/[sessionId]
- Update seedTestStudents script with more realistic test data
- Extend skill-tutorial-config with more skill mappings
- Improve BKT compute with better uncertainty handling
- Update progress-manager with skill completion tracking
- Remove legacy sessions API routes (replaced by session-plans)
- Add migration 0037 for practice_sessions schema cleanup
- Add plan documents for celebration wind-down and SessionMode
- Update gitignore to exclude db backups

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 15:16:34 -06:00
Thomas Hallock
be08efe06f fix(practice): add responsive styles to SessionModeBanner for small screens
Add @media (max-width: 400px) breakpoints to all three banner components:
- RemediationBanner: smaller padding, icon size, font sizes, button
- ProgressionBanner: smaller padding, icon size, font sizes, button
- MaintenanceBanner: smaller padding, icon size, font sizes, button

Also adds minWidth: 0 to text containers to prevent overflow on small screens.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:58:46 -06:00
Thomas Hallock
e9f9aaca16 refactor(practice): remove redundant targeting sections from modal
Now that the Remediation CTA prominently displays weak skills,
the duplicate "Targeting:" and "Focusing on weak skills:" sections
in the config panel are no longer needed.

Removed:
- Target skills summary in collapsed view
- Target skills info in expanded config panel
- Unused targetSkillsInfo useMemo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:47:08 -06:00
Thomas Hallock
7d8bb2f525 feat(practice): add Remediation CTA for weak skill focus sessions
When a student is in remediation mode (has weak skills to strengthen),
the StartPracticeModal now shows a special amber-themed CTA similar to
the tutorial CTA:

- 💪 "Time to build strength!" heading
- Lists weak skills with pKnown percentages
- "Start Focus Practice →" amber button
- Shows up to 4 skills with "+N more" overflow

Includes Storybook stories for:
- Single weak skill
- Multiple weak skills (2)
- Many weak skills (6, with overflow)
- Dark theme variant

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:42:44 -06:00
Thomas Hallock
9851c01026 feat(session-planner): integrate SessionMode for single source of truth targeting
The session planner now accepts an optional sessionMode parameter that:
- Uses pre-computed weak skills from SessionMode (remediation mode)
- Eliminates duplicate BKT computation between UI and problem generation
- Ensures "no rug-pulling" - what the modal shows is what configures problems

Changes:
- session-planner.ts: Accept sessionMode, use getWeakSkillIds() when provided
- useSessionPlan.ts: Accept sessionMode in generateSessionPlan function
- plans/route.ts: Pass sessionMode from request body to planner
- StartPracticeModal.tsx: Pass sessionMode when generating plan
- index.ts: Export SessionMode types from curriculum module

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 14:06:42 -06:00
Thomas Hallock
b345baf3c4 feat(practice): add unified SessionMode system for consistent skill targeting
Creates a single source of truth for practice session decisions:
- SessionMode types: remediation, progression, maintenance
- getSessionMode() centralizes BKT computation
- SessionModeBanner component displays context-aware messaging
- useSessionMode() hook for React Query integration

Updates Dashboard, Summary, and StartPracticeModal to use SessionMode,
eliminating the "three-way messaging" problem where different parts of
the UI showed conflicting skill information.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:56:15 -06:00
Thomas Hallock
bb9506b93e feat(practice): add celebration progression banner with smooth transitions
Adds interpolation utilities and a celebration banner component that
smoothly morphs between celebration and normal states over 60 seconds.

🤫

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 13:42:29 -06:00
Thomas Hallock
56742c511d fix(StartPracticeModal): responsive improvements + integrated tutorial CTA
- Full-screen mode at ≤700px height for iPhone SE support
- Two-column grid layout for settings in landscape mode
- Integrated tutorial CTA: combines unlock banner + start button
- Fixed collapsed mode clipping of target skills section
- Made "focusing on weak skills" visible on all screen sizes
- Fixed duplicate CSS media query breakpoints
- BKT: changed computeBktFromHistory to accept Partial<BktComputeExtendedOptions>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 10:49:49 -06:00
Thomas Hallock
5735ff0810 docs: add drizzle statement-breakpoint lesson to CLAUDE.md
Documents the critical requirement for --> statement-breakpoint markers
between multiple SQL statements in drizzle migrations. References the
2025-12-18 production outage caused by missing breakpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 07:37:00 -06:00
Thomas Hallock
ba68cfc75d fix(migration): add statement-breakpoint between CREATE and INSERT
Drizzle's better-sqlite3 driver requires --> statement-breakpoint
markers between SQL statements in migration files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 07:18:50 -06:00
Thomas Hallock
129907fcc6 fix(dashboard): make student dashboard responsive for small screens
- Tabs stack vertically on mobile (icon above label) instead of hiding labels
- Summary cards use 2x2 grid on mobile, 4x1 on tablet+
- Skill card grids use smaller min-width on mobile (120px vs 140px)
- Reduced padding throughout on mobile screens
- Section headers and buttons stack vertically on mobile
- History and Notes tabs use responsive padding and font sizes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 06:45:26 -06:00
Thomas Hallock
2702ec585f feat(practice): add student notes with animated modal + BKT improvements
Student Notes Feature:
- Add notes column to players table with migration
- Create NotesModal component with zoom animation from student tile
- Add notes button on each student card in StudentSelector
- Support viewing and editing notes directly in modal
- Fix modal reopening bug with pointerEvents during animation
- Fix spring animation to start from clicked tile position

BKT & Curriculum Improvements:
- Add configurable BKT thresholds via admin settings
- Add skill anomaly detection API endpoint
- Add next-skill recommendation API endpoint
- Add problem history API endpoint
- Improve skills page with BKT classifications display
- Add skill tutorial integration infrastructure

Dashboard & Session Improvements:
- Enhanced dashboard with notes tab
- Improved session summary display
- Add StartPracticeModal stories

Test Infrastructure:
- Add seedTestStudents.ts script for BKT manual testing
- Add generateTrajectoryData.ts for simulation data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 06:39:04 -06:00
Thomas Hallock
b94f5338e5 feat(skills-modal): add spring animations and UX improvements
- Add smooth spring-animated accordion expand/collapse using react-spring
- Add dynamic scroll indicators that show when content is scrolled
- Auto-scroll to show expanded category content optimally
- Replace ambiguous arrows with "Show/Hide" + rotating chevron
- Make modal full-screen on mobile, centered on desktop
- Add sticky category headers within scroll container
- Fix z-index layering using shared constants
- Add optimistic updates for skill mutations (instant UI feedback)
- Fix React Query cache sync for live skill updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:23:14 -06:00
Thomas Hallock
fad386f216 refactor(know-your-world): use react-use-measure for safe scale calculation
Replace manual useLayoutEffect + resize listener with react-use-measure
hook for cleaner reactive measurement of takeover container bounds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 07:22:21 -06:00
Thomas Hallock
ceadd9de67 feat(blog): add Bayesian blame attribution validation and address reviewer feedback
- Add proper Bayesian inference implementation alongside heuristic approximation
- Create blame-attribution.test.ts with multi-seed validation (5 seeds × 3 profiles)
- Result: No significant difference (t=-0.41, p>0.05), heuristic wins 3/5

Blog post improvements addressing expert reviewer feedback:
- Add Limitations section (simulation-only validation, technique bypass, independence assumption)
- Add "Why We Built This" section explaining automatic proctoring context
- Soften claims: "validate" → "suggest...may...pending real-world confirmation"
- Commit to follow-up publication with real student data
- Add BlameAttribution interactive chart with comparison data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:04:07 -06:00
Thomas Hallock
ff7554b005 fix(dashboard): use React Query mutations instead of direct fetch
DashboardClient had 3 direct fetch() calls that bypassed React Query:
- handleStartOver (abandon session)
- handleSaveManualSkills (set mastered skills)
- handleRefreshSkill (refresh skill recency)

These used router.refresh() to update data, which didn't reliably
update the React Query cache, causing stale UI state.

Fix:
- Add useSetMasteredSkills and useRefreshSkillRecency hooks
- Use useActiveSessionPlan with server props as initial data
- Replace direct fetch with mutation hooks
- Remove router.refresh() calls - React Query handles cache invalidation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 15:31:32 -06:00
Thomas Hallock
c40baee43f feat(blog): add session 0, line thickness, and category averages to charts
- Add session 0 data to show initial mastery state before practice
- Single Skill tab: line thickness based on skill tier, category average toggles
- All Skills tab: session 0 for ghost lines and averages
- Fix broken GitHub link (was placeholder "...")
- Add source code links to test files that generate chart data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:05:50 -06:00
Thomas Hallock
b227162da6 feat(blog): add layered skill trajectory visualization
New chart design with multiple visual encodings:
- Ghost lines (40% opacity) show individual skill trajectories
- Green spectrum for Adaptive, gray spectrum for Classic
- Darker shades = later in pedagogical sequence
- Line thickness encodes skill difficulty:
  - 1px: basic skills
  - 1.5px: five-complements
  - 2px: ten-complements (friends of 10)
  - 2.5px: cascading/multi-place regrouping
  - 4px: average line (full opacity, on top)
- Clear legend showing Adaptive (avg) vs Classic (avg)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:58:25 -06:00
Thomas Hallock
6ef329dd60 feat(blog): simplify All Skills chart to show average comparison
Replaced cluttered 12-line chart with clean 2-line comparison:
- Green line: Average mastery across all skills (Adaptive mode)
- Gray line: Average mastery across all skills (Classic mode)
- Clear legend with Adaptive/Classic labels
- Area fill for visual distinction
- Tooltip shows both values plus advantage in pp

Much easier to see that Adaptive consistently outpaces Classic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:36:48 -06:00
Thomas Hallock
df9f23d2a3 refactor(blog): use marker comments for chart placement
Changed from heading-based injection to explicit marker comments:
- Markers like <!-- CHART: ValidationResults --> in markdown
- Charts now appear after explanatory text, not directly under headings
- Gives explicit control over chart placement in the document flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:34:30 -06:00
Thomas Hallock
b0c0f5c2da feat(blog): show adaptive vs classic comparison on same chart
Changed "All Skills" tab to display both modes simultaneously:
- Solid lines = Adaptive mode (with circle markers)
- Dashed lines = Classic mode (no markers)
- Same color = same skill
- Tooltip shows both values with diff highlighted

This makes comparison much easier than toggling between modes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:30:10 -06:00
Thomas Hallock
ce85565f06 fix(blog): regenerate trajectory data from correct snapshot
The JSON was stale - regenerated from the correct 6-skill A/B test
snapshot showing adaptive wins 4-0 at 50% and 6-0 at 80%.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:26:52 -06:00
Thomas Hallock
84217a8bb6 docs(blog): add proper introduction before Automaticity Classification chart
Explain the three classification zones (Struggling, Learning, Automated)
and their P(known) thresholds before showing the visualization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:21:59 -06:00
Thomas Hallock
335c385390 docs(blog): remove straw man comparison in uncertainty reporting section
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:20:46 -06:00
Thomas Hallock
6a4dd694a2 feat(blog): add interactive ECharts for BKT validation blog post
- Add ValidationCharts with tabbed interface for A/B trajectory data
  - "All Skills" tab: shows 6 skills at once, toggle Adaptive/Classic
  - "Single Skill" tab: interactive skill selector for individual comparison
  - "Convergence" tab: bar chart comparing sessions to 80% mastery
  - "Data Table" tab: summary with advantage calculations
- Add SkillDifficultyCharts for skill difficulty model visualization
- Create snapshot-based test infrastructure for trajectory data
  - skill-difficulty.test.ts generates A/B mastery trajectories
  - Snapshots capture session-by-session mastery for 6 deficient skills
- Add generator scripts to convert snapshots to JSON for blog charts
  - generateMasteryTrajectoryData.ts → ab-mastery-trajectories.json
  - generateSkillDifficultyData.ts → skill-difficulty-report.json
- Add skill-specific difficulty multipliers to SimulatedStudent
  - Basic skills: 0.8-0.9x (easier)
  - Five-complements: 1.2-1.3x (moderate)
  - Ten-complements: 1.6-2.0x (harder)
- Document SimulatedStudent model in SIMULATED_STUDENT_MODEL.md

Results show adaptive mode reaches 80% mastery faster for all 6 tested
skills (4-0 at 50% threshold, 6-0 at 80% threshold).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:16:47 -06:00
Thomas Hallock
7085a4b3df feat(bkt): add adaptive-bkt mode with unified BKT architecture
- Add 'adaptive-bkt' mode using BKT for both skill targeting AND cost
  calculation (previously BKT was only used for targeting)
- Make adaptive-bkt the default problem generation mode
- Fix session-planner to include adaptive-bkt in BKT targeting logic
- Add fatigue tracking to journey simulator (sum of skill multipliers)
- Add 3-way comparison test (classic vs adaptive vs adaptive-bkt)

Validation results show both adaptive modes perform identically for
learning rate (25-33% faster than classic). The benefit comes from
BKT targeting, not the cost formula - using BKT for both simplifies
the architecture with no performance cost.

UI changes:
- Simplify Problem Selection to two user-friendly options:
  "Focus on weak spots" (recommended) and "Practice everything"
- Remove jargon like "BKT" and "fluency" from user-facing labels

Blog post updated with 3-way comparison findings and unified
BKT architecture documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:24:14 -06:00
Thomas Hallock
354ada596d feat(bkt): implement adaptive skill targeting with validated convergence
BKT (Bayesian Knowledge Tracing) integration for intelligent practice:

Architecture:
- Separate cost calculation (fluency-based) from skill targeting (BKT-based)
- Cost controls difficulty via complexity budgets
- BKT identifies weak skills (pKnown < 0.5, confidence >= 0.3) for targeting
- Weak skills added to targetSkills in focus slots

New modules:
- src/lib/curriculum/bkt/ - Core BKT implementation
  - conjunctive-bkt.ts - Multi-skill blame distribution
  - evidence-quality.ts - Help level and response time weighting
  - confidence.ts - Data-based confidence calculation
  - skill-priors.ts - Initial P(known) estimates by skill type
- src/lib/curriculum/config/bkt-integration.ts - Targeting thresholds

Validation (journey simulator):
- Hill function learning model: P(correct) = exposure^n / (K^n + exposure^n)
- Per-skill assessment without learning pollution
- Convergence results: Adaptive reaches 80% mastery faster in 9/9 scenarios
- Adaptive reaches 50% mastery faster in 8/9 scenarios

Key changes:
- session-planner.ts: identifyWeakSkills() and addWeakSkillsToTargets()
- skillComplexity.ts: Always use fluency multiplier for cost (not BKT)
- comprehensive-ab-test.test.ts: Convergence speed comparison tests
- Updated learner profiles with realistic learning rates (K=25-60)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 17:16:18 -06:00
Thomas Hallock
22cd11e2c3 docs(blog): update BKT post with validation results and adaptive targeting
- Add validation section with convergence speed comparison results
- Document adaptive skill targeting architecture (separate from cost calculation)
- Add per-skill assessment methodology that doesn't pollute learning state
- Include test results: adaptive reaches 80% mastery faster in 9/9 scenarios
- Update abstract and summary with key findings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 17:15:27 -06:00
Thomas Hallock
86cd518c39 feat(test): add journey simulator for BKT A/B testing
Comprehensive test harness for comparing BKT-driven adaptive mode vs
classic mode using simulated students with Hill function learning.

Key components:
- SimulatedStudent: Exposure-based learning model with conjunctive
  skill probability (P = product of individual skill probabilities)
- JourneyRunner: Runs multi-session practice journeys
- Per-skill deficiency profiles: 32 skills × 3 learner types (96 profiles)
- Per-skill assessment: Measures exposure and mastery on specific skills
  WITHOUT learning during assessment

Learner types calibrated for gradual learning over 12 sessions:
- Fast: K=25, n=2.0 (~75 exposures for 90% mastery)
- Average: K=40, n=2.5 (~120 exposures for 90% mastery)
- Slow: K=60, n=3.0 (~150 exposures for 90% mastery)

Key finding: When measuring the RIGHT metric (deficient skill exposure
and mastery), adaptive mode wins 10/15 comparisons vs classic:
- Avg deficient skill exposure: Adaptive=189.6 vs Classic=180.6
- Avg deficient skill mastery: Adaptive=94.9% vs Classic=90.8%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 15:38:27 -06:00
Thomas Hallock
52a4a5cfda test(generator): add A/B test proving targetSkills works in problem generator
Validates that the problem generator correctly responds to targetSkills
constraints:

- With targeting: 100% of problems include the target skill
- Without targeting: ~20% include the skill (baseline random chance)

Tests multiple skills across categories (basic, fiveComplements,
tenComplements) with deterministic seeding for reproducibility.

This test helped identify that the issue with adaptive mode wasn't in
the problem generator itself, but in how targetSkills was being set
upstream in session-planner.ts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:08:43 -06:00
Thomas Hallock
15b633f59a test(bkt): add comprehensive skill identification test for all 34 skills
Validates BKT's ability to identify weak skills using the real problem
generator with deterministic seeding:

- Generates 15,000+ problems covering all 34 skills (400+ per skill)
- Tests each skill by answering 200 correctly and 200 incorrectly
- Verifies BKT identifies each skill as weak (P(known) < 0.6)
- Achieves 100% identification accuracy with sufficient data

Key findings:
- With 100 problems per skill: 82.4% accuracy (6 skills escaped due to
  conjunctive model blame distribution)
- With 400 problems per skill: 100% accuracy (all skills correctly
  identified as weak)

This confirms BKT's conjunctive model works correctly - it just needs
sufficient data to overcome blame distribution effects when skills
co-occur.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:07:00 -06:00
Thomas Hallock
184cba0ec8 fix(blog): correct misleading claim about BKT feeding problem generation
BKT estimates power the Skills Dashboard display only. Problem generation
uses separate fluency states (effortless/fluent/rusty/practicing) from
PlayerSkillMastery records, not BKT P(known) estimates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-14 13:56:38 -06:00
Thomas Hallock
9c313d5303 docs(blog): add conjunctive BKT skill tracing blog post
Describes the pattern tracing system for soroban practice:
- Soroban pedagogy as visual-motor patterns drilled to automaticity
- Simulation-based pattern tagging at problem-generation time
- Conjunctive BKT with probabilistic blame distribution
- Evidence quality weighting (help level, response time)
- Automaticity-aware problem complexity budgeting
- Honest uncertainty reporting with confidence intervals

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-14 13:54:03 -06:00
Thomas Hallock
bf4334b281 feat(skills): add Skills Dashboard with honest skill assessment framing
Add new Skills Dashboard at /practice/[studentId]/skills that provides:
- Per-skill performance tracking with problem history drill-down
- Fluency state categorization (practicing, fluent, effortless, rusty)
- Skills grouped by category (basic, five/ten complements, etc.)

Use conservative presentation to honestly represent skill data:
- Show "Correct: N" and "In errors: M" instead of misleading "Accuracy: X%"
- Label sections "Appear Frequently in Errors" instead of "Need Intervention"
- Add disclaimer: errors may have been caused by other skills in the problem

This addresses the epistemological issue that incorrect answers only tell us
ONE OR MORE skills failed, not which specific skill(s) caused the error.

Files:
- New: SkillsClient.tsx, skills/page.tsx (Skills Dashboard)
- Updated: DashboardClient.tsx (link to skills page)
- Updated: SkillPerformanceReports.tsx (honest framing)
- Updated: session-planner.ts (getRecentSessionResults for problem history)
- Updated: server.ts (re-export new function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-14 10:46:25 -06:00
Thomas Hallock
7a2390bd1b fix(practice): prevent stray "0" rendering in problem area
Fix JSX gotcha where `problemHeight && <Component />` would render "0"
when problemHeight is 0. Changed to `(problemHeight ?? 0) > 0 &&` which
always evaluates to a boolean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:50:39 -06:00
Thomas Hallock
8851be5948 feat(practice): consolidate nav with transport dropdown and mood indicator
- Add SessionMoodIndicator with streak animations and touch-friendly popover
- Consolidate transport controls (pause/resume/browse/end) into dropdown menu
- Remove summary section from SessionProgressIndicator (moved to mood tooltip)
- Add DetailedProblemCard with skill annotations and complexity breakdown
- Add autoPauseCalculator for timing threshold calculations
- Add 0.75 opacity to zero-cost skill pills for reduced visual noise
- Clean up unused timing/estimate code from progress indicator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:35:34 -06:00
Thomas Hallock
c0764ccd85 feat(practice): add inline practice panel for browse mode debugging
Add PracticePreview component that allows practicing any problem while
in browse mode without affecting session state. The practice panel
displays inline below the problem card with a clear header indicating
it doesn't affect the session, preventing UX confusion.

- Add PracticePreview component with keyboard and numpad input support
- Add inline mode to PracticePreview for embedded display
- Update BrowseModeView to show practice panel below problem card
- Toggle button switches between "Practice This Problem" / "Close Practice Panel"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:35:34 -06:00
Thomas Hallock
3c52e607b3 feat(practice): add browse mode navigation and improve SpeedMeter timing display
Browse Mode:
- Add SessionProgressIndicator with collapsible sections for practice/browse modes
- Add BrowseModeView for reviewing problems during practice
- Navigation via clicking progress indicator slots in browse mode
- "Practice This Problem" button to exit browse mode at current problem
- Collapse non-current sections in practice mode (shows ✓count or problem count)

SpeedMeter:
- Add actual time labels (0s, ~Xs avg, Xs pause) positioned under markers
- Extend scale to 120% of threshold so threshold marker isn't always at edge
- Kid-friendly time formatting (8s, 30s, 2m)
- Label overlap detection - combines labels when mean is close to threshold
- Remove unused averageLabel/fastLabel/slowLabel props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:35:34 -06:00
Thomas Hallock
6c88dcfdc5 fix(practice): state-aware complexity selection with graceful fallback
The problem generator was failing with ProblemGenerationError when
minComplexityBudgetPerTerm was set (e.g., challenge slots requiring
complexity >= 1). The issue: skill complexity depends on abacus state,
not just the term value. Adding +4 from 0 is basic (cost 0), but adding
+4 from 7 triggers fiveComplements (cost 1).

The old algorithm filtered terms by minBudget during collection, causing
empty candidate lists when no term could meet the budget at that state.

Fix: Two-phase approach in collectValidTerms() that categorizes ALL
valid terms into meetsMinBudget and belowMinBudget arrays. The selection
prefers high-cost terms but gracefully falls back to lower-cost terms
when the budget is impossible to meet at the current abacus state.

Also adds 31 comprehensive tests covering state-dependent skill
detection, graceful fallback, edge cases, stress tests (1000 problems),
and performance verification.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:35:34 -06:00
github-actions[bot]
d8dee1d746 🎨 Update template examples and crop mark gallery
Auto-generated fresh SVG examples and unified gallery from latest templates.
Includes comprehensive crop mark demonstrations with before/after comparisons.

Files updated:
- packages/templates/gallery-unified.html

🤖 Generated with GitHub Actions

Co-Authored-By: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-14 02:41:33 +00:00
Thomas Hallock
818fdb438d chore: remove debug logging from curriculum modules
Remove console.log statements added during production issue debugging.
The circular import issue causing REINFORCEMENT_CONFIG.creditMultipliers
to be undefined has been fixed. Retain console.error statements for
actual error conditions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 14:25:47 -06:00
Thomas Hallock
aae53aa426 debug: add comprehensive logging to trace REINFORCEMENT_CONFIG import issue
Adding logging at:
1. fluency-thresholds.ts module load time (before and after exports)
2. progress-manager.ts import time (to see what was imported)

This will help identify if:
- The module isn't loading at all
- The config object is partially defined
- There's a circular dependency timing issue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 10:29:11 -06:00
Thomas Hallock
147974a9f0 fix(practice): fix circular import causing REINFORCEMENT_CONFIG.creditMultipliers to be undefined
The issue was that progress-manager.ts was importing REINFORCEMENT_CONFIG
from @/db/schema/player-skill-mastery, which re-exported it from
@/lib/curriculum/config. This created a circular dependency that caused
the config object's creditMultipliers property to be undefined in production.

Root cause found via logging:
```
REINFORCEMENT_CONFIG.creditMultipliers=undefined, helpLevel=0
```

Fix:
- Import REINFORCEMENT_CONFIG directly from @/lib/curriculum/config/fluency-thresholds
- Remove unused re-export from player-skill-mastery.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 10:08:07 -06:00
Thomas Hallock
58192017c7 debug: add detailed logging to recordSkillAttemptWithHelp
This is the function that's actually failing according to the previous
logs. Adding logging around:
- getSkillMastery lookup
- REINFORCEMENT_CONFIG access
- Database update/insert operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 08:49:42 -06:00
Thomas Hallock
f0a9608a6b fix(practice): move SessionPausedModal into ActiveSession for single pause state
- Move SessionPausedModal inside ActiveSession component (single source of truth)
- Replace studentName prop with student: StudentInfo for modal display
- Remove sessionRef imperative handle (no longer needed)
- Fix auto-pause re-triggering immediately after resume by resetting timer
- Update PracticeClient to remove duplicate modal and pause state
- Update stories to use new student prop

Previously, there were two pause states that could get out of sync:
1. PracticeClient's isPaused (controlled modal visibility)
2. ActiveSession's internal phase.phase === 'paused' (controlled input)

Now ActiveSession owns both the modal and the pause state, eliminating the sync issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 08:30:41 -06:00
Thomas Hallock
55e5c121f1 fix: sync pause state between modal and ActiveSession
When the auto-pause modal was dismissed via the Resume button, the modal
would hide but input would remain blocked. This was because:

1. PracticeClient has isPaused state (controls modal visibility)
2. ActiveSession has internal phase.phase='paused' (controls input acceptance)

When the modal's Resume button was clicked, only PracticeClient's state
was updated, leaving ActiveSession stuck in 'paused' phase.

Fix: Add sessionRef prop to ActiveSession that exposes resume/pause
handlers, allowing PracticeClient to trigger ActiveSession's internal
resume when the modal is dismissed.

Long-term: Consider moving the modal inside ActiveSession so there's
a single source of truth for pause state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 08:07:10 -06:00
Thomas Hallock
8802418fe5 debug: add granular logging to recordSlotResult
Add detailed try-catch logging around each step in recordSlotResult:
- Before/after calculateSessionHealth
- Before/after Drizzle update
- Before/after recordSkillAttemptsWithHelp

This will pinpoint exactly where the "[0]" error occurs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 07:58:15 -06:00
Thomas Hallock
85d36c80a2 fix(practice): add comprehensive logging and validation in recordSlotResult
Add detailed logging and more defensive checks to help debug production 500 errors:

- Log entry point with plan ID
- Wrap getSessionPlan in try-catch for better error messages
- Log plan state (status, partIndex, slotIndex, parts/results arrays)
- Validate slots array exists on current part
- Validate results array exists
- All checks provide descriptive error messages

This should help identify exactly where the "Cannot read properties of undefined (reading '0')" error is coming from.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 07:13:21 -06:00
Thomas Hallock
a33e3e6d2b fix(practice): add defensive checks in recordSlotResult
Add robust error handling to recordSlotResult to prevent cryptic
"Cannot read properties of undefined" errors:

- Check that plan.parts exists and is a valid array before indexing
- Validate currentPartIndex is within valid bounds
- Ensure the database update returned a result

These checks provide clearer error messages when data is corrupted
or in an invalid state, helping diagnose production issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 06:48:39 -06:00
Thomas Hallock
9f07bd6df6 refactor(practice): remove deprecated masteryLevel column
Complete Phase 9 of isPracticing migration by removing all traces of the
deprecated 3-state masteryLevel system:

Schema changes:
- Remove masteryLevel column from player_skill_mastery table
- Remove MasteryLevel type export
- Remove MASTERY_CONFIG constant
- Remove calculateMasteryLevel function

Code cleanup:
- Remove masteryLevel from all insert/update operations in progress-manager
- Remove getSkillsByMasteryLevel function and export
- Remove masteryLevel from SkillPerformance interface
- Remove masteryLevel from SkillMasteryData interface in usePlayerCurriculum
- Remove deprecated dbMasteryToState and buildStudentSkillHistory functions
- Remove deprecated tests for removed functions

The system now uses:
- isPracticing: boolean - Set by teacher via checkbox
- FluencyState - Computed from practice history (practicing/effortless/fluent/rusty)
- MasteryState - For cost calculation (adds not_practicing for non-practicing skills)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 21:03:20 -06:00
Thomas Hallock
49d3a8c2d6 chore: remove unused LEGACY_PART_BUDGETS
Dead code that was never used - marked deprecated but had no consumers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 20:16:51 -06:00
Thomas Hallock
b2e7268e7a feat(practice): migrate mastery model to isPracticing + computed fluency
Major refactor of skill mastery tracking system:

**Schema Changes:**
- Add `isPracticing` boolean column to player_skill_mastery
- Fluency state (effortless/fluent/rusty/practicing) now computed from
  practice history instead of stored
- Keep masteryLevel for backwards compatibility (to be removed later)

**Session Planning:**
- Update session-planner to use new isPracticing model
- Add part-type-specific challenge ratios (abacus: 25%, visualization: 15%, linear: 20%)
- Skip challenge slots for students with only basic skills (cost 0)
- Add NoSkillsEnabledError for clear feedback when no skills enabled

**Complexity Budget System:**
- First term exempt from min budget (basic skills always have cost 0)
- Update ProblemDebugPanel to show min/max budgets and term costs
- Create centralized config directory for tuning parameters

**UI Improvements:**
- ManualSkillSelector now uses isPracticing checkboxes
- StartPracticeModal shows specific error for "no skills enabled"
- Better error messages throughout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 20:10:46 -06:00
Thomas Hallock
59f574c178 fix(practice): improve dark mode contrast for sub-nav buttons
- Adjust pause/play button colors for better dark mode contrast
- Restyle stop button with proper dark/light mode variants
- Use conditional colors based on isDark for all button states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 07:41:45 -06:00
Thomas Hallock
2fca17a58b feat(practice): integrate timing display into sub-nav with mobile support
- Move timing display from ActiveSession to PracticeSubNav
- Add per-part-type timing stats (abacus/visualization/linear calculated separately)
- Pass timing data from PracticeClient through sessionHud
- Add responsive mobile styles:
  - Smaller padding and gaps on mobile
  - Hide student name during session on small screens
  - Hide part type text label (keep emoji)
  - Compact timing display with hidden SpeedMeter on very small screens
  - Hide health indicator on small screens
- Add comprehensive Storybook stories for PracticeSubNav covering:
  - Dashboard states, session part types, progress states
  - Timing display states, health indicators
  - Dark mode, mobile/tablet viewports, edge cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 07:07:14 -06:00
Thomas Hallock
18ce1f41af feat(practice): add response time tracking and live timing display
- Fix response time bug: exclude pause duration from calculations
- Add global per-kid stats tracking with new DB columns
- Create SkillPerformanceReports component for dashboard
- Add PracticeTimingDisplay with live problem timer and speed meter
- Extract SpeedMeter to shared component
- Add defensive handling for empty JSON in abacus-settings API

New features:
- Live timer showing elapsed time on current problem
- Speed visualization bar showing position vs average
- Per-part-type timing breakdown (abacus/visualize/linear)
- Skill performance analysis on dashboard (fast/slow/weak skills)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:23:03 -06:00
Thomas Hallock
0c40dd5c42 fix(practice): ensure speed meter bar is always visible
Add minimum width (8%) for the variation bar so it's always visible,
even for very fast students with small absolute values. Clamp mean
position between 5-95% for edge cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:07:51 -06:00
Thomas Hallock
f45428ed82 fix(practice): use inline styles for progress bar
Fix progress bar not rendering by using inline styles instead of
Panda CSS, similar to the resume button fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:06:37 -06:00
Thomas Hallock
cc5bb479c6 fix(practice): correct pause phrase attribution
Auto-pause (system pressed): "We pressed paws!", "This one's a thinker!"
Manual pause (kid pressed): "You pressed paws!", "Good call!", "Smart break!"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:04:47 -06:00
Thomas Hallock
652519f219 feat(practice): separate phrase sets for manual vs auto pause
Auto-pause phrases (thinking-themed):
- "This one's a thinker!", "Brain at work!", "Processing...", etc.

Manual pause phrases (break-themed):
- "We pressed paws! 🙏", "Break time!", "Taking five!", etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:03:45 -06:00
Thomas Hallock
4800a48128 fix(practice): update pun to "We pressed paws!"
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:02:16 -06:00
Thomas Hallock
8405f64486 feat(practice): add "Press paws!" pun to auto-pause phrases
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:01:16 -06:00
Thomas Hallock
c13feddfbb feat(practice): inline emoji with random pause phrases
- Put emoji (🤔/) and phrase on same line in header
- Add random phrases for auto-pause: "This one's a thinker!", "Brain at
  work!", "Deep thoughts happening...", etc.
- Horizontal layout with avatar on left, title/timer on right
- Smaller avatar (56px) for more compact layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 17:01:00 -06:00
Thomas Hallock
0ee14a71b6 refactor(practice): simplify paused modal header layout
Consolidate the stacked header elements into a cleaner layout:
- Single title: "This one's a thinker!" or "Break Time!"
- Timer integrated inline below title instead of separate pill
- Removed redundant name repetition and "Taking a Thinking Break!"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:58:21 -06:00
Thomas Hallock
80a33bcae2 feat(practice): add play emoji to Keep Going button
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:55:53 -06:00
Thomas Hallock
366a1f4b83 refactor(practice): remove manual pause encouragement message
Remove the "Smart thinking to take a break" element to declutter the
paused modal layout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:55:38 -06:00
Thomas Hallock
dd3dd4507c fix(practice): fix invisible resume button by using inline styles
Switch from Panda CSS to inline styles for the resume button to ensure
the green background and white text are properly applied.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:55:10 -06:00
Thomas Hallock
883b683463 refactor(practice): replace "hide" text with close button on stats panel
Add an × close button in the top-right corner of the stats visualization
panel instead of toggling "hide" text in the main paragraph.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:54:11 -06:00
Thomas Hallock
a892902e8a refactor(practice): improve paused modal UX based on feedback
- Remove "We Know Your Rhythm!" heading (creepy)
- Combine explanation text above the bar: "Usually you take X. This one
  took longer, so we paused to check in."
- Hide stats visualization behind low-key "really?" toggle
- Make resume button much more prominent with deeper green, border,
  larger padding, and stronger shadow
- Make "end session" button less prominent with smaller text, muted
  color, and stop emoji

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:53:25 -06:00
Thomas Hallock
0d17809330 refactor(practice): remove stats panel for default timeout case
Only show the rhythm/stats panel when we have enough data to display
meaningful statistics. For the default 5-minute timeout case (before
we've collected enough samples), just show the standard pause UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:49:35 -06:00
Thomas Hallock
3f61dbc0b5 refactor(practice): use Intl.NumberFormat for time duration formatting
Replace hand-rolled duration formatting with Intl.NumberFormat using
the 'unit' style. This leverages browser-native localization for
pluralization (1 second vs 2 seconds) and proper formatting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:47:18 -06:00
Thomas Hallock
11ecb385ad feat(practice): redesign paused modal with kid-friendly statistics UX
Redesign SessionPausedModal to be approachable for children while
maintaining high-fidelity statistical information:

- New visual components:
  - SpeedMeter: shows average response time vs variation range
  - SampleDots: visualizes progress toward learning user's rhythm (5 samples)

- Educational framing:
  - "We Know Your Rhythm!" when we have enough samples
  - "Learning Your Rhythm..." when collecting data
  - "Taking a Thinking Break!" instead of clinical "paused" language

- Friendly UI improvements:
  - Contextual emoji thought bubbles (🤔 for auto-pause,  for manual)
  - Encouraging messages ("Smart thinking to take a break!")
  - "Keep Going!" button instead of "Resume"
  - Progress bar with gradient styling

- Statistical transparency:
  - Shows "Usually you take about X seconds" for mean
  - Visual representation of standard deviation as "wiggle room"
  - Explains why the pause happened in child-friendly terms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:46:06 -06:00
Thomas Hallock
826c8490ba feat(practice): add pause info with response time statistics to paused modal
- Add auto-pause when response time exceeds mean + 2σ (or 5min default)
- Track pause reason (manual vs auto-timeout) and timing info
- Display live-updating pause duration counter
- Show statistical details: sample count, mean, std dev, threshold
- For insufficient data, show "need X more problems for personalized timing"
- Add comprehensive Storybook stories for all pause scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:38:17 -06:00
Thomas Hallock
9c1fd85ed5 feat(practice): add auto-pause and improve docked abacus sizing
- Add auto-pause when user takes too long on a problem
  - Uses mean + 2 standard deviations of response times when ≥5 problems
  - Falls back to 5 minute timeout otherwise
  - Clamped between 30 seconds and 5 minutes

- Fix docked abacus to auto-scale to match problem dimensions
  - AbacusDock now uses width: 100% and measured problem height
  - MyAbacus calculates effectiveScaleFactor based on container size
  - Animations use consistent scale calculations

- Fix bug: session paused modal no longer shows on page reload
- Fix bug: help mode now exits when both overlay and panel are dismissed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 15:01:45 -06:00
Thomas Hallock
60fc81bc2d feat(practice): improve docked abacus UX and submit button behavior
- Force show submit button when abacus is docked (user needs manual submit)
- Disable auto-help when docked; trigger help on submit for prefix sums
- Fix dock animation to measure actual destination position
- Keep problem centered when dock appears/disappears (absolute positioning)
- Use scaleFactor prop for natural abacus sizing instead of manual calculations
- Clean up unused dock size tracking and scale calculation code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 14:35:15 -06:00
Thomas Hallock
2c832c7944 feat(abacus): add smooth animated transitions for dock/undock
Implement FLIP-style animation for the abacus docking feature:
- Measure viewport positions of button and dock using getBoundingClientRect
- Use react-spring to animate position, size, scale, and border-radius
- Add chromeOpacity spring value to smoothly fade button styling
  (background, border, shadow, backdrop-blur) during transitions
- Animation layer renders as fixed-position overlay during transition
- Docking: button chrome fades out as abacus flies to dock
- Undocking: button chrome fades in as abacus returns to corner

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 14:02:23 -06:00
Thomas Hallock
5fb4751728 feat(abacus): add dockable abacus feature for practice sessions
Add AbacusDock component that allows the floating MyAbacus to dock into
designated areas within the UI:
- New AbacusDock component with configurable props (columns, showNumbers,
  value, defaultValue, onValueChange, interactive, animated)
- MyAbacus can now render as: hero, button, open overlay, or docked
- Click floating button when dock is visible to dock the abacus
- Undock button appears in top-right of docked abacus
- Practice sessions use dock for answer input (auto-submit on correct answer)
- Dock sizing now matches problem height with responsive widths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 13:37:28 -06:00
Thomas Hallock
1a7945dd0b fix(practice): correct route path for resume session
Change /practice/[studentId]/session to /practice/[studentId] since the
active session page is at the root [studentId] path, not a /session subfolder.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 12:23:04 -06:00
Thomas Hallock
5730bd6112 fix(practice): ensure badges are never taller than wide
Use fixed height with minWidth and pill-shaped border-radius so badges expand horizontally for multi-digit numbers while staying circular for single digits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 12:04:01 -06:00
Thomas Hallock
34d0232451 feat(practice): improve modal UI with problem counts and time estimation
- Wire up time estimation utility to StartPracticeModal for per-mode problem counts
- Add problem count indicators: underneath emojis in collapsed view, badges on mode boxes in expanded view
- Ensure badges are always circular (aspect-ratio: 1)
- Add full-width progress bar to PracticeSubNav HUD with mode indicator and "X left" display
- Add SessionPausedModal for pause state handling
- Refactor ActiveSession to remove internal HUD (moved to sub-nav)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 12:01:52 -06:00
Thomas Hallock
839171c0ff fix(practice): prevent keypad from covering nav and content
Add CSS to push main content away from the keypad:
- Landscape (small screens): padding-right on body and margin-right on
  nav/header/active-session to avoid the 100px right-side keypad
- Portrait: padding-bottom on body for the bottom keypad

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:25:46 -06:00
Thomas Hallock
6c09976d4b fix(practice): only show landscape keypad on phone-sized screens
Use max-height: 500px constraint to only switch to the two-column
landscape layout on small screens (phones). On tablets and larger
screens in landscape, keep the horizontal bar at the bottom.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:22:28 -06:00
Thomas Hallock
31fbf80b8f fix(practice): use raw CSS media query for landscape keypad visibility
Panda CSS @media queries in object syntax weren't working reliably.
Use raw CSS string with proper @media (orientation: landscape) rule
to toggle between portrait and landscape keypad containers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:15:51 -06:00
Thomas Hallock
1058f411c6 fix(practice): remove empty spacer button from keypad layout
When submit button isn't shown, omit it entirely from the layout
instead of using a hidden spacer. This lets the remaining buttons
flex to fill the full width.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:07:35 -06:00
Thomas Hallock
4b8cbdf83c fix(practice): ensure keypad spans full screen width
Added explicit width: 100% and max-width: none to override
react-simple-keyboard defaults that may constrain width.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:04:26 -06:00
Thomas Hallock
ee8dccd83a feat(practice): responsive mobile keypad and unified skill detection
NumericKeypad improvements:
- Fixed position: bottom bar in portrait, right panel in landscape
- Uses react-simple-keyboard with key-like styling (raised, press effect)
- Persists once shown even if physical keyboard detected

Skill detection refactoring:
- Unified all skill analysis through generateUnifiedInstructionSequence
- Removed ~210 lines of dead column-based analysis code
- Added cascading carry/borrow detection for consecutive ten complements
- Ported test cases from columnAnalysis.test.ts to skillDetection.test.ts

abacus-react:
- Added server-side compatible static exports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:03:19 -06:00
Thomas Hallock
1139c4d1a1 fix(practice): correct five complement skill detection for addition and subtraction
Fix bugs where five complement skills were incorrectly detected when the
heaven bead was already active:

Addition: When adding 1-4 results in 6-9 and currentDigit >= 5, no five
complement is needed - just add earth beads directly.

Subtraction: When ten complement addition crosses 5 boundary but
currentDigit >= 5, no five complement is needed for the addition part.

Also includes UX improvements from previous session:
- Dream sequence: show target value for 1s then fade out after help completion
- Clear answer boxes on help abacus dismiss
- Handle typing during help mode

Add comprehensive unit tests for column analysis functions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 06:50:57 -06:00
Thomas Hallock
bcb1c7a173 feat(practice): improve help mode UX with crossfade and dismiss behaviors
- Add crossfade animation between answer boxes and help abacus (1s enter, 300ms dismiss)
- Preserve user's prefix sum in answer boxes during fade-out transition
- Clear answer boxes after help abacus entrance transition completes
- Add dismiss button to help abacus with tooltip suppression on dismiss
- Add keyboard shortcuts (Escape/Delete/Backspace) to exit help mode
- Typing while in help mode dismisses help and starts fresh input
- Add independent dismiss controls for help abacus and help panel
- Fix tooltip remaining visible when help abacus is dismissed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 06:06:57 -06:00
Thomas Hallock
a27fb0c9a4 feat(practice): improve session summary UI
- Group skills by category (pedagogical order: basic → 5-complements → 10-complements)
- Make skill categories collapsible with aggregate stats in header
- Use vertical layout for abacus/visualization problems (compact workbook style)
- Use horizontal layout for mental math problems (equation format)
- Remove duplicate "Review Answered Problems" section
- Remove "Review Problems" heading (self-evident)
- Fix part names: "Abacus", "Visualize", "Mental Math"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 05:05:38 -06:00
Thomas Hallock
f95456dadc fix(practice): remove fallback random problem generation
Remove the fallback that was silently generating random problems when
skill-based generation failed. Instead, throw ProblemGenerationError
with detailed constraint information so issues can be addressed.

The analyzeRequiredSkills function should only be used for tests or
stories, not in production where we need accurate skill tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 20:56:48 -06:00
Thomas Hallock
5d61de4bf6 feat(practice): add complexity budget system and toggleable session parts
- Add skill complexity budget system with base costs per skill type:
  - Basic skills: 0 (trivial bead movements)
  - Five complements: 1 (single mental substitution)
  - Ten complements: 2 (cross-column operations)
  - Cascading operations: 3 (multi-column)

- Add per-term complexity debug overlay in VerticalProblem (toggle via visual debug mode)
  - Shows total cost per term and individual skill costs
  - Highlights over-budget terms in red

- Make session structure parts toggleable in configure page:
  - Can enable/disable abacus, visualization, and linear parts
  - Time estimates, problem counts adjust dynamically
  - At least one part must remain enabled

- Fix max terms per problem not being respected:
  - generateSingleProblem was hardcoding 3-5 terms
  - Now properly uses minTerms/maxTerms from constraints

- Set visualization complexity budget to 3 (more restrictive)
- Hide complexity badges for zero-cost (basic) skills in ManualSkillSelector

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 20:18:20 -06:00
Thomas Hallock
9159608dcd feat(practice): reduce term count for visualization part
Visualization problems now use 2-4 terms (75% of abacus's 3-6 terms)
to make them easier since students don't have the physical abacus
to help track their mental calculations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:32:19 -06:00
Thomas Hallock
7cf689c3d9 feat(practice): add cascading regrouping skills and improve help UX
- Add advanced.cascadingCarry and advanced.cascadingBorrow skills for
  detecting when carry/borrow propagates across 2+ consecutive columns
  (e.g., 999 + 1 = 1000 or 1000 - 1 = 999)
- Update VerticalProblem help UX: replace answer boxes with help abacus
  instead of floating above terms (less confusing for kids)
- Dim terms already in prefix sum at 40% opacity when in help mode
- Enlarge current-help arrow indicator to 1.75rem
- Add "Advanced Multi-Column Operations" category to ManualSkillSelector
  so teachers can manually enable these skills
- Add unit tests for cascading regrouping detection (21 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:19:09 -06:00
Thomas Hallock
5cfbeeb8df fix(practice): size answer boxes for intermediate prefix sums
- Calculate prefix sums (intermediate values) when determining maxDigits
- Ensures kids can enter step-by-step solutions that may be larger than final answer
- Example: 100-99=1 now has 3 answer boxes to accommodate entering "100" first

Also adds Playground story for testing any term sequence with:
- Textarea input with smart parsing of negatives
- Display of all prefix sums and max digits needed
- Interactive answer input

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:24:20 -06:00
Thomas Hallock
e5c697b7a8 feat(tutorial): implement subtraction in unified step generator
Add complete subtraction support to the decomposition system:

- Direct subtraction: remove beads when sufficient
- Five's complement subtraction: -d = -5 + (5-d)
- Ten's complement subtraction (borrow): -d = +(10-d) - 10
- Multi-digit subtraction with left-to-right processing
- Cascade borrow through consecutive zeros

Also adds:
- Comprehensive architecture documentation
- Subtraction implementation plan with design decisions
- Decomposition audit story for testing all operation types
- Skill extraction functions for subtraction skills

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 09:31:13 -06:00
Thomas Hallock
4f7a9d76cd feat(practice): add subtraction support to problem generator
- Add subtraction problem generation alongside addition
- Generator now uses signed terms (negative = subtraction)
- Update analyzeRequiredSkills to handle mixed operations
- Remove dead generateSkillTrace function (replaced by provenance)
- Add ProblemGeneratorAudit story for debugging skill analysis
- Display subtraction terms in red with proper +/- signs in audit UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 08:06:34 -06:00
Thomas Hallock
a3e79dac74 chore: sync lockfile with package.json
Regenerate pnpm-lock.yaml to fix CI deployment failure.
The lockfile was out of sync causing "ERR_PNPM_OUTDATED_LOCKFILE"
in GitHub Actions when running with frozen-lockfile.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 07:06:29 -06:00
Thomas Hallock
e42766c893 feat(practice): add 30 and 45 minute session duration options
Extends session duration choices from [5, 10, 15, 20] to [5, 10, 15, 20, 30, 45] minutes, allowing for longer practice sessions with more problems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 21:01:13 -06:00
Thomas Hallock
c40543ac64 feat(practice): unify dashboard with session-aware progress display
- Make ProgressDashboard session-aware with single primary CTA
  - No session: "Start Practice →" (blue)
  - Active session: "Resume Practice →" (green) with progress count
  - Single "Start over" link replaces redundant Abandon/Regenerate buttons
- Add skill mismatch warning inline in level card
- Add masteredSkillIds to session_plans for mismatch detection
- Fix getActiveSessionPlan to check completedAt IS NULL (fixes loop bug)
- Remove separate Active Session Card from dashboard (now integrated)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 20:59:53 -06:00
Thomas Hallock
245cc269fe feat(practice): use student's actual mastered skills for problem generation
Major fix: Session planner now uses the student's actual mastered skills
from the database instead of hardcoded phase-based constraints.

Changes:
- Add buildConstraintsFromMasteredSkills() to convert student skill records
  to problem generator constraints
- Session planner fetches mastered skills and passes them to problem generator
- Skills set via ManualSkillSelector now actually affect generated problems
- Remove unused buildChallengeConstraints() function
- Fix findStrugglingSkills() signature (remove unused param)

Also includes supporting changes from previous session:
- Add setMasteredSkills() to progress-manager for persisting skills
- Add PUT endpoint to skills/route.ts for saving mastered skills
- Display mastered skills in session configure preview
- Add "View All Planned Problems" section to SessionSummary
- Sync ManualSkillSelector state when modal opens

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 19:18:48 -06:00
Thomas Hallock
c19109758a refactor(practice): remove unnecessary route guards
Routes are now independent views, not exclusive states:
- /dashboard: always accessible (view stats during session)
- /configure: always accessible (prep next session during session)
- /summary: always accessible (shows in-progress partial results,
  completed session, or empty state)

Only /practice/[studentId] retains guards since it requires an
active in-progress session to render a problem.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:10:54 -06:00
Thomas Hallock
5ebc743b43 refactor(practice): unify configure page with live session preview
- Restructure practice routes so each route represents valid state
- /practice/[studentId] now ONLY shows the current problem
- New /dashboard route for progress view
- New /summary route with guards (can't view mid-session)
- Combine configure + plan review into single unified page with:
  - Duration selector that updates preview in real-time
  - Live problem count and session structure preview
  - Single "Let's Go!" button that generates + starts session
- Replace two-stage flow with instant feedback UX
- Delete StudentPracticeClient (replaced by simpler PracticeClient)
- Add getMostRecentCompletedSession for summary page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 14:13:54 -06:00
Thomas Hallock
9c646acc16 style: format practice session files
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 12:48:36 -06:00
Thomas Hallock
f74db216da chore: remove abandoned 3d-printing feature
Remove dead code from abandoned 3D printing initiative:
- Delete jobManager.ts (had unbounded memory growth)
- Delete openscad.worker.ts (unused)
- Delete 3D model files from public/
- Remove openscad-wasm-prebuilt dependency
- Clean up doc references

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 12:48:13 -06:00
Thomas Hallock
ae1a0a8e2d fix(practice): use React Query cache for /resume page session data
The /resume page was showing stale session data when navigating mid-session.
Now uses useActiveSessionPlan with server props as initialData, so cached
session data from the active practice session takes priority.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 12:48:05 -06:00
Thomas Hallock
28b3b30da6 fix(practice): include endEarly.data in currentPlan priority chain
The stop button wasn't working because endEarly.data was not included
in the currentPlan derivation chain. When the mutation completed with
the updated plan (completedAt set), the view didn't update to 'summary'.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 12:22:15 -06:00
Thomas Hallock
7b476e80c1 feat(practice): add /resume route for "Welcome back" experience
- Create /practice/[studentId]/resume route for returning students
- Student selector navigates to /resume instead of main practice page
- /resume shows "Welcome back" card with session progress
- Clicking "Continue" navigates to /practice/[studentId] (goes straight to practice)
- Clicking "Start Fresh" abandons session and goes to /configure
- Main practice page no longer shows welcome card (goes straight to practicing)
- Reloading mid-session stays in practice (no welcome card)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 12:00:01 -06:00
Thomas Hallock
7243502873 fix(practice): make session plan page self-sufficient for data loading
- Update useActiveSessionPlan to accept initialData from server props
- Page now fetches its own data if cache is empty (no abstraction hole)
- Three loading scenarios handled:
  1. Cache populated (from ConfigureClient mutation): instant display
  2. Cache miss: fetches from API with loading state
  3. Direct page load: uses server props as initialData
- Add loading view while fetching session plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 11:52:52 -06:00
Thomas Hallock
8a9afa86bc fix(practice): disable auto-scroll and add modern PWA meta tag
- Add scroll: false to all router.push() calls in practice pages
- Add scroll={false} to Link component in not-found page
- Fixes Next.js warning about auto-scroll with fixed position header
- Add mobile-web-app-capable meta tag alongside deprecated apple-mobile-web-app-capable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 11:25:53 -06:00
Thomas Hallock
43e7db4e88 fix(practice): check all later prefix sums for ambiguity, not just final answer
Previously, typing "3" for problem [2, 1, 30, 10, 1] with prefix sums
[2, 3, 33, 43, 44] would immediately show help for prefix sum 3 because
the code only checked if "3" was a digit-prefix of the final answer (44).

Now it correctly checks if "3" could be a digit-prefix of ANY later prefix
sum (33 in this case), making it ambiguous and allowing the user to continue
typing "33" to get help for that prefix instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 10:48:38 -06:00
Thomas Hallock
ed277ef745 feat(practice): refactor disambiguation into state machine with comprehensive tests
- Complete migration of disambiguation state into the state machine
- Remove backward compatibility code (no legacy concerns in new app)
- Eliminate dual-state patterns in ActiveSession.tsx
- Export derived state from hook (attempt, helpContext, outgoingAttempt)
- Export boolean predicates (isTransitioning, isPaused, isSubmitting)
- Add comprehensive tests for awaitingDisambiguation phase
- Fix tests to match actual unambiguous prefix sum behavior
- Add SSR support with proper hydration for practice pages

The state machine is now the single source of truth for all UI state.
Unambiguous prefix matches immediately trigger helpMode, while ambiguous
matches enter awaitingDisambiguation with a 4-second timer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 10:19:14 -06:00
Thomas Hallock
46ff5f528a feat(practice): add prefix sum disambiguation and debug panel
- Add ProblemDebugPanel component for viewing current problem details
  when visual debug mode is enabled (fixed position, collapsible, copy JSON)

- Fix false positive help mode triggers when typing multi-digit answers
  - "3" when answer is "33" now shows "need help?" prompt instead of
    immediately triggering help mode
  - 4 second timer before auto-triggering help in ambiguous cases

- Add leading zero disambiguation for requesting help
  - Typing "03" explicitly requests help for prefix sum 3
  - isDigitConsistent now allows leading zeros
  - findMatchedPrefixIndex treats leading zeros as unambiguous help request

- Add "need help?" styled pill prompt on ambiguous prefix matches
  - Yellow pill badge with arrow pointing to the term
  - Pulse animation for visibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 09:32:27 -06:00
Thomas Hallock
36c9ec3301 fix(practice): handle paused state transitions and add complete phase
Fix edge cases in the state machine:
- completeSubmit now works while paused (updates resumePhase)
- completeTransition now works while paused (updates resumePhase)
- Add 'complete' phase for session completion
- Allow enterHelpMode from helpMode (navigate between terms)
- Add transformActivePhase helper for paused state handling
- Add markComplete action and isComplete predicate
- Prevent pausing from complete phase

Add 5 new tests for these edge cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 15:47:47 -06:00
Thomas Hallock
1ce448eb0b refactor(practice): clean up props for state machine compatibility
- NumericKeypad: add showSubmitButton prop to hide submit during auto-submit
- VerticalProblem: remove autoSubmitPending prop (state machine handles this)
- Add usePracticeSoundEffects hook for centralized sound effect management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 15:28:03 -06:00
Thomas Hallock
4d41c9c54a refactor(practice): replace boolean flags with state machine
Replace scattered boolean state flags (isPaused, isSubmitting, isTransitioning,
feedback, helpTermIndex) with a single discriminated union state machine.

- Add useInteractionPhase hook with 7 explicit phases:
  loading, inputting, helpMode, submitting, showingFeedback, transitioning, paused
- Derive all UI predicates from phase state (canAcceptInput, showHelpOverlay, etc.)
- Delete useProblemAttempt hook (superseded by state machine)
- Add 62 comprehensive tests for phase transitions and derived state

Benefits:
- Single source of truth for all interaction state
- Impossible states eliminated (can't be paused AND submitting)
- Explicit phase transitions instead of scattered boolean flipping
- Type safety ensures phase-appropriate data access

Both vertical and linear problem formats use the same state machine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 15:28:03 -06:00
314 changed files with 86451 additions and 9098 deletions

View File

@@ -181,9 +181,44 @@
"Bash(shasum:*)",
"Bash(open http://localhost:3000/arcade/matching)",
"Bash(echo:*)",
"Bash(npm run type-check:*)"
"Bash(npm run type-check:*)",
"mcp__sqlite__read_query",
"mcp__sqlite__list_tables",
"mcp__sqlite__describe_table",
"Bash(npm run format:*)",
"Bash(npm run lint:fix:*)",
"Bash(npm run lint:*)",
"Bash(npx drizzle-kit:*)",
"Bash(npm run db:migrate:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run seed:test-students:*)",
"Bash(npx @biomejs/biome lint:*)",
"Bash(npm run build:seed-script:*)",
"Bash(ls:*)",
"mcp__sqlite__write_query",
"Bash(apps/web/src/lib/curriculum/session-mode.ts )",
"Bash(apps/web/src/app/api/curriculum/[playerId]/session-mode/ )",
"Bash(apps/web/src/hooks/useSessionMode.ts )",
"Bash(apps/web/src/components/practice/SessionModeBanner.tsx )",
"Bash(apps/web/src/components/practice/SessionModeBanner.stories.tsx )",
"Bash(apps/web/src/components/practice/index.ts )",
"Bash(apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx )",
"Bash(apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx )",
"Bash(apps/web/src/components/practice/StartPracticeModal.tsx )",
"Bash(apps/web/src/components/practice/StartPracticeModal.stories.tsx)",
"Bash(apps/web/src/lib/curriculum/session-planner.ts )",
"Bash(apps/web/src/lib/curriculum/index.ts )",
"Bash(apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts )",
"Bash(apps/web/src/hooks/useSessionPlan.ts )",
"Bash(apps/web/src/components/practice/StartPracticeModal.tsx)",
"Bash(apps/web/.claude/REMEDIATION_CTA_PLAN.md)",
"Bash(npx @biomejs/biome:*)"
],
"deny": [],
"ask": []
}
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
}

1
.gitignore vendored
View File

@@ -59,3 +59,4 @@ temp/
.claude/settings.local.json
*storybook.log
storybook-static
apps/web/data/sqlite.db.backup.*

View File

@@ -858,6 +858,7 @@ React component library for rendering interactive and static abacus visualizatio
Interactive mathematical decomposition visualization showing step-by-step soroban operations. Features hoverable terms with pedagogical explanations, grouped operations, and bidirectional abacus coordination.
**Key Features**:
- **Interactive Terms** - Hover to see why each operation is performed
- **Pedagogical Grouping** - Related operations (e.g., "+10 -3" for adding 7) grouped visually
- **Step Tracking** - Integrates with tutorial and practice step progression
@@ -871,6 +872,7 @@ Interactive mathematical decomposition visualization showing step-by-step soroba
Structured curriculum-based practice system following traditional Japanese soroban teaching methodology.
**Key Features**:
- **Student Progress Tracking** - Per-skill mastery levels (learning → practicing → mastered)
- **Session Planning** - Adaptive problem selection based on student history
- **Teacher Controls** - Real-time session health monitoring and mid-session adjustments

View File

@@ -1,335 +0,0 @@
# 3D Printing Docker Setup
## Summary
The 3D printable abacus customization feature is fully containerized with optimized Docker multi-stage builds.
**Key Technologies:**
- OpenSCAD 2021.01 (for rendering STL/3MF from .scad files)
- BOSL2 v2.0.0 (minimized library, .scad files only)
- Typst v0.11.1 (pre-built binary)
**Image Size:** ~257MB (optimized with multi-stage builds, saved ~38MB)
**Build Stages:** 7 total (base → builder → deps → typst-builder → bosl2-builder → runner)
## Overview
The 3D printable abacus customization feature requires OpenSCAD and the BOSL2 library to be available in the Docker container.
## Size Optimization Strategy
The Dockerfile uses **multi-stage builds** to minimize the final image size:
1. **typst-builder stage** - Downloads and extracts typst, discards wget/xz-utils
2. **bosl2-builder stage** - Clones BOSL2 and removes unnecessary files (tests, docs, examples, images)
3. **runner stage** - Only copies final binaries and minimized libraries
### Size Reductions
- **Removed from runner**: git, wget, curl, xz-utils (~40MB)
- **BOSL2 minimized**: Removed .git, tests, tutorials, examples, images, markdown files (~2-3MB savings)
- **Kept only .scad files** in BOSL2 library
## Dockerfile Changes
### Build Stages Overview
The Dockerfile now has **7 stages**:
1. **base** (Alpine) - Install build tools and dependencies
2. **builder** (Alpine) - Build Next.js application
3. **deps** (Alpine) - Install production node_modules
4. **typst-builder** (Debian) - Download and extract typst binary
5. **bosl2-builder** (Debian) - Clone and minimize BOSL2 library
6. **runner** (Debian) - Final production image
### Stage 1-3: Base, Builder, Deps (unchanged)
Uses Alpine Linux for building the application (smaller and faster builds).
### Stage 4: Typst Builder (lines 68-87)
```dockerfile
FROM node:18-slim AS typst-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
xz-utils \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN ARCH=$(uname -m) && \
... download and install typst from GitHub releases
```
**Purpose:** Download typst binary in isolation, then discard build tools (wget, xz-utils).
**Result:** Only the typst binary is copied to runner stage (line 120).
### Stage 5: BOSL2 Builder (lines 90-103)
```dockerfile
FROM node:18-slim AS bosl2-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /bosl2 && \
cd /bosl2 && \
git clone --depth 1 --branch v2.0.0 https://github.com/BelfrySCAD/BOSL2.git . && \
# Remove unnecessary files to minimize size
rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \
# Keep only .scad files and essential directories
find . -type f ! -name "*.scad" -delete && \
find . -type d -empty -delete
```
**Purpose:** Clone BOSL2 and aggressively minimize by removing:
- `.git` directory
- Tests, tutorials, examples
- Documentation (markdown files)
- Images
- All non-.scad files
**Result:** Minimized BOSL2 library (~1-2MB instead of ~5MB) copied to runner (line 124).
### Stage 6: Runner - Production Image (lines 106-177)
**Base Image:** `node:18-slim` (Debian) - Required for OpenSCAD availability
**Runtime Dependencies (lines 111-117):**
```dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
qpdf \
openscad \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
```
**Removed from runner:**
- ❌ git (only needed in bosl2-builder)
- ❌ wget (only needed in typst-builder)
- ❌ curl (not needed at runtime)
- ❌ xz-utils (only needed in typst-builder)
**Artifacts Copied from Other Stages:**
```dockerfile
# From typst-builder (line 120)
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
# From bosl2-builder (line 124)
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
# From builder (lines 131-159)
# Next.js app, styled-system, server files, etc.
# From deps (lines 145-146)
# Production node_modules only
```
BOSL2 v2.0.0 (minimized) is copied to `/usr/share/openscad/libraries/BOSL2/`, which is OpenSCAD's default library search path. This allows `include <BOSL2/std.scad>` to work in the abacus.scad file.
### Temp Directory for Job Outputs (line 168)
```dockerfile
RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp
```
Creates the directory where JobManager stores generated 3D files.
## Files Included in Docker Image
The following files are automatically included via the `COPY` command at line 132:
```
apps/web/public/3d-models/
├── abacus.scad (parametric OpenSCAD source)
└── simplified.abacus.stl (base model, 4.8MB)
```
These files are NOT excluded by `.dockerignore`.
## Testing the Docker Build
### Local Testing
1. **Build the Docker image:**
```bash
docker build -t soroban-abacus-test .
```
2. **Run the container:**
```bash
docker run -p 3000:3000 soroban-abacus-test
```
3. **Test OpenSCAD inside the container:**
```bash
docker exec -it <container-id> sh
openscad --version
ls /usr/share/openscad/libraries/BOSL2
```
4. **Test the 3D printing endpoint:**
- Visit http://localhost:3000/3d-print
- Adjust parameters and generate a file
- Monitor job progress
- Download the result
### Verify BOSL2 Installation
Inside the running container:
```bash
# Check OpenSCAD version
openscad --version
# Verify BOSL2 library exists
ls -la /usr/share/openscad/libraries/BOSL2/
# Test rendering a simple file
cd /app/apps/web/public/3d-models
openscad -o /tmp/test.stl abacus.scad
```
## Production Deployment
### Environment Variables
No additional environment variables are required for the 3D printing feature.
### Volume Mounts (Optional)
For better performance and to avoid rebuilding the image when updating 3D models:
```bash
docker run -p 3000:3000 \
-v $(pwd)/apps/web/public/3d-models:/app/apps/web/public/3d-models:ro \
soroban-abacus-test
```
### Disk Space Considerations
- **BOSL2 library**: ~5MB (cloned during build)
- **Base STL file**: 4.8MB (in public/3d-models/)
- **Generated files**: Vary by parameters, typically 1-10MB each
- **Job cleanup**: Old jobs are automatically cleaned up after 1 hour
## Image Size
The final image is Debian-based (required for OpenSCAD), but optimized using multi-stage builds:
**Before optimization (original Debian approach):**
- Base runner: ~250MB
- With all build tools (git, wget, curl, xz-utils): ~290MB
- With BOSL2 (full): ~295MB
- **Total: ~295MB**
**After optimization (current multi-stage approach):**
- Base runner: ~250MB
- Runtime deps only (no build tools): ~250MB
- BOSL2 (minimized, .scad only): ~252MB
- 3D models (STL): ~257MB
- **Total: ~257MB**
**Savings: ~38MB (~13% reduction)**
### What Was Removed
- ❌ git (~15MB)
- ❌ wget (~2MB)
- ❌ curl (~5MB)
- ❌ xz-utils (~1MB)
- ❌ BOSL2 .git directory (~1MB)
- ❌ BOSL2 tests, examples, tutorials (~10MB)
- ❌ BOSL2 images and docs (~4MB)
**Total removed: ~38MB**
This trade-off (Debian vs Alpine) is necessary for OpenSCAD availability, but the multi-stage approach minimizes the size impact.
## Troubleshooting
### OpenSCAD Not Found
If you see "openscad: command not found" in logs:
1. Verify OpenSCAD is installed:
```bash
docker exec -it <container-id> which openscad
docker exec -it <container-id> openscad --version
```
2. Check if the Debian package install succeeded:
```bash
docker exec -it <container-id> dpkg -l | grep openscad
```
### BOSL2 Include Error
If OpenSCAD reports "Can't open library 'BOSL2/std.scad'":
1. Check BOSL2 exists:
```bash
docker exec -it <container-id> ls /usr/share/openscad/libraries/BOSL2/std.scad
```
2. Test include path:
```bash
docker exec -it <container-id> sh -c "cd /tmp && echo 'include <BOSL2/std.scad>; cube(10);' > test.scad && openscad -o test.stl test.scad"
```
### Job Fails with "Permission Denied"
Check tmp directory permissions:
```bash
docker exec -it <container-id> ls -la /app/apps/web/tmp
# Should show: drwxr-xr-x ... nextjs nodejs ... 3d-jobs
```
### Large File Generation Timeout
Jobs timeout after 60 seconds. For complex models, increase the timeout in `jobManager.ts:138`:
```typescript
timeout: 120000, // 2 minutes instead of 60 seconds
```
## Performance Notes
- **Cold start**: First generation takes ~5-10 seconds (OpenSCAD initialization)
- **Warm generations**: Subsequent generations take ~3-5 seconds
- **STL size**: Typically 5-15MB depending on scale parameters
- **3MF size**: Similar to STL (no significant compression)
- **SCAD size**: ~1KB (just text parameters)
## Monitoring
Job processing is logged to stdout:
```
Executing: openscad -o /app/apps/web/tmp/3d-jobs/abacus-abc123.stl ...
Job abc123 completed successfully
```
Check logs with:
```bash
docker logs <container-id> | grep "Job"
```

View File

@@ -7,10 +7,12 @@ When animating continuous rotation where the **speed changes smoothly** but you
### The Problem
**CSS Animation approach fails because:**
- Changing `animation-duration` resets the animation phase, causing jumps
- `animation-delay` tricks don't reliably preserve position across speed changes
**Calling `spring.start()` 60fps fails because:**
- React-spring's internal batching can't keep up with 60fps updates
- Spring value lags 1000+ degrees behind, causing wild spinning
- React re-renders interfere with spring updates

View File

@@ -0,0 +1,683 @@
# Bayesian Knowledge Tracing (BKT) Design Specification
## Overview
This document specifies the implementation of Conjunctive Bayesian Knowledge Tracing for the soroban practice system. BKT provides epistemologically honest skill mastery estimates that account for:
1. **Asymmetric evidence**: Correct answers prove all skills; wrong answers only prove ≥1 skill failed
2. **Multi-skill problems**: Probabilistic blame distribution across co-occurring skills
3. **Uncertainty quantification**: Confidence intervals on mastery estimates
4. **Staleness indicators**: Show "last practiced X days ago" separately (not decay)
## Architecture Decision: Lazy Computation
**Key Decision**: BKT is computed on-demand when viewing reports, NOT in real-time during practice.
**Why:**
- No new database tables needed
- No hooks into practice session flow
- Can replay SlotResult history to compute BKT state
- Easy to change algorithm without migration
- Can add user controls (confidence slider, priors toggle) dynamically
- Estimated computation time: ~50ms for full report
**How it works:**
1. User opens Skills Dashboard
2. Dashboard fetches recent SlotResults (already stored in session_plans)
3. Pure functions replay history to compute BKT state for each skill
4. Display results with confidence indicators
---
## The Problem We're Solving
**Current approach (naive):**
```
accuracy = correct / attempts // Treats both signals as equivalent
```
**Why it's wrong:**
- Correct: Strong evidence ALL skills are known
- Incorrect: Weak evidence that ONE OR MORE skills failed (we don't know which)
**BKT approach:**
- Maintain P(known) per skill with proper Bayesian updates
- Distribute "blame" for errors probabilistically based on prior beliefs
- Report uncertainty honestly
---
## 1. Data Source
### Existing Data (No Schema Changes Needed)
We already have all the data we need in `session_plans.results`:
```typescript
// From src/db/schema/session-plans.ts
export interface SlotResult {
slotIndex: number;
problemIndex: number;
problem: GeneratedProblem; // Contains skillIds
isCorrect: boolean;
timestamp: number;
responseTimeMs: number;
userAnswer: number | null;
helpLevel: 0 | 1 | 2 | 3;
}
```
The `problem.skillIds` field tells us which skills were involved in each problem.
### Data Fetching
Already implemented: `getRecentSessionResults(playerId, sessionCount)` in `session-planner.ts`
---
## 2. BKT Algorithm (Pure Functions)
### 2.1 Core BKT Update Equations
```typescript
// src/lib/curriculum/bkt/bkt-core.ts
export interface BktParams {
pInit: number; // P(L0) - prior knowledge
pLearn: number; // P(T) - learning rate
pSlip: number; // P(S) - slip rate
pGuess: number; // P(G) - guess rate
}
export interface BktState {
pKnown: number;
opportunities: number;
successCount: number;
lastPracticedAt: Date | null;
}
/**
* Standard BKT update for a SINGLE skill given an observation.
*
* For correct answer:
* P(known | correct) = P(correct | known) × P(known) / P(correct)
* where P(correct | known) = 1 - P(slip)
* and P(correct | ¬known) = P(guess)
*
* For incorrect answer:
* P(known | incorrect) = P(incorrect | known) × P(known) / P(incorrect)
* where P(incorrect | known) = P(slip)
* and P(incorrect | ¬known) = 1 - P(guess)
*/
export function bktUpdate(
priorPKnown: number,
isCorrect: boolean,
params: BktParams,
): number {
const { pSlip, pGuess } = params;
if (isCorrect) {
const pCorrect = priorPKnown * (1 - pSlip) + (1 - priorPKnown) * pGuess;
const pKnownGivenCorrect = (priorPKnown * (1 - pSlip)) / pCorrect;
return pKnownGivenCorrect;
} else {
const pIncorrect = priorPKnown * pSlip + (1 - priorPKnown) * (1 - pGuess);
const pKnownGivenIncorrect = (priorPKnown * pSlip) / pIncorrect;
return pKnownGivenIncorrect;
}
}
/**
* Apply learning transition after observation.
* P(known after learning) = P(known) + P(¬known) × P(learn)
*/
export function applyLearning(pKnown: number, pLearn: number): number {
return pKnown + (1 - pKnown) * pLearn;
}
```
### 2.2 Conjunctive BKT for Multi-Skill Problems
```typescript
// src/lib/curriculum/bkt/conjunctive-bkt.ts
export interface SkillBktRecord {
skillId: string;
pKnown: number;
params: BktParams;
}
export interface BlameDistribution {
skillId: string;
blameWeight: number; // Higher = more likely this skill caused the error
updatedPKnown: number;
}
/**
* For a CORRECT multi-skill answer:
* All skills receive positive evidence (student knew all of them).
* Update each skill independently with the correct observation.
*/
export function updateOnCorrect(
skills: SkillBktRecord[],
): { skillId: string; updatedPKnown: number }[] {
return skills.map((skill) => ({
skillId: skill.skillId,
updatedPKnown: applyLearning(
bktUpdate(skill.pKnown, true, skill.params),
skill.params.pLearn,
),
}));
}
/**
* For an INCORRECT multi-skill answer:
* Distribute blame probabilistically based on which skill most likely failed.
*
* Simplified approximation:
* blame(X) ∝ (1 - pKnown(X)) / Σ(1 - pKnown(all))
*/
export function updateOnIncorrect(
skills: SkillBktRecord[],
): BlameDistribution[] {
const totalUnknown = skills.reduce((sum, s) => sum + (1 - s.pKnown), 0);
if (totalUnknown < 0.001) {
// All skills appear mastered - must be a slip, distribute evenly
const evenWeight = 1 / skills.length;
return skills.map((skill) => ({
skillId: skill.skillId,
blameWeight: evenWeight,
updatedPKnown: bktUpdate(skill.pKnown, false, skill.params),
}));
}
return skills.map((skill) => {
const blameWeight = (1 - skill.pKnown) / totalUnknown;
// Weighted update: soften negative evidence for skills unlikely to have caused error
const fullNegativeUpdate = bktUpdate(skill.pKnown, false, skill.params);
const weightedPKnown =
skill.pKnown * (1 - blameWeight) + fullNegativeUpdate * blameWeight;
return {
skillId: skill.skillId,
blameWeight,
updatedPKnown: weightedPKnown,
};
});
}
```
### 2.3 Evidence Quality Modifiers
```typescript
// src/lib/curriculum/bkt/evidence-quality.ts
/**
* Adjust observation weight based on help level.
* More help = less confident the student really knows it.
*/
export function helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number {
switch (helpLevel) {
case 0:
return 1.0; // No help - full evidence
case 1:
return 0.8; // Minor hint - slight reduction
case 2:
return 0.5; // Significant help - halve evidence
case 3:
return 0.5; // Full help - halve evidence
}
}
/**
* Adjust observation weight based on response time.
*
* - Fast correct → strong evidence of mastery
* - Slow correct → might have struggled
* - Fast incorrect → careless slip (less negative)
* - Slow incorrect → genuine confusion (stronger negative)
*/
export function responseTimeWeight(
responseTimeMs: number,
isCorrect: boolean,
expectedTimeMs: number = 5000,
): number {
const ratio = responseTimeMs / expectedTimeMs;
if (isCorrect) {
if (ratio < 0.5) return 1.2; // Very fast - strong mastery
if (ratio > 2.0) return 0.8; // Very slow - struggled
return 1.0;
} else {
if (ratio < 0.3) return 0.5; // Very fast error - careless slip
if (ratio > 2.0) return 1.2; // Very slow error - genuine confusion
return 1.0;
}
}
```
### 2.4 Domain-Informed Priors
```typescript
// src/lib/curriculum/bkt/skill-priors.ts
export function getDefaultParams(skillId: string): BktParams {
// Basic skills are easier to learn
if (skillId.startsWith("basic.")) {
return { pInit: 0.3, pLearn: 0.4, pSlip: 0.05, pGuess: 0.02 };
}
// Five complements are moderately difficult
if (skillId.startsWith("fiveComplements")) {
return { pInit: 0.1, pLearn: 0.3, pSlip: 0.1, pGuess: 0.02 };
}
// Ten complements are harder
if (skillId.startsWith("tenComplements")) {
return { pInit: 0.05, pLearn: 0.25, pSlip: 0.15, pGuess: 0.02 };
}
// Mixed complements are hardest
if (skillId.startsWith("mixedComplements")) {
return { pInit: 0.02, pLearn: 0.2, pSlip: 0.2, pGuess: 0.02 };
}
// Default
return { pInit: 0.1, pLearn: 0.3, pSlip: 0.1, pGuess: 0.05 };
}
```
### 2.5 Confidence Calculation
```typescript
// src/lib/curriculum/bkt/confidence.ts
/**
* Calculate confidence in pKnown estimate.
* Based on number of opportunities and consistency of observations.
* Returns value in [0, 1] where 1 = highly confident.
*/
export function calculateConfidence(
opportunities: number,
successRate: number,
): number {
// More data = more confidence (asymptotic to 1)
const dataConfidence = 1 - Math.exp(-opportunities / 20);
// Extreme success rates (very high or very low) = more confidence
const extremity = Math.abs(successRate - 0.5) * 2; // 0 at 50%, 1 at 0% or 100%
const consistencyBonus = extremity * 0.2;
return Math.min(1, dataConfidence + consistencyBonus);
}
/**
* Get confidence label for display.
*/
export function getConfidenceLabel(confidence: number): string {
if (confidence > 0.7) return "confident";
if (confidence > 0.4) return "moderate";
return "uncertain";
}
/**
* Calculate uncertainty range around pKnown estimate.
* Wider range when confidence is low.
*/
export function getUncertaintyRange(
pKnown: number,
confidence: number,
): { low: number; high: number } {
const uncertainty = (1 - confidence) * 0.3; // Max ±30% when confidence = 0
return {
low: Math.max(0, pKnown - uncertainty),
high: Math.min(1, pKnown + uncertainty),
};
}
```
---
## 3. Main BKT Computation Function
```typescript
// src/lib/curriculum/bkt/compute-bkt.ts
import type { ProblemResultWithContext } from "../session-planner";
import { getDefaultParams, type BktParams } from "./skill-priors";
import { updateOnCorrect, updateOnIncorrect } from "./conjunctive-bkt";
import { helpLevelWeight, responseTimeWeight } from "./evidence-quality";
import { calculateConfidence, getUncertaintyRange } from "./confidence";
export interface BktComputeOptions {
/** Confidence threshold for mastery classification */
confidenceThreshold: number;
/** Use cross-student priors (aggregated from other students) */
useCrossStudentPriors: boolean;
}
export interface SkillBktResult {
skillId: string;
pKnown: number;
confidence: number;
uncertaintyRange: { low: number; high: number };
opportunities: number;
successCount: number;
lastPracticedAt: Date | null;
masteryClassification: "mastered" | "learning" | "struggling";
}
export interface BktComputeResult {
skills: SkillBktResult[];
interventionNeeded: SkillBktResult[];
strengths: SkillBktResult[];
}
/**
* Compute BKT state for all skills from problem history.
* This is the main entry point - call it when displaying the Skills Dashboard.
*/
export function computeBktFromHistory(
results: ProblemResultWithContext[],
options: BktComputeOptions = {
confidenceThreshold: 0.5,
useCrossStudentPriors: false,
},
): BktComputeResult {
// Sort by timestamp to replay in order
const sorted = [...results].sort((a, b) => a.timestamp - b.timestamp);
// Track state for each skill
const skillStates = new Map<
string,
{
pKnown: number;
opportunities: number;
successCount: number;
lastPracticedAt: Date | null;
params: BktParams;
}
>();
// Initialize and update for each problem
for (const result of sorted) {
const skillIds = result.problem.skillIds ?? [];
if (skillIds.length === 0) continue;
// Ensure all skills have state
for (const skillId of skillIds) {
if (!skillStates.has(skillId)) {
const params = getDefaultParams(skillId);
skillStates.set(skillId, {
pKnown: params.pInit,
opportunities: 0,
successCount: 0,
lastPracticedAt: null,
params,
});
}
}
// Build skill records for BKT update
const skillRecords = skillIds.map((skillId) => {
const state = skillStates.get(skillId)!;
return {
skillId,
pKnown: state.pKnown,
params: state.params,
};
});
// Calculate evidence weight
const helpWeight = helpLevelWeight(result.helpLevel);
const rtWeight = responseTimeWeight(
result.responseTimeMs,
result.isCorrect,
);
const evidenceWeight = helpWeight * rtWeight;
// Compute updates
const updates = result.isCorrect
? updateOnCorrect(skillRecords)
: updateOnIncorrect(skillRecords);
// Apply updates with evidence weighting
for (const update of updates) {
const state = skillStates.get(update.skillId)!;
// Weighted blend between old and new pKnown based on evidence quality
const newPKnown =
state.pKnown * (1 - evidenceWeight) +
update.updatedPKnown * evidenceWeight;
state.pKnown = newPKnown;
state.opportunities += 1;
if (result.isCorrect) state.successCount += 1;
state.lastPracticedAt = new Date(result.timestamp);
}
}
// Convert to results
const skills: SkillBktResult[] = [];
for (const [skillId, state] of skillStates) {
const successRate =
state.opportunities > 0 ? state.successCount / state.opportunities : 0.5;
const confidence = calculateConfidence(state.opportunities, successRate);
const uncertaintyRange = getUncertaintyRange(state.pKnown, confidence);
// Classify mastery
let masteryClassification: "mastered" | "learning" | "struggling";
if (state.pKnown >= 0.8 && confidence >= options.confidenceThreshold) {
masteryClassification = "mastered";
} else if (
state.pKnown < 0.5 &&
confidence >= options.confidenceThreshold
) {
masteryClassification = "struggling";
} else {
masteryClassification = "learning";
}
skills.push({
skillId,
pKnown: state.pKnown,
confidence,
uncertaintyRange,
opportunities: state.opportunities,
successCount: state.successCount,
lastPracticedAt: state.lastPracticedAt,
masteryClassification,
});
}
// Sort by pKnown ascending (struggling skills first)
skills.sort((a, b) => a.pKnown - b.pKnown);
// Identify intervention needed (low pKnown with high confidence)
const interventionNeeded = skills.filter(
(s) => s.masteryClassification === "struggling",
);
// Identify strengths (high pKnown with high confidence)
const strengths = skills.filter(
(s) => s.masteryClassification === "mastered",
);
return { skills, interventionNeeded, strengths };
}
```
---
## 4. UI Display Updates
### 4.1 Honest Language Guidelines
**DON'T say:**
- "85% accuracy" (misleading - implies binary success tracking)
- "Mastery: 85%" (implies certainty we don't have)
- "You know this skill" (we can't know for sure)
**DO say:**
- "~73% mastered (moderate confidence)"
- "Estimated: 73% ± 15%"
- "Appears mastered (based on 12 problems)"
- "Needs attention (5 recent errors)"
### 4.2 Skill Card Display
```typescript
interface SkillDisplayData {
skillId: string;
displayName: string;
// BKT metrics
pKnown: number; // 0-1, the main estimate
confidence: number; // 0-1, how certain we are
uncertaintyRange: { low: number; high: number };
// Raw evidence
opportunities: number; // Total problems
successCount: number;
errorCount: number; // opportunities - successCount
// Staleness
lastPracticedAt: Date | null;
daysSinceLastPractice: number | null;
}
// Display:
// "~73% mastered (moderate confidence)"
// "Based on 15 problems (12 correct, 3 with errors)"
// "Last practiced 3 days ago"
```
### 4.3 Staleness Indicator
Show staleness separately from P(known) - don't apply decay to the estimate.
```typescript
function getStalenessWarning(
daysSinceLastPractice: number | null,
): string | null {
if (daysSinceLastPractice === null) return null;
if (daysSinceLastPractice < 7) return null;
if (daysSinceLastPractice < 14) return "Not practiced recently";
if (daysSinceLastPractice < 30) return "Getting rusty";
return "Very stale - may need review";
}
```
### 4.4 UI Controls
**Confidence Threshold Slider:**
- Default: 0.5
- Range: 0.3 to 0.8
- Affects mastery classification: higher threshold = stricter "mastered" label
**Cross-Student Priors Toggle (future):**
- Default: off (use domain-informed priors only)
- When on: adjust priors based on aggregate student data
---
## 5. Implementation Plan
### Phase 1: Core BKT Functions (No DB Changes)
1. Create `src/lib/curriculum/bkt/` directory
2. Implement pure functions: bkt-core.ts, conjunctive-bkt.ts, evidence-quality.ts, skill-priors.ts, confidence.ts
3. Implement main entry point: compute-bkt.ts
4. Write unit tests for BKT math
### Phase 2: Skills Dashboard Update
1. Update `SkillsClient.tsx` to call `computeBktFromHistory()`
2. Replace naive accuracy display with P(known) + confidence
3. Use honest language in all labels
4. Add staleness indicators
### Phase 3: UI Controls
1. Add confidence threshold slider to Skills Dashboard
2. Store preference in localStorage
3. (Future) Add cross-student priors toggle
---
## 6. Open Questions (Deferred)
1. **Cross-student priors**: How do we aggregate data across students to inform priors?
- Answer: Deferred. Start with domain-informed priors only.
2. **Decay vs Staleness**: Should we eventually add decay?
- Answer: Show staleness indicator for now. Can add optional decay toggle later.
3. **Parameter estimation**: Should P(T), P(S), P(G) be learned from data?
- Answer: Start with domain-informed values. Can tune later with A/B testing.
---
## 7. BKT-Driven Problem Generation
**Implemented in December 2024**
### 7.1 Problem Generation Modes
Students can choose between two modes in the "Ready to Practice" modal:
**Adaptive Mode (Default):**
- Uses BKT P(known) estimates for continuous complexity scaling
- Formula: `multiplier = 4 - (pKnown × 3)`
- Requires confidence ≥ 0.5 (~20 problems with skill)
- Falls back to Classic mode if insufficient data
**Classic Mode:**
- Uses fluency-based discrete multipliers
- `effortless (1×), fluent (2×), rusty (3×), practicing (3×), not_practicing (4×)`
- Fluency requires: ≥5 consecutive correct, ≥10 attempts, ≥85% accuracy
### 7.2 Implementation Files
| File | Purpose |
| --------------------------- | ---------------------------------------- |
| `config/bkt-integration.ts` | BKT config and multiplier calculation |
| `utils/skillComplexity.ts` | Cost calculator with BKT support |
| `session-planner.ts` | Session planning with BKT loading |
| `StartPracticeModal.tsx` | Mode selection UI |
| `SkillsClient.tsx` | Skills dashboard with multiplier display |
### 7.3 User Preference Storage
```sql
-- player_curriculum table
problem_generation_mode TEXT DEFAULT 'adaptive' NOT NULL
-- Values: 'adaptive' | 'classic'
```
### 7.4 Skills Dashboard Consistency
The Skills Dashboard now shows:
1. **P(known) estimate** - Same BKT estimate used for problem generation
2. **Complexity multiplier** - Actual multiplier that will be used (e.g., "1.75×")
3. **Mode indicator** - Whether BKT or fluency is being used for this skill
This ensures complete transparency about what drives problem generation.
---
## References
- Corbett, A. T., & Anderson, J. R. (1994). Knowledge tracing: Modeling the acquisition of procedural knowledge.
- Pardos, Z. A., & Heffernan, N. T. (2011). KT-IDEM: Introducing item difficulty to the knowledge tracing model.

View File

@@ -0,0 +1,213 @@
# BKT-Driven Problem Generation Plan
## Overview
**Goal:** Use BKT P(known) estimates to drive problem complexity budgeting, replacing the discrete fluency-based system. Add preference toggle and ensure transparency across the system.
**Status:** Implementation in progress
---
## Current State vs Target State
| Aspect | Current (Fluency) | Target (BKT) |
| --------------------- | ------------------------------- | ------------------------------------- |
| **Output** | 5 discrete states | Continuous P(known) [0,1] |
| **Multi-skill blame** | All skills get +1 attempt | Probabilistic: `blame ∝ (1 - pKnown)` |
| **Help level** | Heavy help breaks streak | Weighted evidence: 1.0×, 0.8×, 0.5× |
| **Response time** | Recorded but IGNORED | Weighted evidence: 0.5× to 1.2× |
| **Confidence** | None | Built-in confidence measure |
| **Progress** | Binary threshold (cliff effect) | Continuous smooth updates |
---
## Architecture
### Core Flow
```
generateSessionPlan()
├─ Load problem history → getRecentSessionResults(playerId, 50)
├─ Compute BKT → computeBktFromHistory(problemHistory)
│ Returns: Map<skillId, {pKnown, confidence}>
└─ createSkillCostCalculator(fluencyHistory, { bktResults, useBktScaling })
├─ IF useBktScaling AND bkt[skillId].confidence ≥ 0.5:
│ multiplier = 4 - (pKnown × 3) // Continuous [1, 4]
└─ ELSE: fluency fallback (discrete [1, 4])
```
### Multiplier Mapping
**BKT Continuous:**
- `pKnown = 0.0` → multiplier 4.0 (struggling)
- `pKnown = 0.5` → multiplier 2.5 (learning)
- `pKnown = 1.0` → multiplier 1.0 (mastered)
**Fluency Discrete (fallback):**
- `effortless` → 1
- `fluent` → 2
- `rusty` → 3
- `practicing` → 3
- `not_practicing` → 4
---
## Implementation Phases
### Phase 1: Core Backend Integration
**Files to modify:**
1. `src/utils/skillComplexity.ts`
- Add `SkillCostCalculatorOptions` interface
- Add `bktResults` and `useBktScaling` parameters
- Implement continuous multiplier calculation
2. `src/lib/curriculum/session-planner.ts`
- Add `getRecentSessionResults()` call
- Compute BKT during session planning
- Pass BKT results to cost calculator
3. `src/lib/curriculum/bkt/index.ts`
- Export necessary types and functions
### Phase 2: Preference Setting
**Files to create/modify:**
1. `src/db/schema/player-curriculum.ts`
- Add `problemGenerationMode` field
2. `drizzle/XXXX_add_problem_generation_mode.sql`
- Migration to add column
3. `src/lib/curriculum/progress-manager.ts`
- Add getter/setter for preference
4. `src/components/practice/StartSessionModal.tsx` (or equivalent)
- Add toggle in expanded settings
### Phase 3: Skills Dashboard Consistency
**Files to modify:**
1. `src/app/practice/[studentId]/skills/SkillsClient.tsx`
- Show complexity multiplier derived from P(known)
- Add evidence breakdown
- Show "what this means for problem generation"
2. `src/app/api/curriculum/[playerId]/bkt/route.ts`
- Ensure same BKT computation as session planner
### Phase 4: Transparency & Education
**Files to create:**
1. `src/components/practice/BktExplainer.tsx`
- "Learn more" modal content
2. `src/components/practice/SessionSummary.tsx` (enhance)
- Show BKT changes after session
---
## Configuration
### New Config Constants
Location: `src/lib/curriculum/config/bkt-integration.ts`
```typescript
export const BKT_INTEGRATION_CONFIG = {
/** Confidence threshold for trusting BKT over fluency */
confidenceThreshold: 0.5,
/** Minimum multiplier (when pKnown = 1.0) */
minMultiplier: 1.0,
/** Maximum multiplier (when pKnown = 0.0) */
maxMultiplier: 4.0,
/** Number of recent sessions to load for BKT computation */
sessionHistoryDepth: 50,
};
```
---
## UI Design
### Ready to Practice Modal - Advanced Settings
```
┌─────────────────────────────────────────────────────────────┐
│ ▼ Advanced Settings │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Problem Selection ││
│ │ ││
│ │ ○ Adaptive (recommended) ││
│ │ Uses Bayesian inference to estimate pattern mastery. ││
│ │ Problems adjust smoothly based on your performance. ││
│ │ ││
│ │ ○ Classic ││
│ │ Uses streak-based fluency thresholds. ││
│ │ Problems change when you hit mastery milestones. ││
│ │ ││
│ │ [?] Learn more about how problem selection works ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
### Enhanced Skill Card
```
┌─────────────────────────────────────────────────────────────┐
│ Pattern: Ten Complements +6 │
│ │
│ Mastery: ████████░░ 78% Confidence: High (0.72) │
│ │
│ Problem Generation Impact: │
│ • Complexity multiplier: 1.66× (lower = easier problems) │
│ • This pattern appears in review and mixed practice │
│ │
│ Evidence: │
│ • 47 problems • 89% accuracy • Avg 4.2s • 4 hints used │
└─────────────────────────────────────────────────────────────┘
```
---
## Testing Strategy
1. **Unit tests:** `createSkillCostCalculator` with/without BKT
2. **Integration tests:** Session planning produces valid plans in both modes
3. **Consistency tests:** Same BKT input → same output in dashboard and generation
4. **Manual testing:** Toggle preference, verify behavior changes
---
## Risks & Mitigations
| Risk | Mitigation |
| ----------------------------- | ---------------------------------- |
| Performance (loading history) | Load in parallel; consider caching |
| Cold start (no data) | Automatic fluency fallback |
| User confusion | Clear explanations, "Learn more" |
| Dashboard/generation mismatch | Single BKT computation source |
---
## Documentation Updates
After implementation, update:
- `docs/DAILY_PRACTICE_SYSTEM.md` - Add BKT integration section
- `.claude/CLAUDE.md` - Add BKT integration notes
- Blog post - Update to reflect actual integration

View File

@@ -0,0 +1,548 @@
# Celebration Wind-Down: The Proper Way
## Concept
Every single CSS property morphs individually from celebration state to normal state over ~60 seconds. No cheating with cross-fades. Pure interpolation madness.
## SIMPLIFICATION: Same Text Throughout
To make the transition truly seamless, the text content stays the same from start to finish:
- **Title**: "New Skill Unlocked: +5 3" (same throughout)
- **Subtitle**: "Ready to start the tutorial" (same throughout)
- **Button**: "Begin Tutorial →" (same throughout)
Only the *styling* of the text changes (size, color, shadow) - not the content.
This eliminates 6 properties that were doing text cross-fades.
## Properties to Interpolate
### Container
| Property | Celebration | Normal | Interpolation |
|----------|-------------|--------|---------------|
| background | `linear-gradient(135deg, rgba(234,179,8,0.25), rgba(251,191,36,0.15), rgba(234,179,8,0.25))` | `linear-gradient(135deg, rgba(59,130,246,0.15), rgba(99,102,241,0.1))` | RGB channels per stop |
| border-width | `3px` | `1px` | numeric |
| border-color | `yellow.500` (#eab308) | `blue.500` (#3b82f6) | RGB |
| border-radius | `16px` | `12px` | numeric |
| padding | `1.5rem` (24px) | `0.75rem` (12px) | numeric |
| box-shadow | `0 0 20px rgba(234,179,8,0.4), 0 0 40px rgba(234,179,8,0.2)` | `0 2px 8px rgba(0,0,0,0.1)` | multiple shadows, each with color+blur+spread |
| text-align | `center` | `left` | discrete flip at 50%? Or use justify-content |
| flex-direction | `column` | `row` | discrete flip |
### Emoji/Icon
| Property | Celebration | Normal |
|----------|-------------|--------|
| font-size | `4rem` (64px) | `1.5rem` (24px) | numeric |
| opacity (🏆) | `1` | `0` | numeric |
| opacity (🎓) | `0` | `1` | numeric |
| transform | `rotate(-3deg)` to `rotate(3deg)` wiggle | `rotate(0)` | numeric (animation amplitude → 0) |
| margin-bottom | `0.5rem` | `0` | numeric |
### Title Text
| Property | Celebration | Normal |
|----------|-------------|--------|
| font-size | `1.75rem` (28px) | `1rem` (16px) | numeric |
| font-weight | `bold` (700) | `600` | numeric |
| color | `yellow.200` (#fef08a) | `blue.700` (#1d4ed8) | RGB |
| text-shadow | `0 0 20px rgba(234,179,8,0.5)` | `none` (0 0 0 transparent) | color+blur |
| margin-bottom | `0.5rem` | `0.25rem` | numeric |
| opacity ("New Skill Unlocked!") | `1` | `0` | numeric |
| opacity ("Ready to Learn") | `0` | `1` | numeric |
### Subtitle Text
| Property | Celebration | Normal |
|----------|-------------|--------|
| font-size | `1.25rem` (20px) | `0.875rem` (14px) | numeric |
| color | `gray.200` | `gray.600` | RGB |
| margin-bottom | `1rem` | `0` | numeric |
| opacity (celebration text) | `1` | `0` | numeric |
| opacity (normal text) | `0` | `1` | numeric |
### CTA Button
| Property | Celebration | Normal |
|----------|-------------|--------|
| padding-x | `2rem` (32px) | `1rem` (16px) | numeric |
| padding-y | `0.75rem` (12px) | `0.5rem` (8px) | numeric |
| font-size | `1.125rem` (18px) | `0.875rem` (14px) | numeric |
| background | `linear-gradient(135deg, #FCD34D, #F59E0B)` | `#3b82f6` | RGB gradient → solid |
| border-radius | `12px` | `8px` | numeric |
| box-shadow | `0 4px 15px rgba(245,158,11,0.4)` | `0 2px 4px rgba(0,0,0,0.1)` | color+offset+blur |
| color | `gray.900` (#111827) | `white` (#ffffff) | RGB |
| transform (hover) | `scale(1.05)` | `scale(1.02)` | numeric |
### Shimmer Overlay
| Property | Celebration | Normal |
|----------|-------------|--------|
| opacity | `1` | `0` | numeric |
| animation-duration | `2s` | `10s` (slow to imperceptible stop) | numeric |
### Glow Animation
| Property | Celebration | Normal |
|----------|-------------|--------|
| box-shadow intensity | `1` | `0` | multiplier on shadow alpha |
| animation amplitude | full | `0` | numeric |
## Interpolation Utilities
```typescript
// Basic linear interpolation
function lerp(start: number, end: number, t: number): number {
return start + (end - start) * t
}
// Color interpolation (RGB)
function lerpColor(startHex: string, endHex: string, t: number): string {
const start = hexToRgb(startHex)
const end = hexToRgb(endHex)
return `rgb(${lerp(start.r, end.r, t)}, ${lerp(start.g, end.g, t)}, ${lerp(start.b, end.b, t)})`
}
// RGBA interpolation
function lerpRgba(start: RGBA, end: RGBA, t: number): string {
return `rgba(${lerp(start.r, end.r, t)}, ${lerp(start.g, end.g, t)}, ${lerp(start.b, end.b, t)}, ${lerp(start.a, end.a, t)})`
}
// Gradient interpolation (same number of stops)
function lerpGradient(startStops: GradientStop[], endStops: GradientStop[], t: number): string {
const interpolatedStops = startStops.map((start, i) => {
const end = endStops[i]
return {
color: lerpRgba(start.color, end.color, t),
position: lerp(start.position, end.position, t)
}
})
return `linear-gradient(135deg, ${interpolatedStops.map(s => `${s.color} ${s.position}%`).join(', ')})`
}
// Box shadow interpolation
function lerpBoxShadow(start: BoxShadow[], end: BoxShadow[], t: number): string {
// Pad shorter array with transparent shadows
const maxLen = Math.max(start.length, end.length)
const paddedStart = padShadows(start, maxLen)
const paddedEnd = padShadows(end, maxLen)
return paddedStart.map((s, i) => {
const e = paddedEnd[i]
return `${lerp(s.x, e.x, t)}px ${lerp(s.y, e.y, t)}px ${lerp(s.blur, e.blur, t)}px ${lerp(s.spread, e.spread, t)}px ${lerpRgba(s.color, e.color, t)}`
}).join(', ')
}
```
## Timing Function
Ultra-slow ease-out that feels imperceptible:
```typescript
function windDownProgress(elapsedMs: number): number {
const BURST_DURATION = 5_000 // 5s full celebration
const WIND_DOWN_DURATION = 55_000 // 55s transition
if (elapsedMs < BURST_DURATION) return 0
const windDownElapsed = elapsedMs - BURST_DURATION
if (windDownElapsed >= WIND_DOWN_DURATION) return 1
const t = windDownElapsed / WIND_DOWN_DURATION
// Attempt: Start EXTREMELY slow, accelerate near end
// Using quartic ease-out: 1 - (1-t)^4
// But even slower: quintic ease-out: 1 - (1-t)^5
return 1 - Math.pow(1 - t, 5)
}
```
Progress over time with quintic ease-out:
- 10s: 0.03% transitioned (imperceptible)
- 20s: 0.8% transitioned (still imperceptible)
- 30s: 4% transitioned (barely noticeable if you squint)
- 40s: 13% transitioned (hmm, something's different?)
- 50s: 33% transitioned (ok it's changing)
- 55s: 52% transitioned
- 58s: 75% transitioned
- 60s: 100% done
## Animation Amplitude Wind-Down
For the wiggle animation on the trophy:
```typescript
// Current wiggle: rotate between -3deg and +3deg
// Wind down: amplitude goes from 3 → 0
function getWiggleAmplitude(t: number): number {
// Inverse of progress - starts at 3, ends at 0
return 3 * (1 - t)
}
// In CSS/style:
const wiggleAmplitude = getWiggleAmplitude(progress)
// Use CSS custom property or inline keyframes
style={{
animation: wiggleAmplitude > 0.1
? `wiggle-${Math.round(wiggleAmplitude * 10)} 0.5s ease-in-out infinite`
: 'none'
}}
```
Actually, for smooth wiggle wind-down, we should use a spring-based approach or just interpolate the transform directly:
```typescript
// Wiggle is a sine wave with decreasing amplitude
const time = Date.now() / 500 // oscillation period
const amplitude = 3 * (1 - progress)
const rotation = Math.sin(time) * amplitude
// transform: `rotate(${rotation}deg)`
```
## Component Structure
```typescript
interface CelebrationStyles {
// Container
containerBackground: string
containerBorderWidth: number
containerBorderColor: string
containerBorderRadius: number
containerPadding: number
containerBoxShadow: string
containerFlexDirection: 'column' | 'row'
containerAlignItems: 'center' | 'flex-start'
containerTextAlign: 'center' | 'left'
// Emoji
trophyOpacity: number
graduationCapOpacity: number
emojiSize: number
emojiRotation: number
emojiMarginBottom: number
// Title
titleFontSize: number
titleColor: string
titleTextShadow: string
titleMarginBottom: number
celebrationTitleOpacity: number
normalTitleOpacity: number
// Subtitle
subtitleFontSize: number
subtitleColor: string
subtitleMarginBottom: number
celebrationSubtitleOpacity: number
normalSubtitleOpacity: number
// Button
buttonPaddingX: number
buttonPaddingY: number
buttonFontSize: number
buttonBackground: string
buttonBorderRadius: number
buttonBoxShadow: string
buttonColor: string
// Shimmer
shimmerOpacity: number
// Glow
glowIntensity: number
}
function calculateStyles(progress: number, isDark: boolean): CelebrationStyles {
const t = progress // 0 = celebration, 1 = normal
return {
// Container
containerBackground: lerpGradient(
isDark ? DARK_CELEBRATION_BG : LIGHT_CELEBRATION_BG,
isDark ? DARK_NORMAL_BG : LIGHT_NORMAL_BG,
t
),
containerBorderWidth: lerp(3, 1, t),
containerBorderColor: lerpColor('#eab308', '#3b82f6', t),
containerBorderRadius: lerp(16, 12, t),
containerPadding: lerp(24, 12, t),
containerBoxShadow: lerpBoxShadow(CELEBRATION_SHADOWS, NORMAL_SHADOWS, t),
containerFlexDirection: t < 0.5 ? 'column' : 'row',
containerAlignItems: t < 0.5 ? 'center' : 'flex-start',
containerTextAlign: t < 0.5 ? 'center' : 'left',
// Emoji - cross-fade between trophy and graduation cap
trophyOpacity: 1 - t,
graduationCapOpacity: t,
emojiSize: lerp(64, 24, t),
emojiRotation: Math.sin(Date.now() / 500) * 3 * (1 - t),
emojiMarginBottom: lerp(8, 0, t),
// Title
titleFontSize: lerp(28, 16, t),
titleColor: lerpColor(isDark ? '#fef08a' : '#a16207', isDark ? '#93c5fd' : '#1d4ed8', t),
titleTextShadow: `0 0 ${lerp(20, 0, t)}px rgba(234,179,8,${lerp(0.5, 0, t)})`,
titleMarginBottom: lerp(8, 4, t),
celebrationTitleOpacity: 1 - t,
normalTitleOpacity: t,
// Subtitle
subtitleFontSize: lerp(20, 14, t),
subtitleColor: lerpColor(isDark ? '#e5e7eb' : '#374151', isDark ? '#9ca3af' : '#4b5563', t),
subtitleMarginBottom: lerp(16, 0, t),
celebrationSubtitleOpacity: 1 - t,
normalSubtitleOpacity: t,
// Button
buttonPaddingX: lerp(32, 16, t),
buttonPaddingY: lerp(12, 8, t),
buttonFontSize: lerp(18, 14, t),
buttonBackground: lerpGradient(CELEBRATION_BUTTON_BG, NORMAL_BUTTON_BG, t),
buttonBorderRadius: lerp(12, 8, t),
buttonBoxShadow: lerpBoxShadow(CELEBRATION_BUTTON_SHADOW, NORMAL_BUTTON_SHADOW, t),
buttonColor: lerpColor('#111827', '#ffffff', t),
// Effects
shimmerOpacity: 1 - t,
glowIntensity: 1 - t,
}
}
```
## Render Logic
```tsx
function CelebrationProgressionBanner({ sessionMode, onAction, variant, isDark }: Props) {
const skillId = sessionMode.nextSkill.skillId
const { progress, shouldFireConfetti, oscillation } = useCelebrationWindDown(skillId)
// Fire confetti once
useEffect(() => {
if (shouldFireConfetti) {
fireConfettiCelebration()
}
}, [shouldFireConfetti])
// Calculate all interpolated styles
const styles = calculateStyles(progress, isDark)
// For layout transition (column → row), we need to handle this carefully
// Use flexbox with animated flex-direction doesn't work well
// Instead: use a wrapper that morphs via width/height constraints
return (
<div
data-element="session-mode-banner"
data-celebration-progress={progress}
style={{
position: 'relative',
background: styles.containerBackground,
borderWidth: `${styles.containerBorderWidth}px`,
borderStyle: 'solid',
borderColor: styles.containerBorderColor,
borderRadius: `${styles.containerBorderRadius}px`,
padding: `${styles.containerPadding}px`,
boxShadow: styles.containerBoxShadow,
display: 'flex',
flexDirection: styles.containerFlexDirection,
alignItems: styles.containerAlignItems,
textAlign: styles.containerTextAlign,
overflow: 'hidden',
}}
>
{/* Shimmer overlay - fades out */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 2s linear infinite',
opacity: styles.shimmerOpacity,
pointerEvents: 'none',
}}
/>
{/* Emoji container - both emojis positioned, cross-fading */}
<div style={{
position: 'relative',
fontSize: `${styles.emojiSize}px`,
marginBottom: `${styles.emojiMarginBottom}px`,
marginRight: styles.containerFlexDirection === 'row' ? '12px' : 0,
}}>
{/* Trophy - fades out, wiggles */}
<span style={{
opacity: styles.trophyOpacity,
transform: `rotate(${styles.emojiRotation}deg)`,
position: styles.trophyOpacity < 0.5 ? 'absolute' : 'relative',
}}>
🏆
</span>
{/* Graduation cap - fades in */}
<span style={{
opacity: styles.graduationCapOpacity,
position: styles.graduationCapOpacity < 0.5 ? 'absolute' : 'relative',
}}>
🎓
</span>
</div>
{/* Text content area */}
<div style={{ flex: 1 }}>
{/* Title - both versions, cross-fading */}
<div style={{
position: 'relative',
fontSize: `${styles.titleFontSize}px`,
fontWeight: 'bold',
color: styles.titleColor,
textShadow: styles.titleTextShadow,
marginBottom: `${styles.titleMarginBottom}px`,
}}>
<span style={{ opacity: styles.celebrationTitleOpacity }}>
New Skill Unlocked!
</span>
<span style={{
opacity: styles.normalTitleOpacity,
position: 'absolute',
left: 0,
top: 0,
}}>
Ready to Learn New Skill
</span>
</div>
{/* Subtitle - both versions, cross-fading */}
<div style={{
position: 'relative',
fontSize: `${styles.subtitleFontSize}px`,
color: styles.subtitleColor,
marginBottom: `${styles.subtitleMarginBottom}px`,
}}>
<span style={{ opacity: styles.celebrationSubtitleOpacity }}>
You're ready to learn <strong>{sessionMode.nextSkill.displayName}</strong>
</span>
<span style={{
opacity: styles.normalSubtitleOpacity,
position: 'absolute',
left: 0,
top: 0,
}}>
{sessionMode.nextSkill.displayName} — Start the tutorial to begin
</span>
</div>
</div>
{/* Button */}
<button
onClick={onAction}
style={{
padding: `${styles.buttonPaddingY}px ${styles.buttonPaddingX}px`,
fontSize: `${styles.buttonFontSize}px`,
fontWeight: 'bold',
background: styles.buttonBackground,
color: styles.buttonColor,
borderRadius: `${styles.buttonBorderRadius}px`,
border: 'none',
boxShadow: styles.buttonBoxShadow,
cursor: 'pointer',
}}
>
{/* Button text also cross-fades */}
<span style={{ opacity: styles.celebrationTitleOpacity }}>Start Learning!</span>
<span style={{ opacity: styles.normalTitleOpacity, position: 'absolute' }}>
Start Tutorial
</span>
</button>
</div>
)
}
```
## Animation Frame Loop
The wind-down needs to run on requestAnimationFrame for smooth updates:
```typescript
function useCelebrationWindDown(skillId: string) {
const [progress, setProgress] = useState(0)
const [shouldFireConfetti, setShouldFireConfetti] = useState(false)
const [oscillation, setOscillation] = useState(0)
useEffect(() => {
const state = getCelebrationState(skillId)
if (!state) {
// First time seeing this skill unlock
setCelebrationState(skillId, { startedAt: Date.now(), confettiFired: false })
setShouldFireConfetti(true)
}
let rafId: number
const animate = () => {
const state = getCelebrationState(skillId)
if (!state) return
const elapsed = Date.now() - state.startedAt
const newProgress = windDownProgress(elapsed)
setProgress(newProgress)
setOscillation(Math.sin(Date.now() / 500)) // For wiggle
if (newProgress < 1) {
rafId = requestAnimationFrame(animate)
}
}
rafId = requestAnimationFrame(animate)
return () => cancelAnimationFrame(rafId)
}, [skillId])
return { progress, shouldFireConfetti, oscillation }
}
```
## Implementation Order
1. **Create interpolation utilities** (`src/utils/interpolate.ts`)
- `lerp(start, end, t)`
- `hexToRgb(hex)`, `rgbToHex(r, g, b)`
- `lerpColor(startHex, endHex, t)`
- `lerpRgba(start, end, t)`
- `parseGradient(css)`, `lerpGradient(start, end, t)`
- `parseBoxShadow(css)`, `lerpBoxShadow(start, end, t)`
2. **Create wind-down hook** (`src/hooks/useCelebrationWindDown.ts`)
- localStorage state management
- requestAnimationFrame loop
- Progress calculation with quintic ease-out
- Confetti trigger flag
3. **Create style calculation** (in SessionModeBanner or separate file)
- Define start/end values for all properties
- `calculateCelebrationStyles(progress, isDark)`
4. **Update SessionModeBanner**
- Add CelebrationProgressionBanner sub-component
- Integrate wind-down when progression + tutorialRequired
- Move confetti firing into banner
5. **Clean up Dashboard/Summary**
- Remove SkillUnlockBanner conditionals
- Let SessionModeBanner handle everything
6. **Consider: SkillUnlockBanner**
- Deprecate or keep for other uses?
- Could extract confetti logic to shared util
## Total Property Count
We're interpolating:
**Container:** 6 properties (background, border-width, border-color, border-radius, padding, box-shadow)
**Emoji:** 5 properties (trophy opacity, star opacity, size, rotation, margin)
**Title:** 3 properties (font-size, color, text-shadow)
**Subtitle:** 3 properties (font-size, color, margin-top)
**Button:** 7 properties (padding-y, padding-x, font-size, background, border-radius, box-shadow, color)
**Effects:** 1 property (shimmer opacity)
**Layout:** 1 property (flex-direction/alignment switch at 70%)
**Total: 26 interpolated properties**
Plus the oscillation for the wiggle animation running independently at 60fps.
This is properly ridiculous. The text stays the same throughout, making the transition truly imperceptible.

View File

@@ -42,6 +42,7 @@ When you agree with the user on a technical approach (e.g., "use getBBox() for b
3. **When fixes don't work, FIRST verify the agreed approach was actually implemented everywhere** - don't add patches on top of a broken foundation
**The failure pattern:**
- User and Claude agree: "Part 1 and Part 2 should both use method X"
- Claude implements method X for Part 2 (the obvious case)
- Claude leaves Part 1 using the old method Y
@@ -50,11 +51,13 @@ When you agree with the user on a technical approach (e.g., "use getBBox() for b
- Cycle repeats until user is frustrated
**What to do instead:**
- Before implementing: "Part 1 will use [exact method], Part 2 will use [exact method]"
- After implementing: Verify BOTH actually use the agreed method
- When debugging: First question should be "did I actually implement what we agreed on everywhere?"
**Why this matters:**
- Users cannot verify every line of code you write
- They trust that when you agree to do something, you actually do it
- Superficial fixes waste everyone's time when the root cause is incomplete implementation
@@ -821,6 +824,49 @@ When adding/modifying database schema:
- Production deployments run `npm run db:migrate` automatically
- Improperly created migrations will fail in production
### CRITICAL: Statement Breakpoints in Migrations
**When a migration contains multiple SQL statements, you MUST add `--> statement-breakpoint` between them.**
Drizzle's better-sqlite3 driver executes statements one at a time. If you have multiple statements without breakpoints, the migration will fail with:
```
RangeError: The supplied SQL string contains more than one statement
```
**✅ CORRECT - Multiple statements with breakpoints:**
```sql
-- Create the table
CREATE TABLE `app_settings` (
`id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
`threshold` real DEFAULT 0.3 NOT NULL
);
--> statement-breakpoint
-- Seed default data
INSERT INTO `app_settings` (`id`, `threshold`) VALUES ('default', 0.3);
```
**❌ WRONG - Multiple statements without breakpoint (CAUSES PRODUCTION OUTAGE):**
```sql
CREATE TABLE `app_settings` (...);
-- This will fail!
INSERT INTO `app_settings` ...;
```
**When this applies:**
- CREATE TABLE followed by INSERT (seeding data)
- CREATE TABLE followed by CREATE INDEX
- Any migration with 2+ SQL statements
**Historical context:**
This mistake caused a production outage on 2025-12-18. The app crash-looped because migration 0035 had CREATE TABLE + INSERT without a breakpoint. Always verify migrations with multiple statements have `--> statement-breakpoint` markers.
## Deployment Verification
**CRITICAL: Never assume deployment is complete just because the website is accessible.**
@@ -884,6 +930,7 @@ When working on the curriculum-based daily practice system, refer to:
- Database schema and API endpoints
**Key Files**:
- `src/lib/curriculum/progress-manager.ts` - CRUD operations
- `src/hooks/usePlayerCurriculum.ts` - Client-side state management
- `src/components/practice/` - UI components (StudentSelector, ProgressDashboard)

View File

@@ -0,0 +1,324 @@
# Complexity Budget System
## Overview
The complexity budget system controls problem difficulty by measuring the cognitive cost of each term in a problem. This allows us to:
1. **Cap difficulty** for beginners (max budget) - don't overwhelm with too many hard skills per term
2. **Require difficulty** for challenge problems (min budget) - ensure every term exercises real skills
3. **Personalize difficulty** based on student mastery - same problem is "harder" for students still learning
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ SESSION PLANNER │
│ ┌─────────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ PlayerSkillMastery │───▶│ buildStudentSkillHistory() │ │
│ │ (from DB) │ │ ↓ │ │
│ └─────────────────────┘ │ StudentSkillHistory │ │
│ │ ↓ │ │
│ │ createSkillCostCalculator() │ │
│ │ ↓ │ │
│ │ SkillCostCalculator │──┐
│ └─────────────────────────────────────────┘ │ │
│ │ │
│ ┌─────────────────────┐ ┌─────────────────────────────────────────┐ │ │
│ │ purposeComplexity │───▶│ getComplexityBoundsForSlot() │ │ │
│ │ Bounds (config) │ │ ↓ │ │ │
│ └─────────────────────┘ │ { min?: number, max?: number } │──┼─┐
│ └─────────────────────────────────────────┘ │ │ │
└──────────────────────────────────────────────────────────────────────────┘ │ │
│ │
┌─────────────────────────────────────────────────────────────────────────┐ │ │
│ PROBLEM GENERATOR │ │ │
│ │ │ │
│ generateProblemFromConstraints(constraints, costCalculator) ◀───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ For each candidate term: │ │
│ │ termCost = costCalculator.calculateTermCost(stepSkills) │◀─┘
│ │ │
│ │ if (termCost > maxBudget) continue // Too hard │
│ │ if (termCost < minBudget) continue // Too easy │
│ │ │
│ │ candidates.push({ term, skillsUsed, complexityCost: termCost }) │
│ └─────────────────────────────────────────────────────────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────────────────────────────────────────────┐
│ │ GenerationTrace (output) │
│ │ - steps[].complexityCost │
│ │ - totalComplexityCost │
│ │ - minBudgetConstraint / budgetConstraint │
│ │ - skillMasteryContext (per-skill mastery for display) │
│ └─────────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────────────────┘
```
## Cost Calculation
### Base Skill Complexity (Intrinsic)
| Skill Category | Base Cost | Rationale |
| ------------------------ | --------- | -------------------------- |
| `basic.*` (direct moves) | 0 | Trivial bead movements |
| `fiveComplements.*` | 1 | Single mental substitution |
| `tenComplements.*` | 2 | Cross-column operation |
| `advanced.cascading*` | 3 | Multi-column propagation |
### Mastery Multipliers (Student-Specific)
| Mastery State | Multiplier | Description |
| ------------- | ---------- | --------------------------------- |
| `effortless` | 1× | Automatic, no thought required |
| `fluent` | 2× | Solid but needs some attention |
| `practicing` | 3× | Currently working on, needs focus |
| `learning` | 4× | Just introduced, maximum effort |
### Effective Cost Formula
```
effectiveCost = baseCost × masteryMultiplier
termCost = Σ(effectiveCost for each skill in term)
```
**Example**: `5 + 9 = 14` requires `tenComplements.9=10-1`
- For a beginner (learning): `2 × 4 = 8`
- For an expert (effortless): `2 × 1 = 2`
Same problem, different cognitive load.
## Configuration
### Purpose-Specific Complexity Bounds
```typescript
purposeComplexityBounds: {
focus: {
abacus: { min: null, max: null }, // Full range
visualization: { min: null, max: 3 }, // Cap for mental math
linear: { min: null, max: null },
},
reinforce: {
abacus: { min: null, max: null },
visualization: { min: null, max: 3 },
linear: { min: null, max: null },
},
review: {
abacus: { min: null, max: null },
visualization: { min: null, max: 3 },
linear: { min: null, max: null },
},
challenge: {
abacus: { min: 1, max: null }, // Require complement skills
visualization: { min: 1, max: null }, // No cap, require min
linear: { min: 1, max: null },
},
}
```
### What the Bounds Mean
- **`min: null`** - Any term is acceptable, including trivial `+1` direct additions
- **`min: 1`** - Every term must use at least one non-trivial skill (five-complement or higher)
- **`max: 3`** - No term can exceed cost 3 (prevents overwhelming visualization)
- **`max: null`** - No upper limit
## Data Flow
### 1. Session Planning
```typescript
// session-planner.ts
const skillMastery = await getAllSkillMastery(playerId);
// Build student-aware calculator
const studentHistory = buildStudentSkillHistory(skillMastery);
const costCalculator = createSkillCostCalculator(studentHistory);
// For each slot
const bounds = getComplexityBoundsForSlot(purpose, partType, config);
const slot = createSlot(index, purpose, constraints, partType, config);
slot.complexityBounds = bounds;
// Generate problem with calculator
slot.problem = generateProblemFromConstraints(slot.constraints, costCalculator);
```
### 2. Problem Generation
```typescript
// problem-generator.ts
function generateProblemFromConstraints(
constraints: ProblemConstraints,
costCalculator?: SkillCostCalculator,
): GeneratedProblem {
// Pass through to generator
const problem = generateSingleProblem({
constraints: {
...generatorConstraints,
minComplexityBudgetPerTerm: constraints.minComplexityBudgetPerTerm,
maxComplexityBudgetPerTerm: constraints.maxComplexityBudgetPerTerm,
},
allowedSkills,
costCalculator,
});
}
```
### 3. Term Filtering
```typescript
// problemGenerator.ts - findValidNextTermWithTrace
const termCost = costCalculator?.calculateTermCost(stepSkills);
if (termCost !== undefined) {
if (maxBudget !== undefined && termCost > maxBudget) continue;
if (minBudget !== undefined && termCost < minBudget) continue;
}
candidates.push({ term, skillsUsed, complexityCost: termCost });
```
### 4. Trace Capture
```typescript
// Captured in GenerationTrace
{
steps: [
{ termAdded: 4, skillsUsed: ['fiveComplements.4=5-1'], complexityCost: 2 },
{ termAdded: 9, skillsUsed: ['tenComplements.9=10-1'], complexityCost: 4 },
],
totalComplexityCost: 6,
minBudgetConstraint: 1,
budgetConstraint: null,
skillMasteryContext: {
'fiveComplements.4=5-1': { masteryLevel: 'fluent', baseCost: 1, effectiveCost: 2 },
'tenComplements.9=10-1': { masteryLevel: 'practicing', baseCost: 2, effectiveCost: 6 },
}
}
```
## UI Display
### Purpose Tooltip (Enhanced)
The purpose badge tooltip shows complexity information:
```
⭐ Challenge
Harder problems - every term requires complement techniques.
┌─────────────────────────────────────────┐
│ Complexity │
│ ─────────────────────────────────────── │
│ Required: ≥1 per term Actual: 2 avg │
│ │
│ +4 (5-comp) cost: 2 [fluent] │
│ +9 (10-comp) cost: 4 [practicing] │
│ │
│ Total: 6 │
└─────────────────────────────────────────┘
```
## Future Extensions
### Mastery Recency (Not Implemented Yet)
The architecture supports adding recency-based mastery states:
**Scenarios to support:**
1. **Mastered + continuously practiced**`effortless` (1×)
2. **Mastered + not practiced recently**`rusty` (2.5×) - NEW STATE
3. **Recently mastered**`fluent` (2×)
**Implementation path:**
1. **Track `masteredAt` timestamp** in `player_skill_mastery` table
2. **Add `rusty` state** to `MasteryState` type and multipliers:
```typescript
export type MasteryState =
| "effortless"
| "fluent"
| "rusty"
| "practicing"
| "learning";
export const MASTERY_MULTIPLIERS: Record<MasteryState, number> = {
effortless: 1,
fluent: 2,
rusty: 2.5, // NEW
practicing: 3,
learning: 4,
};
```
3. **Enhance `dbMasteryToState` conversion:**
```typescript
export function dbMasteryToState(
dbLevel: "learning" | "practicing" | "mastered",
daysSinceLastPractice?: number,
daysSinceMastery?: number,
): MasteryState {
if (dbLevel === "learning") return "learning";
if (dbLevel === "practicing") return "practicing";
// Mastered - but how rusty?
if (daysSinceLastPractice !== undefined && daysSinceLastPractice > 14) {
return "rusty"; // Mastered but neglected
}
if (daysSinceMastery !== undefined && daysSinceMastery > 30) {
return "effortless"; // Long-term mastery + recent practice
}
return "fluent"; // Recently mastered
}
```
**Why this is straightforward:**
- `SkillCostCalculator` is an interface - can swap implementations
- `dbMasteryToState` is the single conversion point - all recency logic goes here
- `StudentSkillState` interface already has documented extension points
- UI captures `skillMasteryContext` in trace - automatically displays new states
### Other Future Extensions
1. **Accuracy-based multipliers**: Students with <70% accuracy on a skill get higher multiplier
2. **Time-based decay**: Multiplier increases gradually based on days since practice
3. **Per-skill complexity overrides**: Some skills are harder for specific students
## Files Reference
| File | Purpose |
| ------------------------------------------- | ---------------------------------------------- |
| `src/utils/skillComplexity.ts` | Base costs, mastery states, calculator factory |
| `src/utils/problemGenerator.ts` | Term filtering with budget enforcement |
| `src/lib/curriculum/problem-generator.ts` | Wrapper that passes calculator through |
| `src/lib/curriculum/session-planner.ts` | Builds calculator, sets purpose bounds |
| `src/db/schema/session-plans.ts` | Type definitions, config defaults |
| `src/components/practice/ActiveSession.tsx` | UI display of complexity data |
## Testing
### Verify Budget Enforcement
```typescript
// Existing test file: src/utils/__tests__/problemGenerator.budget.test.ts
describe('complexity budget', () => {
it('rejects terms exceeding max budget', () => { ... })
it('rejects terms below min budget', () => { ... }) // NEW
it('uses student mastery to calculate cost', () => { ... })
})
```
### Verify UI Display
Check Storybook stories for `PurposeBadge` with complexity data visible.

View File

@@ -0,0 +1,221 @@
# Consultation with Kehkashan Khan - Student Learning Model
## Context
We are improving the SimulatedStudent model used in journey simulation tests to validate BKT-based adaptive problem generation. The current model uses a Hill function for learning but lacks several realistic phenomena.
## Current Model Limitations
| Phenomenon | Reality | Current Model |
| -------------------------- | ------------------------------------------ | ---------------------- |
| **Forgetting** | Skills decay without practice | Skills never decay |
| **Transfer** | Learning one complement helps learn others | Skills are independent |
| **Skill difficulty** | Some skills are inherently harder | All skills have same K |
| **Within-session fatigue** | Later problems are harder | All problems equal |
| **Warm-up effect** | First few problems are shakier | No warm-up |
## Email Sent to Kehkashan
**Date:** 2025-12-15
**From:** Thomas Hallock <hallock@gmail.com>
**To:** Kehkashan Khan
**Subject:** (not captured)
---
Hi Ms. Hkan,
I hope you and your mother are doing well in Oman. Please don't feel the need to reply to this immediately—whenever you have a spare moment is fine.
I've been updating some abacus practice software and I've been testing on Sonia and Fern, but I only have a sample size of 2, so I have had to make some assumptions that I'd like to improve upon. Specifically I've been trying to make it "smarter" about which problems to generate for them. The goal is for the app to automatically detect when they are struggling with a specific movement (like a 5-complement) and give them just enough practice to fix it without getting boring.
I have a computer simulation running to test this, and have seen some very positive results in learning compared to the method from my books, but I realized my assumptions about how children actually learn might be a bit too simple. Since you have observes this process with many different children, I'd love your take on a few things:
Are some skills inherently harder? In your experience, are certain movements just naturally harder for kids to grasp than others? For example, is a "10-complement" (like +9 = -1 +10) usually harder to master than a "5-complement" (like +4 = +5 -1)? Or are they about the same difficulty once the concept clicks?
Do skills transfer? Once a student truly understands the movement for +4, does that make learning +3 easier? Or do they tend to treat every new number as a completely new skill that needs to be practiced from scratch?
How fast does "rust" set in? If a student masters a specific skill but doesn't use it for two weeks, do they usually retain it? Or do they tend to forget it and need to re-learn it?
Fatigue vs. Warm-up Do you notice that accuracy drops significantly after 15-20 minutes? Or is there the opposite effect, where they need a "warm-up" period at the start of a lesson before they hit their stride?
Any "gut feeling" or observations you have would be incredibly helpful. I can use that info to make the math behind the app much more realistic.
Hope you are managing everything over there. See you Sunday!
p.s If you're curious, I have written up a draft about the system on my blog here:
https://abaci.one/blog/conjunctive-bkt-skill-tracing
Best,
Thomas
---
## Questions Asked & How to Use Answers
### 1. Skill Difficulty
**Question:** Are 10-complements harder than 5-complements?
**How to model:** Add per-skill K values (half-max exposure) in SimulatedStudent
```typescript
const SKILL_DIFFICULTY: Record<string, number> = {
"basic.directAddition": 5,
"fiveComplements.*": 10, // If she says 5-comp is medium
"tenComplements.*": 18, // If she says 10-comp is harder
};
```
### 2. Transfer Effects
**Question:** Does learning +4 help with +3?
**How to model:** Add transfer weights between related skills
```typescript
// If she says yes, skills transfer within categories:
function getEffectiveExposure(skillId: string): number {
const direct = exposures.get(skillId) ?? 0;
const transferred = getRelatedSkills(skillId).reduce(
(sum, related) => sum + (exposures.get(related) ?? 0) * TRANSFER_WEIGHT,
0,
);
return direct + transferred;
}
```
### 3. Forgetting/Rust
**Question:** How fast do skills decay without practice?
**How to model:** Multiply probability by retention factor
```typescript
// If she says 2 weeks causes noticeable rust:
const HALF_LIFE_DAYS = 14; // Tune based on her answer
retention = Math.exp(-daysSinceLastPractice / HALF_LIFE_DAYS);
P_effective = P_base * retention;
```
### 4. Fatigue & Warm-up
**Question:** Does accuracy drop after 15-20 min? Is there warm-up?
**How to model:** Add session position effects
```typescript
// If she says both exist:
function sessionPositionMultiplier(
problemIndex: number,
totalProblems: number,
): number {
const warmupBoost = Math.min(1, problemIndex / 3); // First 3 problems are warm-up
const fatiguePenalty = (problemIndex / totalProblems) * 0.1; // 10% drop by end
return warmupBoost * (1 - fatiguePenalty);
}
```
## Background on Kehkashan
- Abacus coach for Sonia and Fern (Thomas's kids)
- Teaches 1 hour each Sunday
- Getting PhD in something related to academic rigor in children
- Expert in soroban pedagogy
- Currently in Oman caring for her mother
- Not deeply technical/statistical, so answers will be qualitative observations
---
## Response Received (2025-12-16)
**From:** Kehkashan Khan
---
Hi, good to hear from you. We are taking it one day at a time with my mother. Thank you for asking.
I appreciate all your concerns about this program.
First the benefits, it is a developmentally appropriate and age appropriate program. Your books are a bit too complicated if you don't mind me saying that. Your initial push with Sonia and Fern has given them a firm footing. They are such beautiful kids I have no words to describe them.
My concerns,
One is the book I shared with you already. It's unnecessarily complicated.
Secondly the abacus itself, if you want them to learn all the skills then they need to use the one that has beads on both sides and should be able to manipulate them using both hands.
Their foundational skills are strong, maybe you are looking for perfection. I don't know.
I have seen so much improvement in Fern's mastery of concepts. Sonia was an expert even before I started coaching them. The complicated oral problems she does is amazing.
Now in general, this is a stressful class, you need to give them more breaks. They are great negotiators, come up with a strategy that will please them but still keep you in control.
The skills are transferable, not just within the program but also cross curricular. After a while they will want to continue working on this because it makes them smarter and they will know the difference. All the operations whether +/-, combinations of 10 or 5, need practice and patience. Meta cognition is visible all the time, their learning is almost visible.
Let me see the app , we can arrange a google meet just to check it out. No charges. Children get frustrated when pieces of the puzzle don't fit. I wonder if there are parts that are not quite fitting in their mental framework. I will be able to give you a better idea if I see the components.
I hope I was able to respond to your questions. I am on break from my university work and can spend some time on your project if required even if it is just for feedback. Also, please leave a google review for my program. It will be greatly appreciated.
Sincerely,
Khan
---
## Interpreted Responses (with Thomas's context)
| Her Statement | Context/Interpretation |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| "Your books are a bit too complicated" | SAI Speed Academy workbooks - Fern needs more repetition than they provide, which drove building the app |
| "abacus... beads on both sides... both hands" | Thomas made custom 4-column abaci. Kids will need to transition to full-size after mastering add/subtract |
| "this is a stressful class, you need to give them more breaks" | Sunday lessons come after other activities (math, violin). Scheduling issue, not generalizable |
| "skills are transferable... cross curricular" | Too general - she means abacus helps general math, not that +4 helps +3 within soroban |
| "All operations... need practice and patience" | Every skill needs drilling, none can be skipped. No dramatic difficulty differences implied |
| "pieces of the puzzle don't fit" | Validates our goal - she recognizes value of isolating specific deficiencies. Has NOT seen app yet |
| "Let me see the app" | Most valuable next step - schedule Google Meet |
---
## Follow-up Email Sent (2025-12-16)
**From:** Thomas Hallock
---
Hi Ms. Khan,
Good to hear from you. I hope you and your mother continue to hold up well.
Thank you for the feedback on the books and the abacus size. I think you're right that Fern needs more repetition than the books provide, which is what drove me to build the software. I will also look into transitioning them to the full-sized, two-handed abacus now that they are less likely to get distracted by the extra columns.
I would definitely appreciate a Google Meet. I'd love to walk you through the logic the app uses to diagnose student errors. It attempts to automate the "struggle detection" you do naturally as a teacher, and I could use your feedback on whether it's calibrated correctly.
You can preview the basic interface at https://abaci.one/practice, but a live demo would be better to explain the background logic.
Please let me know what time works for you, and send over the link for your Google Review.
Best,
Thomas
---
## Implications for Student Model
### What we learned:
- **All skills need practice** - No evidence of dramatic difficulty differences between skill categories
- **Validation of the goal** - Isolating "puzzle pieces" that don't fit is valuable
- **Individual variance** - Sonia vs Fern confirms wide learner differences (matches our profiles)
### What we still don't know:
- Whether skills transfer within soroban (does +4 help +3?)
- How fast "rust" sets in
- Warm-up effects
### Recommendation:
Wait for Google Meet feedback before making model changes. She'll provide more specific input after seeing the app's "struggle detection" logic.
---
## Next Steps
1. ✅ Send follow-up email requesting Google Meet
2. ⏳ Leave Google review for her program (need link)
3. ⏳ Schedule and conduct Google Meet demo
4. ⏳ Update this document with her feedback on BKT calibration

View File

@@ -0,0 +1,151 @@
# Remediation CTA Plan
## Overview
Add special "fancy" treatment to the StartPracticeModal when the student is in remediation mode (has weak skills that need strengthening). This mirrors the existing tutorial CTA treatment.
## Current Tutorial CTA Treatment (lines 1311-1428)
When `sessionMode.type === 'progression' && tutorialRequired`:
1. **Visual Design:**
- Green gradient background with border
- 🌟 icon
- "You've unlocked: [skill name]" heading
- "Start with a quick tutorial" subtitle
- Green gradient button: "🎓 Begin Tutorial →"
2. **Behavior:**
- Replaces the regular "Let's Go!" button
- Clicking opens the SkillTutorialLauncher
## Proposed Remediation CTA
When `sessionMode.type === 'remediation'`:
1. **Visual Design:**
- Amber/orange gradient background with border (warm "focus" colors)
- 💪 icon (strength/building)
- "Time to build strength!" heading
- "Focusing on [N] skills that need practice" subtitle
- Show weak skill badges with pKnown percentages
- Amber gradient button: "💪 Start Focus Practice →"
2. **Behavior:**
- Replaces the regular "Let's Go!" button
- Clicking goes straight to practice (no separate launcher needed)
- The session will automatically target weak skills via sessionMode
## Implementation Steps
### Step 1: Add remediation detection
```typescript
// Derive whether to show remediation CTA
const showRemediationCta = sessionMode.type === 'remediation' && sessionMode.weakSkills.length > 0
```
### Step 2: Create RemediationCta component section
Add after the Tutorial CTA section (line ~1428), or restructure to have a single "special CTA" section that handles both cases.
```tsx
{/* Remediation CTA - Weak skills need strengthening */}
{showRemediationCta && !showTutorialGate && (
<div
data-element="remediation-cta"
className={css({...})}
style={{
background: isDark
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(234, 88, 12, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(234, 88, 12, 0.05) 100%)',
border: `2px solid ${isDark ? 'rgba(245, 158, 11, 0.25)' : 'rgba(245, 158, 11, 0.2)'}`,
}}
>
{/* Info section */}
<div className={css({...})}>
<span>💪</span>
<div>
<p>Time to build strength!</p>
<p>Focusing on {weakSkills.length} skill{weakSkills.length > 1 ? 's' : ''} that need practice</p>
</div>
</div>
{/* Weak skills badges */}
<div className={css({...})}>
{sessionMode.weakSkills.slice(0, 4).map((skill) => (
<span key={skill.skillId} className={css({...})}>
{skill.displayName} ({Math.round(skill.pKnown * 100)}%)
</span>
))}
{sessionMode.weakSkills.length > 4 && (
<span>+{sessionMode.weakSkills.length - 4} more</span>
)}
</div>
{/* Integrated start button */}
<button
data-action="start-focus-practice"
onClick={handleStart}
disabled={isStarting}
style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
}}
>
{isStarting ? 'Starting...' : (
<>
<span>💪</span>
<span>Start Focus Practice</span>
<span></span>
</>
)}
</button>
</div>
)}
```
### Step 3: Update start button visibility logic
Change from:
```tsx
{!showTutorialGate && (
<button>Let's Go! </button>
)}
```
To:
```tsx
{!showTutorialGate && !showRemediationCta && (
<button>Let's Go! </button>
)}
```
## Visual Comparison
| Mode | Icon | Color Theme | Heading | Button Text |
|------|------|-------------|---------|-------------|
| Tutorial | 🌟 | Green | "You've unlocked: [skill]" | "🎓 Begin Tutorial →" |
| Remediation | 💪 | Amber | "Time to build strength!" | "💪 Start Focus Practice →" |
| Normal | - | Blue | "Ready to practice?" | "Let's Go! →" |
## Files to Modify
1. `apps/web/src/components/practice/StartPracticeModal.tsx`
- Add `showRemediationCta` derived state
- Add Remediation CTA section (similar structure to Tutorial CTA)
- Update regular start button visibility condition
## Testing Considerations
1. Storybook stories should cover:
- Remediation mode with 1 weak skill
- Remediation mode with 3+ weak skills
- Remediation mode with 5+ weak skills (overflow)
2. The existing `StartPracticeModal.stories.tsx` already has sessionMode mocks - add remediation variants.
## Accessibility
- Ensure proper ARIA labels on the remediation CTA
- Color contrast should meet WCAG guidelines (amber text on amber background needs checking)
- Screen reader should announce the focus practice intent

View File

@@ -0,0 +1,161 @@
# Session Mode Unified Architecture
## Problem Statement
The current architecture has three independent BKT computations:
1. Dashboard computes BKT locally for skill cards
2. Modal computes BKT locally for "Targeting: X" preview
3. Session planner computes BKT when generating problems
This creates potential mismatches where the modal shows one thing but the session planner does another ("rug-pulling").
Additionally, students see conflicting signals:
- Header: "Addition: +1 (Direct Method)"
- Tutorial notice: "You've unlocked: +1 = +5 - 4"
- Targeting: "+3 = +5 - 2"
## Solution: Unified SessionMode
A single `SessionMode` object computed once and used everywhere:
- Dashboard (what banner to show)
- Modal (what CTA to display)
- Session planner (what problems to generate)
### Key Principles
1. **No rug-pulling**: Whatever the modal shows IS what configures problem generation
2. **Transparent blocking**: When remediation blocks promotion, student knows why
3. **Single source of truth**: One computation, used everywhere
## SessionMode Type Definition
```typescript
interface SkillInfo {
skillId: string
displayName: string
pKnown: number // 0-1 probability
}
type SessionMode =
| {
type: 'remediation'
weakSkills: SkillInfo[]
focusDescription: string
// What promotion is being blocked
blockedPromotion?: {
nextSkill: SkillInfo
reason: string // "Strengthen +3 and +5-2 first"
}
}
| {
type: 'progression'
nextSkill: SkillInfo
tutorialRequired: boolean
focusDescription: string
}
| {
type: 'maintenance'
focusDescription: string // "All skills strong - mixed practice"
}
```
## UI States
### Dashboard Banner Area
**Progression Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ 🌟 New Skill Unlocked! │
│ You're ready to learn: +5 - 4 │
│ [Start Practice] │
└────────────────────────────────────────────────────────────┘
```
**Remediation Mode (with blocked promotion):**
```
┌────────────────────────────────────────────────────────────┐
│ 🔒 Almost there! │
│ Strengthen +3 and +5-2 to unlock: +5 - 4 │
│ Progress: ████████░░ 80% │
│ [Practice Now] │
└────────────────────────────────────────────────────────────┘
```
**Maintenance Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ ✨ All skills strong! │
│ Keep practicing to maintain mastery │
│ [Practice] │
└────────────────────────────────────────────────────────────┘
```
### Modal CTA Area
**Progression Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ 🌟 You've unlocked: +5 - 4 │
│ Start with a quick tutorial │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 🎓 Begin Tutorial → │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
**Remediation Mode:**
```
┌────────────────────────────────────────────────────────────┐
│ 💪 Strengthening weak skills │
│ Targeting: +3, +5-2 │
│ Then you'll unlock: +5 - 4 │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Let's Go! → │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
## Data Flow
```
1. Dashboard loads → GET /api/curriculum/{playerId}/session-mode
→ Returns SessionMode (computed once)
→ Dashboard displays appropriate banner
2. User clicks "Start Practice" → Modal opens
→ Modal receives SAME SessionMode
→ Displays matching CTA
3. User clicks "Let's Go!" → generateSessionPlan(sessionMode)
→ Session planner uses the SAME mode
→ Problems generated match what modal showed
```
## Implementation Files
### New Files
- `src/lib/curriculum/session-mode.ts` - Core `getSessionMode()` function
- `src/hooks/useSessionMode.ts` - React Query hook
- `src/app/api/curriculum/[playerId]/session-mode/route.ts` - API endpoint
- `src/components/practice/SessionModeBanner.tsx` - Unified banner component
- `src/stories/SessionModeBanner.stories.tsx` - Storybook stories
### Modified Files
- `src/components/practice/StartPracticeModal.tsx` - Use SessionMode instead of local BKT
- `src/app/practice/[studentId]/dashboard/DashboardClient.tsx` - Use SessionModeBanner
- `src/lib/curriculum/session-planner.ts` - Accept SessionMode as input
- `src/hooks/useNextSkillToLearn.ts` - Deprecate or derive from useSessionMode
## Implementation Order
1. Create `SessionMode` types and `getSessionMode()` function
2. Create API endpoint
3. Create `useSessionMode()` hook
4. Create `SessionModeBanner` component with all 3 modes
5. Add Storybook stories for all states
6. Update Dashboard to use new banner
7. Update Modal to use SessionMode
8. Update session planner to accept SessionMode
9. Remove duplicate BKT computations
10. Test end-to-end flow

View File

@@ -0,0 +1,179 @@
# Simulated Student Model
## Overview
The `SimulatedStudent` class models how students learn soroban skills over time. It's used in journey simulation tests to validate that BKT-based adaptive problem generation outperforms classic random generation.
**Location:** `src/test/journey-simulator/SimulatedStudent.ts`
## Core Model: Hill Function Learning
The model uses the **Hill function** (from biochemistry/pharmacology) to model learning:
```
P(correct | skill) = exposure^n / (K^n + exposure^n)
```
Where:
- **exposure**: Number of times the student has attempted problems using this skill
- **K** (halfMaxExposure): Exposure count where P(correct) = 0.5
- **n** (hillCoefficient): Controls curve shape (n > 1 delays onset, then accelerates)
### Why Hill Function?
The Hill function naturally models how real learning works:
1. **Early struggles**: Low exposure = low probability (building foundation)
2. **Breakthrough**: At some point, understanding "clicks" (steep improvement)
3. **Mastery plateau**: High exposure approaches but never reaches 100%
### Example Curves
With K=10, n=2:
| Exposures | P(correct) | Stage |
| --------- | ---------- | ----------------------------- |
| 0 | 0% | No knowledge |
| 5 | 20% | Building foundation |
| 10 | 50% | Half-way (by definition of K) |
| 15 | 69% | Understanding clicks |
| 20 | 80% | Confident |
| 30 | 90% | Near mastery |
## Skill-Specific Difficulty
**Key insight from pedagogy:** Not all skills are equally hard. Ten-complements require cross-column operations and are inherently harder than five-complements.
### Difficulty Multipliers
Each skill has a difficulty multiplier applied to K:
```typescript
effectiveK = profile.halfMaxExposure * SKILL_DIFFICULTY_MULTIPLIER[skillId];
```
| Skill Category | Multiplier | Effect |
| ---------------------------------- | ---------- | -------------------------------- |
| Basic (directAddition, heavenBead) | 0.8-0.9x | Easier, fewer exposures needed |
| Five-complements | 1.2-1.3x | Moderate, ~20-30% more exposures |
| Ten-complements | 1.6-2.1x | Hardest, ~60-110% more exposures |
### Concrete Example
With profile K=10:
| Skill | Multiplier | Effective K | Exposures for 50% |
| --------------------- | ---------- | ----------- | ----------------- |
| basic.directAddition | 0.8 | 8 | 8 |
| fiveComplements.4=5-1 | 1.2 | 12 | 12 |
| tenComplements.9=10-1 | 1.6 | 16 | 16 |
| tenComplements.1=10-9 | 2.0 | 20 | 20 |
### Rationale for Specific Values
Based on soroban pedagogy:
- **Basic skills (0.8-0.9)**: Single-column, direct bead manipulation
- **Five-complements (1.2-1.3)**: Requires decomposition thinking (+4 = +5 -1)
- **Ten-complements (1.6-2.1)**: Cross-column carrying/borrowing, harder mental model
- **Harder ten-complements**: Larger adjustments (tenComplements.1=10-9 = +1 requires -9+10) are cognitively harder
## Conjunctive Model for Multi-Skill Problems
When a problem requires multiple skills (e.g., basic.directAddition + tenComplements.9=10-1):
```
P(correct) = P(skill_A) × P(skill_B) × P(skill_C) × ...
```
This models that ALL component skills must be applied correctly. A student strong in basics but weak in ten-complements will fail problems requiring ten-complements.
## Student Profiles
Profiles define different learner types:
```typescript
interface StudentProfile {
name: string;
halfMaxExposure: number; // K: lower = faster learner
hillCoefficient: number; // n: curve shape
initialExposures: Record<string, number>; // Pre-seeded learning
helpUsageProbabilities: [number, number, number, number];
helpBonuses: [number, number, number, number];
baseResponseTimeMs: number;
responseTimeVariance: number;
}
```
### Example Profiles
| Profile | K | n | Description |
| --------------- | --- | --- | ---------------------------------- |
| Fast Learner | 8 | 1.5 | Quick acquisition, smooth curve |
| Average Learner | 12 | 2.0 | Typical learning rate |
| Slow Learner | 15 | 2.5 | Needs more practice, delayed onset |
## Exposure Accumulation
**Critical behavior**: Exposure increments on EVERY attempt, not just correct answers.
This models that students learn from engaging with material, regardless of success. The attempt itself is the learning event.
```typescript
// Learning happens from attempting, not just succeeding
for (const skillId of skillsChallenged) {
const current = this.skillExposures.get(skillId) ?? 0;
this.skillExposures.set(skillId, current + 1);
}
```
## Fatigue Tracking
The model tracks cognitive load based on true skill mastery:
| True P(correct) | Fatigue Multiplier | Interpretation |
| --------------- | ------------------ | ------------------------------ |
| ≥ 90% | 1.0x | Automated, low effort |
| ≥ 70% | 1.5x | Nearly automated |
| ≥ 50% | 2.0x | Moderate effort |
| ≥ 30% | 3.0x | Struggling |
| < 30% | 4.0x | Very weak, high cognitive load |
## Help System
Students can use help at four levels:
- **Level 0**: No help
- **Level 1**: Hint
- **Level 2**: Decomposition shown
- **Level 3**: Full solution
Help provides an additive bonus to probability (not multiplicative), simulating that help scaffolds understanding but doesn't guarantee correctness.
## Validation
The model is validated by:
1. **BKT Correlation**: BKT's P(known) should correlate with true P(correct)
2. **Learning Trajectories**: Accuracy should improve over sessions
3. **Skill Targeting**: Adaptive mode should surface weak skills faster
4. **Difficulty Ordering**: Ten-complements should take longer to master than five-complements
## Files
- `src/test/journey-simulator/SimulatedStudent.ts` - Main model implementation
- `src/test/journey-simulator/types.ts` - StudentProfile type definition
- `src/test/journey-simulator/profiles/` - Predefined learner profiles
- `src/test/journey-simulator/journey-simulator.test.ts` - Validation tests
## Future Improvements
Based on consultation with Kehkashan Khan (abacus coach):
1. **Forgetting/Decay**: Skills may decay without practice (not yet implemented)
2. **Transfer Effects**: Learning +4 may help learning +3 (not yet implemented)
3. **Warm-up Effects**: First few problems may be shakier (not yet implemented)
4. **Within-session Fatigue**: Later problems may be harder (partially implemented via fatigue tracking)
See `.claude/KEHKASHAN_CONSULTATION.md` for full consultation notes.

View File

@@ -0,0 +1,810 @@
# Skill Tutorial Integration Plan
## Overview
This document outlines the integration between the curriculum skill system and the existing tutorial system to create a **tutorial-gated skill progression** with **gap-filling enforcement**.
## Core Principles
1. **Skills have two states:**
- **Conceptual understanding** (tutorial completed) - "I understand how this works"
- **Fluency** (practice mastery) - "I can do this automatically under cognitive load"
2. **Tutorial completion is required before practice:**
- A skill must have tutorial completion BEFORE it enters practice rotation (`isPracticing=true`)
- Teacher override is available for offline learning scenarios
3. **Gap-filling is strict:**
- Cannot advance to higher curriculum phases until ALL prerequisite skills are mastered
- System identifies gaps and prioritizes them over new skill introduction
---
## The Tutorial System (Already Exists)
### `generateUnifiedInstructionSequence(startValue, targetValue)`
Location: `src/utils/unifiedStepGenerator.ts`
This function is a complete pedagogical engine that:
- Takes any `(startValue, targetValue)` pair
- Generates step-by-step bead movements with English instructions
- Detects which complement rules are used (Direct, FiveComplement, TenComplement, Cascade)
- Creates `PedagogicalSegment` objects with human-readable explanations
**Output structure:**
```typescript
interface UnifiedInstructionSequence {
fullDecomposition: string; // e.g., "3 + 4 = 3 + (5 - 1) = 7"
isMeaningfulDecomposition: boolean;
steps: UnifiedStepData[]; // Each step has:
// - mathematicalTerm: "5", "-1"
// - englishInstruction: "activate heaven bead", "remove 1 earth bead"
// - expectedValue: number after this step
// - expectedState: AbacusState after this step
// - beadMovements: which beads to move
segments: PedagogicalSegment[]; // High-level explanations:
// - readable.title: "Make 5 — ones"
// - readable.summary: "Add 4 to the ones, but there isn't room..."
// - readable.subtitle: "Using 5's friend"
}
```
### TutorialPlayer Component
Location: `src/components/tutorial/TutorialPlayer.tsx`
Already handles:
- Step-by-step guided practice
- Bead highlighting and movement tracking
- Progress tracking through steps
- "Next step" / "Try again" interaction
---
## Integration Architecture
### Key Insight: Generate Tutorials Dynamically
Instead of authoring tutorials for each of 30+ skills, we **generate tutorials dynamically** by:
1. **For a given skill**, identify example problems that REQUIRE that skill
2. **Generate tutorial steps** using `generateUnifiedInstructionSequence()`
3. **Present using TutorialPlayer** with auto-generated steps
### Skill → Tutorial Problem Mapping
Each skill maps to a set of example problems that demonstrate it:
```typescript
// src/lib/curriculum/skill-tutorial-config.ts
interface SkillTutorialConfig {
skillId: string;
title: string;
description: string;
/** Example problems that demonstrate this skill */
exampleProblems: Array<{ start: number; target: number }>;
/** Number of practice problems before sign-off (default 3) */
practiceCount?: number;
}
export const SKILL_TUTORIAL_CONFIGS: Record<string, SkillTutorialConfig> = {
// Five-complement addition
"fiveComplements.4=5-1": {
skillId: "fiveComplements.4=5-1",
title: "Adding 4 using 5's friend",
description:
"When you need to add 4 but don't have room for 4 earth beads, use 5's friend: add 5, then take away 1.",
exampleProblems: [
{ start: 1, target: 5 }, // 1 + 4 = 5 (simplest)
{ start: 2, target: 6 }, // 2 + 4 = 6
{ start: 3, target: 7 }, // 3 + 4 = 7
],
practiceCount: 3,
},
"fiveComplements.3=5-2": {
skillId: "fiveComplements.3=5-2",
title: "Adding 3 using 5's friend",
description:
"When you need to add 3 but don't have room, use 5's friend: add 5, then take away 2.",
exampleProblems: [
{ start: 2, target: 5 },
{ start: 3, target: 6 },
{ start: 4, target: 7 },
],
},
// Ten-complement addition
"tenComplements.9=10-1": {
skillId: "tenComplements.9=10-1",
title: "Adding 9 with a carry",
description:
"When adding 9 would overflow the column, carry 10 to the next column and take away 1 here.",
exampleProblems: [
{ start: 1, target: 10 }, // 1 + 9 = 10
{ start: 2, target: 11 }, // 2 + 9 = 11
{ start: 5, target: 14 }, // 5 + 9 = 14
],
},
// Five-complement subtraction
"fiveComplementsSub.-4=-5+1": {
skillId: "fiveComplementsSub.-4=-5+1",
title: "Subtracting 4 using 5's friend",
description:
"When you need to subtract 4 but don't have 4 earth beads, use 5's friend: take away 5, then add 1 back.",
exampleProblems: [
{ start: 5, target: 1 },
{ start: 6, target: 2 },
{ start: 7, target: 3 },
],
},
// Ten-complement subtraction
"tenComplementsSub.-9=+1-10": {
skillId: "tenComplementsSub.-9=+1-10",
title: "Subtracting 9 with a borrow",
description:
"When subtracting 9 but you don't have enough, borrow 10 from the next column and add 1 here.",
exampleProblems: [
{ start: 10, target: 1 },
{ start: 11, target: 2 },
{ start: 15, target: 6 },
],
},
// Basic skills (simpler tutorials)
"basic.directAddition": {
skillId: "basic.directAddition",
title: "Adding by moving earth beads",
description:
"The simplest way to add: just push up the earth beads you need.",
exampleProblems: [
{ start: 0, target: 1 },
{ start: 0, target: 3 },
{ start: 1, target: 4 },
],
},
"basic.heavenBead": {
skillId: "basic.heavenBead",
title: "Using the heaven bead for 5",
description:
"The heaven bead is worth 5. Push it down to add 5 in one move.",
exampleProblems: [
{ start: 0, target: 5 },
{ start: 1, target: 6 },
{ start: 3, target: 8 },
],
},
};
```
---
## New Data Model
### skill_tutorial_progress Table
```sql
CREATE TABLE skill_tutorial_progress (
id TEXT PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL,
-- Tutorial completion state
tutorial_completed INTEGER NOT NULL DEFAULT 0, -- boolean
completed_at INTEGER, -- timestamp
-- Teacher override
teacher_override INTEGER NOT NULL DEFAULT 0, -- boolean
override_at INTEGER,
override_reason TEXT, -- e.g., "Learned in class with Kehkashan"
-- Metadata
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(player_id, skill_id)
);
CREATE INDEX idx_skill_tutorial_player ON skill_tutorial_progress(player_id);
```
### Schema Definition
```typescript
// src/db/schema/skill-tutorial-progress.ts
import { createId } from "@paralleldrive/cuid2";
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { players } from "./players";
export const skillTutorialProgress = sqliteTable(
"skill_tutorial_progress",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
skillId: text("skill_id").notNull(),
// Tutorial completion
tutorialCompleted: integer("tutorial_completed", { mode: "boolean" })
.notNull()
.default(false),
completedAt: integer("completed_at", { mode: "timestamp" }),
// Teacher override (bypasses tutorial requirement)
teacherOverride: integer("teacher_override", { mode: "boolean" })
.notNull()
.default(false),
overrideAt: integer("override_at", { mode: "timestamp" }),
overrideReason: text("override_reason"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
playerIdIdx: index("skill_tutorial_player_idx").on(table.playerId),
playerSkillUnique: uniqueIndex("skill_tutorial_player_skill_unique").on(
table.playerId,
table.skillId,
),
}),
);
```
---
## Next Skill Algorithm
Simple linear walk through curriculum: find the **first unmastered, unpracticed skill**.
### `getNextSkillToLearn(playerId)`
```typescript
// src/lib/curriculum/skill-unlock.ts
interface SkillSuggestion {
skillId: string;
phaseId: string;
phaseName: string;
description: string;
/** True if tutorial is already completed (or teacher override) */
tutorialReady: boolean;
}
/**
* Find the next skill the student should learn.
*
* Algorithm: Walk through curriculum phases in order.
* - If skill is MASTERED → skip (they know it)
* - If skill is PRACTICING → return null (they're working on it)
* - Otherwise → this is the next skill to learn
*/
export async function getNextSkillToLearn(
playerId: string,
): Promise<SkillSuggestion | null> {
// 1. Get mastered skills from BKT
const history = await getRecentSessionResults(playerId, 100);
const bktResults = computeBktFromHistory(history, {
confidenceThreshold: 0.3,
useCrossStudentPriors: false,
});
const masteredSkillIds = new Set(
bktResults.skills
.filter((s) => s.masteryClassification === "mastered")
.map((s) => s.skillId),
);
// 2. Get currently practicing skills
const practicing = await getPracticingSkills(playerId);
const practicingIds = new Set(practicing.map((s) => s.skillId));
// 3. Walk curriculum in order
for (const phase of ALL_PHASES) {
const skillId = phase.primarySkillId;
// Mastered? Skip - they know it
if (masteredSkillIds.has(skillId)) {
continue;
}
// Currently practicing? They're working on it - no new suggestion
if (practicingIds.has(skillId)) {
return null;
}
// Found first unmastered, unpracticed skill!
const tutorialProgress = await getSkillTutorialProgress(playerId, skillId);
const tutorialReady =
tutorialProgress?.tutorialCompleted ||
tutorialProgress?.teacherOverride ||
false;
return {
skillId,
phaseId: phase.id,
phaseName: phase.name,
description: phase.description,
tutorialReady,
};
}
// All phases complete - curriculum finished!
return null;
}
/**
* Get anomalies for teacher dashboard.
* Returns skills that are mastered but not in practice rotation.
*/
export async function getSkillAnomalies(playerId: string): Promise<
Array<{
skillId: string;
issue: "mastered_not_practicing" | "tutorial_skipped_repeatedly";
details: string;
}>
> {
const anomalies = [];
// Get mastered and practicing sets
const history = await getRecentSessionResults(playerId, 100);
const bktResults = computeBktFromHistory(history, {
confidenceThreshold: 0.3,
});
const masteredSkillIds = new Set(
bktResults.skills
.filter((s) => s.masteryClassification === "mastered")
.map((s) => s.skillId),
);
const practicing = await getPracticingSkills(playerId);
const practicingIds = new Set(practicing.map((s) => s.skillId));
// Find mastered but not practicing
for (const skillId of masteredSkillIds) {
if (!practicingIds.has(skillId)) {
anomalies.push({
skillId,
issue: "mastered_not_practicing" as const,
details: "Skill is mastered but not in practice rotation",
});
}
}
// TODO: Track tutorial skip count and flag repeated skips
return anomalies;
}
```
---
## Tutorial Launcher Component
### SkillTutorialLauncher
```typescript
// src/components/tutorial/SkillTutorialLauncher.tsx
interface SkillTutorialLauncherProps {
skillId: string
playerId: string
onComplete: () => void
onCancel: () => void
}
export function SkillTutorialLauncher({
skillId,
playerId,
onComplete,
onCancel,
}: SkillTutorialLauncherProps) {
const config = SKILL_TUTORIAL_CONFIGS[skillId]
if (!config) {
return <div>No tutorial available for {skillId}</div>
}
// Generate tutorial from config
const [currentProblemIndex, setCurrentProblemIndex] = useState(0)
const currentProblem = config.exampleProblems[currentProblemIndex]
// Generate instruction sequence for current problem
const sequence = useMemo(() => {
return generateUnifiedInstructionSequence(
currentProblem.start,
currentProblem.target
)
}, [currentProblem])
// Convert to tutorial steps
const tutorialSteps = useMemo(() => {
return sequence.steps.map((step, i) => ({
instruction: step.englishInstruction,
expectedValue: step.expectedValue,
expectedState: step.expectedState,
beadHighlights: step.beadMovements,
segment: sequence.segments.find(s => s.stepIndices.includes(i)),
}))
}, [sequence])
const handleProblemComplete = async () => {
if (currentProblemIndex < config.exampleProblems.length - 1) {
// More problems to go
setCurrentProblemIndex(i => i + 1)
} else {
// Tutorial complete!
await markTutorialComplete(playerId, skillId)
onComplete()
}
}
return (
<div data-component="skill-tutorial-launcher">
{/* Header with skill info */}
<header>
<h2>{config.title}</h2>
<p>{config.description}</p>
<div>
Problem {currentProblemIndex + 1} of {config.exampleProblems.length}
</div>
</header>
{/* Show the decomposition */}
<div data-section="decomposition">
<code>{sequence.fullDecomposition}</code>
</div>
{/* Show segment explanation if meaningful */}
{sequence.segments[0]?.readable && (
<div data-section="explanation">
<h3>{sequence.segments[0].readable.title}</h3>
<p>{sequence.segments[0].readable.summary}</p>
</div>
)}
{/* Interactive tutorial player */}
<TutorialPlayer
steps={tutorialSteps}
startValue={currentProblem.start}
targetValue={currentProblem.target}
onComplete={handleProblemComplete}
/>
{/* Cancel button */}
<button onClick={onCancel}>Cancel</button>
</div>
)
}
```
---
## UI Integration Points
### Primary Gate: Start Practice Modal
The tutorial happens BEFORE practice, not after. When a student sits down to practice,
that's when they learn the new skill - not when they're done and tired.
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ STUDENT CLICKS "START PRACTICE" │
│ ↓ │
│ │
│ CHECK: Is there a new skill ready to learn? │
│ (first unmastered, unpracticed skill in curriculum) │
│ AND tutorial not yet completed? │
│ │
│ ↓ ↓ │
│ YES NO │
│ ↓ ↓ │
│ │
│ START PRACTICE MODAL START PRACTICE MODAL │
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
│ │ Before we practice, │ │ Ready to practice? │ │
│ │ let's learn something │ │ │ │
│ │ new! │ │ [Start Session] │ │
│ │ │ └─────────────────────┘ │
│ │ +3 Five-Complement │ ↓ │
│ │ "Adding 3 using 5's │ │ │
│ │ friend" │ │ │
│ │ │ │ │
│ │ [Learn This First] │ │ │
│ │ [Skip for Now] │ │ │
│ └─────────────────────────┘ │ │
│ ↓ │ │
│ TUTORIAL │ │
│ (3 guided examples) │ │
│ ↓ │ │
│ Add to isPracticing │ │
│ ↓ │ │
│ └──────────────────────────────────┘ │
│ ↓ │
│ PRACTICE SESSION │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 1. Session Summary: Celebrate, Don't Assign
After a session, celebrate unlocks but DON'T make them do a tutorial - they're tired!
```
┌─────────────────────────────────────────┐
│ SESSION COMPLETE │
│ │
│ Great work today! │
│ │
│ ✓ 12 problems completed │
│ ✓ 83% accuracy │
│ │
│ ───────────────────────────────────── │
│ │
│ 🎉 You've unlocked a new skill! │
│ │
│ "+3 Five-Complement" is now │
│ available to learn. │
│ │
│ It'll be waiting for you next time! │
│ │
│ [Done] │
│ │
└─────────────────────────────────────────┘
```
No tutorial button. Just celebration.
### 2. Skills Dashboard (includes Teacher Anomalies pane)
Shows progression state with readiness indicator and teacher notes:
```
┌─────────────────────────────────────────┐
│ YOUR SKILLS │
│ │
│ Currently Practicing │
│ ─────────────────── │
│ ✓ +1 Direct (mastered) │
│ ✓ +2 Direct (mastered) │
│ ○ +3 Direct (learning - 65%) │
│ │
│ Ready to Learn │
│ ─────────────────── │
│ 📚 +4 Direct │
│ Start a session to learn this │
│ [Start Session with Tutorial] │
│ │
│ ───────────────────────────────────── │
│ │
│ ⚠️ Teacher Notes │
│ ─────────────────── │
│ • "basic.heavenBead" - mastered but │
│ not in practice rotation │
│ [Re-add] [Dismiss] │
│ │
│ • "+4 Direct" - tutorial skipped │
│ 3 times │
│ [Mark as learned] [Investigate] │
│ │
└─────────────────────────────────────────┘
```
The "Start Session with Tutorial" button goes straight to the tutorial, then into practice.
### 3. ManualSkillSelector (Teacher Override)
Add teacher override capability:
```tsx
// In ManualSkillSelector.tsx
function SkillRow({ skill, tutorialProgress, onToggle, onOverride }) {
const needsTutorial =
!tutorialProgress?.tutorialCompleted && !tutorialProgress?.teacherOverride;
return (
<div data-skill={skill.id}>
<input
type="checkbox"
checked={skill.isPracticing}
onChange={onToggle}
disabled={needsTutorial && !skill.isPracticing}
/>
<span>{skill.displayName}</span>
{needsTutorial && (
<span data-status="needs-tutorial">
📚 Needs tutorial
<button
onClick={() => onOverride(skill.id)}
title="Mark as learned offline"
>
Override
</button>
</span>
)}
{tutorialProgress?.teacherOverride && (
<span data-status="override">
Teacher override
{tutorialProgress.overrideReason && (
<span>({tutorialProgress.overrideReason})</span>
)}
</span>
)}
</div>
);
}
```
### UI Touchpoint Summary
| Touchpoint | What happens |
| ------------------------ | ------------------------------------------------------------------------------ |
| **Start Practice Modal** | PRIMARY GATE - Tutorial offered here before session starts |
| **Session Summary** | Celebrate unlock, no action required |
| **Skills Dashboard** | Shows readiness + teacher anomalies pane, offers "start session with tutorial" |
---
## Implementation Phases
### Phase 1: Data Foundation (1-2 hours)
- [ ] Create `skill_tutorial_progress` schema
- [ ] Create migration
- [ ] Add CRUD operations in `progress-manager.ts`
### Phase 2: Skill Tutorial Config (2-3 hours)
- [ ] Create `src/lib/curriculum/skill-tutorial-config.ts`
- [ ] Map all ~30 skills to example problems
- [ ] Add display names for skills
### Phase 3: Gap Detection (2-3 hours)
- [ ] Implement `computeUnlockSuggestions()`
- [ ] Implement `findHighestMasteredPhase()`
- [ ] Unit tests for gap detection scenarios:
- Normal progression (no gaps)
- Gap in five-complements
- Gap in basic skills
- Multiple gaps
### Phase 4: Tutorial Launcher (3-4 hours)
- [ ] Create `SkillTutorialLauncher` component
- [ ] Integrate with existing `TutorialPlayer`
- [ ] Handle tutorial completion tracking
- [ ] Test with various skill types
### Phase 5: UI Integration (2-3 hours)
- [ ] Add to Session Summary
- [ ] Create Skills Dashboard progression view
- [ ] Update ManualSkillSelector with tutorial gating
- [ ] Add teacher override modal
### Phase 6: Testing & Polish (2-3 hours)
- [ ] End-to-end flow testing
- [ ] Edge cases (no skills practicing, all mastered, etc.)
- [ ] Mobile responsiveness
- [ ] Accessibility review
---
## Test Scenarios
### Gap Detection Tests
```typescript
describe("Gap Detection", () => {
it("identifies gap when five-complement missing but ten-complement mastered", async () => {
// Setup: Student has mastered +7=10-3 but never learned -2=-5+3
await setMasteredSkill(playerId, "tenComplements.7=10-3");
// -2=-5+3 is in L1, should be unlocked before L2 ten-complements
const suggestions = await computeUnlockSuggestions(playerId);
expect(suggestions[0]).toMatchObject({
skillId: "fiveComplementsSub.-2=-5+3",
type: "gap",
});
});
it("suggests advancement when no gaps exist", async () => {
// Setup: All L1 skills mastered
await masterAllL1Skills(playerId);
const suggestions = await computeUnlockSuggestions(playerId);
expect(suggestions[0]).toMatchObject({
type: "advancement",
// First L2 skill
});
});
it("blocks advancement until all gaps filled", async () => {
// Setup: Two gaps exist
await setMasteredSkill(playerId, "tenComplements.9=10-1");
// Missing: basic.heavenBead and fiveComplements.3=5-2
const suggestions = await computeUnlockSuggestions(playerId);
// Should suggest gaps first, ordered by curriculum
expect(suggestions.length).toBe(2);
expect(suggestions[0].type).toBe("gap");
expect(suggestions[1].type).toBe("gap");
});
});
```
---
## Open Questions (Resolved)
| Question | Decision |
| ------------------------------------- | ------------------------------------------------------- |
| Gap-fill before advancement? | **STRICT** - Must fill all gaps before advancing |
| Auto-generated vs authored tutorials? | **AUTO** - Use `generateUnifiedInstructionSequence()` |
| Tutorial thoroughness? | **THOROUGH** - 3 guided examples with explanations |
| Teacher override? | **YES** - Teachers can mark skills as "learned offline" |
---
## Files to Create/Modify
### New Files
- `src/db/schema/skill-tutorial-progress.ts` - DB schema
- `drizzle/XXXX_skill_tutorial_progress.sql` - Migration
- `src/lib/curriculum/skill-tutorial-config.ts` - Skill → tutorial mapping
- `src/lib/curriculum/skill-unlock.ts` - Gap detection algorithm
- `src/components/tutorial/SkillTutorialLauncher.tsx` - Tutorial launcher
- `src/app/api/curriculum/[playerId]/tutorial-progress/route.ts` - API
### Modified Files
- `src/lib/curriculum/progress-manager.ts` - Add tutorial progress CRUD
- `src/components/practice/SessionSummary.tsx` - Add unlock prompts
- `src/components/practice/ManualSkillSelector.tsx` - Add tutorial gating
- `src/app/practice/[studentId]/skills/SkillsClient.tsx` - Add progression view
---
## Summary
This integration plan leverages the existing powerful tutorial system to create a seamless skill progression experience:
1. **BKT identifies mastery** → triggers unlock suggestion
2. **Gap detection ensures curriculum integrity** → prerequisites before advancement
3. **Dynamic tutorial generation** → no manual authoring needed
4. **Tutorial completion gates practice** → conceptual understanding before fluency drilling
5. **Teacher override available** → for offline learning scenarios
The key insight is that `generateUnifiedInstructionSequence()` already does all the heavy lifting for tutorial content. We just need to configure which problems demonstrate which skills and wire up the progression logic.

View File

@@ -358,16 +358,17 @@ export function DecompositionProvider({
**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'
"use client";
import { useDecomposition } from '@/contexts/DecompositionContext'
import { ReasonTooltip } from './ReasonTooltip' // Moved here
import './decomposition.css'
import { useDecomposition } from "@/contexts/DecompositionContext";
import { ReasonTooltip } from "./ReasonTooltip"; // Moved here
import "./decomposition.css";
export function DecompositionDisplay() {
const {
@@ -380,7 +381,7 @@ export function DecompositionDisplay() {
activeIndividualTermIndex,
handleTermHover,
getGroupTermIndicesFromTermIndex,
} = useDecomposition()
} = useDecomposition();
// ... rendering logic (adapted from DecompositionWithReasons)
}
@@ -406,6 +407,7 @@ function SegmentGroup({ segment, steps, ... }) {
### 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
@@ -491,21 +493,25 @@ src/
## 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
@@ -513,6 +519,7 @@ src/
## Testing Checklist
### Tutorial Mode
- [ ] Decomposition shows correctly for each step
- [ ] Current step is highlighted
- [ ] Term hover shows tooltip
@@ -521,6 +528,7 @@ src/
- [ ] 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
@@ -528,6 +536,7 @@ src/
- [ ] No console errors
### Edge Cases
- [ ] Single-digit addition (no meaningful decomposition)
- [ ] Multi-column carries
- [ ] Complement operations (five/ten complements)
@@ -536,24 +545,28 @@ src/
## 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 |
| 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)

View File

@@ -7,6 +7,7 @@
**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
@@ -28,11 +29,11 @@
## 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 |
| 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)
@@ -49,15 +50,15 @@
## 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 |
| 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

View File

@@ -0,0 +1,311 @@
# Plan: Migrate Dashboard to React Query
## Problem Statement
`DashboardClient.tsx` has 3 direct `fetch()` calls that bypass React Query:
1. `handleStartOver` - abandons session
2. `handleSaveManualSkills` - sets mastered skills
3. `handleRefreshSkill` - refreshes skill recency
These use `router.refresh()` to update data, but this doesn't work reliably because:
- `router.refresh()` re-runs server components but doesn't guarantee client state updates
- The React Query cache is not invalidated, so other components see stale data
- There's a race condition between navigation and data refresh
## Root Cause
`DashboardClient` receives data as **server-side props** and doesn't use React Query hooks:
```typescript
// Current: Props-based data
export function DashboardClient({
activeSession, // Server prop - stale after mutations
skills, // Server prop - stale after mutations
...
}: DashboardClientProps) {
```
Meanwhile, React Query mutations exist in `useSessionPlan.ts` and `usePlayerCurriculum.ts` but aren't used here.
## Solution: Use React Query Hooks with Server Props as Initial Data
### Pattern: Hydrate React Query from Server Props
```typescript
// New: Use hooks with server props as initial data
export function DashboardClient({
activeSession: initialActiveSession,
skills: initialSkills,
...
}: DashboardClientProps) {
// Use React Query with server props as initial data
const { data: activeSession } = useActiveSessionPlan(studentId, initialActiveSession)
// Use mutation instead of direct fetch
const abandonMutation = useAbandonSession()
const handleStartOver = useCallback(async () => {
if (!activeSession) return
setIsStartingOver(true)
try {
await abandonMutation.mutateAsync({ playerId: studentId, planId: activeSession.id })
router.push(`/practice/${studentId}/configure`)
} catch (error) {
console.error('Failed to start over:', error)
} finally {
setIsStartingOver(false)
}
}, [activeSession, studentId, abandonMutation, router])
```
## Implementation Steps
### Step 1: Add Missing React Query Mutation for Skills
**File:** `src/hooks/usePlayerCurriculum.ts`
The skills mutations (`setMasteredSkills`, `refreshSkillRecency`) aren't currently exported. Add them:
```typescript
/**
* Hook: Set mastered skills (manual skill management)
*/
export function useSetMasteredSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
playerId,
masteredSkillIds,
}: {
playerId: string;
masteredSkillIds: string[];
}) => {
const res = await api(`curriculum/${playerId}/skills`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ masteredSkillIds }),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.error || "Failed to set mastered skills");
}
return res.json();
},
onSuccess: (_, { playerId }) => {
// Invalidate curriculum to refetch skills
queryClient.invalidateQueries({
queryKey: curriculumKeys.detail(playerId),
});
},
});
}
/**
* Hook: Refresh skill recency (mark as recently practiced)
*/
export function useRefreshSkillRecency() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
playerId,
skillId,
}: {
playerId: string;
skillId: string;
}) => {
const res = await api(`curriculum/${playerId}/skills`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId }),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.error || "Failed to refresh skill");
}
return res.json();
},
onSuccess: (_, { playerId }) => {
queryClient.invalidateQueries({
queryKey: curriculumKeys.detail(playerId),
});
},
});
}
```
### Step 2: Update DashboardClient to Use React Query
**File:** `src/app/practice/[studentId]/dashboard/DashboardClient.tsx`
1. Add imports:
```typescript
import {
useAbandonSession,
useActiveSessionPlan,
} from "@/hooks/useSessionPlan";
import {
useSetMasteredSkills,
useRefreshSkillRecency,
} from "@/hooks/usePlayerCurriculum";
```
2. Use hooks with server props as initial data:
```typescript
export function DashboardClient({
studentId,
player,
curriculum,
skills,
recentSessions,
activeSession: initialActiveSession,
currentPracticingSkillIds,
problemHistory,
initialTab = 'overview',
}: DashboardClientProps) {
// Use React Query for active session (server prop as initial data)
const { data: activeSession } = useActiveSessionPlan(studentId, initialActiveSession)
// Mutations
const abandonMutation = useAbandonSession()
const setMasteredSkillsMutation = useSetMasteredSkills()
const refreshSkillMutation = useRefreshSkillRecency()
```
3. Replace direct fetch handlers:
```typescript
const handleStartOver = useCallback(async () => {
if (!activeSession) return;
setIsStartingOver(true);
try {
await abandonMutation.mutateAsync({
playerId: studentId,
planId: activeSession.id,
});
router.push(`/practice/${studentId}/configure`);
} catch (error) {
console.error("Failed to start over:", error);
} finally {
setIsStartingOver(false);
}
}, [activeSession, studentId, abandonMutation, router]);
const handleSaveManualSkills = useCallback(
async (masteredSkillIds: string[]) => {
await setMasteredSkillsMutation.mutateAsync({
playerId: studentId,
masteredSkillIds,
});
setShowManualSkillModal(false);
},
[studentId, setMasteredSkillsMutation],
);
const handleRefreshSkill = useCallback(
async (skillId: string) => {
await refreshSkillMutation.mutateAsync({
playerId: studentId,
skillId,
});
},
[studentId, refreshSkillMutation],
);
```
4. Remove router.refresh() calls - they're no longer needed.
### Step 3: Add Skills Query Hook (Optional Enhancement)
For full consistency, skills should also come from React Query. Add to `usePlayerCurriculum.ts`:
```typescript
export function usePlayerSkills(
playerId: string,
initialData?: PlayerSkillMastery[],
) {
return useQuery({
queryKey: [...curriculumKeys.detail(playerId), "skills"],
queryFn: async () => {
const res = await api(`curriculum/${playerId}`);
if (!res.ok) throw new Error("Failed to fetch curriculum");
const data = await res.json();
return data.skills as PlayerSkillMastery[];
},
initialData,
staleTime: initialData ? 30000 : 0,
});
}
```
Then in DashboardClient:
```typescript
const { data: skills } = usePlayerSkills(studentId, initialSkills);
```
### Step 4: Ensure QueryClient Provider Wraps Practice Pages
**File:** `src/app/practice/[studentId]/layout.tsx` (or similar)
Verify that `QueryClientProvider` is available. It should be in the root layout, but verify:
```typescript
// src/app/providers.tsx or similar
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: true,
},
},
})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
```
## Files to Modify
| File | Changes |
| ------------------------------------------------------------ | ---------------------------------------------------- |
| `src/hooks/usePlayerCurriculum.ts` | Add `useSetMasteredSkills`, `useRefreshSkillRecency` |
| `src/app/practice/[studentId]/dashboard/DashboardClient.tsx` | Use React Query hooks, remove direct fetch |
## Testing Checklist
- [ ] Click "Start Over" → session abandons, UI updates immediately
- [ ] Click "Start Over" → navigate to /configure works
- [ ] Click "Start Over" → if navigation fails, dashboard shows no active session
- [ ] Manage Skills → save changes → Skills tab updates immediately
- [ ] Refresh skill recency → skill card updates (staleness warning clears)
- [ ] Multiple browser tabs → mutation in one reflects in other after refocus
## Why This Works
1. **Server props hydrate React Query cache** - No loading flash on initial render
2. **Mutations update cache** - `abandonMutation.mutateAsync()` sets active session to `null`
3. **Components read from cache** - `useActiveSessionPlan` returns fresh data
4. **No router.refresh() needed** - React Query manages state, not Next.js
5. **Consistent across components** - Any component using these hooks sees the same data
## Rollout Risk
Low risk:
- Existing hooks already tested in other practice components
- Server props still provide initial data (no loading states)
- Incremental change - only DashboardClient affected

View File

@@ -1,138 +1,73 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)",
"WebFetch(domain:react-resizable-panels.vercel.app)",
"Bash(gh run watch:*)",
"Bash(npm run build:*)",
"Bash(NODE_ENV=production npm run build:*)",
"Bash(npx @pandacss/dev:*)",
"Bash(npm run build-storybook:*)",
"Bash(ssh nas.home.network:*)",
"Bash(python3:*)",
"Bash(curl:*)",
"WebSearch",
"WebFetch(domain:community.home-assistant.io)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:www.google.com)",
"Bash(gcloud auth list:*)",
"Bash(gcloud auth login:*)",
"Bash(gcloud projects list:*)",
"Bash(gcloud projects create:*)",
"Bash(gcloud config set:*)",
"Bash(gcloud services enable:*)",
"Bash(gcloud alpha services api-keys create:*)",
"Bash(gcloud components install:*)",
"Bash(chmod:*)",
"Bash(./fetch-streetview.sh:*)",
"Bash(xargs:*)",
"Bash(npx @biomejs/biome lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(npm run type-check:*)",
"Bash(npm run pre-commit:*)",
"Bash(git add:*)",
"Bash(npm info:*)",
"Bash(gh run list:*)",
"Bash(ssh:*)",
"Bash(git fetch:*)",
"Bash(npx tsc:*)",
"Bash(git commit -m \"$(cat <<''EOF''\ndocs: add comprehensive merge conflict resolution guide\n\nAdd detailed guide for intelligent diff3-style merge conflict resolution:\n- Explanation of diff3 format (OURS, BASE, THEIRS)\n- 5 resolution patterns with examples (Compatible, Redundant, Conflicting, Delete vs Modify, Rename + References)\n- zdiff3 modern alternative\n- Semantic merge concepts\n- Best practices and anti-patterns\n- Debugging guide for failed resolutions\n- Quick reference checklist\n\nThis guide helps resolve merge conflicts intelligently by understanding the intent of both sides'' changes.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit -m \"$(cat <<''EOF''\ndocs: add merge conflict resolution section to CLAUDE.md\n\nAdd quick reference section for merge conflict resolution:\n- Link to comprehensive guide (.claude/MERGE_CONFLICT_RESOLUTION.md)\n- Enable zdiff3 command\n- Quick resolution strategy summary\n- Reminder to test thoroughly after resolution\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit -m \"$(cat <<''EOF''\nchore: add auto-approvals for development commands\n\nAdd auto-approvals for common development workflow commands:\n- npm run type-check\n- npm run pre-commit \n- git add\n- npm info\n- npx tsc\n\nThese commands are safe to run automatically during development and code quality checks.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit:*)",
"Bash(/tmp/worksheet-preview-new.tsx)",
"Bash(npm run build:*)",
"Bash(curl:*)",
"Bash(pkill:*)",
"Bash(git rev-parse:*)",
"Bash(sqlite3:*)",
"Bash(gh run view:*)",
"Bash(gh run rerun:*)",
"Bash(git checkout:*)",
"Bash(scp:*)",
"Bash(rsync:*)",
"Bash(npm run format:*)",
"Bash(npm run lint:fix:*)",
"Bash(npm run lint)",
"mcp__sqlite__read_query",
"mcp__sqlite__describe_table",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npx @biomejs/biome:*)",
"Bash(git rev-parse:*)",
"Bash(gh run list:*)",
"Bash(npx biome:*)",
"WebFetch(domain:www.macintoshrepository.org)",
"WebFetch(domain:www.npmjs.com)",
"Bash(npm install:*)",
"Bash(pnpm add:*)",
"Bash(node -e:*)",
"Bash(npm search:*)",
"Bash(git revert:*)",
"Bash(pnpm remove:*)",
"Bash(gh run view:*)",
"Bash(pnpm install:*)",
"Bash(git checkout:*)",
"Bash(node server.js:*)",
"Bash(git fetch:*)",
"Bash(cat:*)",
"Bash(npm run test:run:*)",
"Bash(for:*)",
"Bash(do sleep 30)",
"Bash(echo:*)",
"Bash(done)",
"Bash(do sleep 120)",
"Bash(node --version)",
"Bash(docker run:*)",
"Bash(docker pull:*)",
"Bash(docker inspect:*)",
"Bash(docker system prune:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(docker logs:*)",
"Bash(docker exec:*)",
"Bash(node --input-type=module -e:*)",
"Bash(npm test:*)",
"Bash(npx tsx:*)",
"Bash(tsc:*)",
"Bash(npx @biomejs/biome check:*)",
"Bash(npx vitest:*)",
"Bash(ssh:*)",
"Bash(break)",
"Bash(node -e:*)",
"Bash(npm test:*)",
"Bash(npx @biomejs/biome format:*)",
"Bash(npm run lint:*)",
"WebFetch(domain:strudel.cc)",
"WebFetch(domain:club.tidalcycles.org)",
"Bash(git reset:*)",
"WebFetch(domain:abaci.one)",
"Bash(awk:*)",
"Bash(sort:*)",
"Bash(apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx )",
"Bash(apps/web/src/arcade-games/know-your-world/docs/MAPRENDERER_REFACTORING_PLAN.md )",
"Bash(apps/web/src/arcade-games/know-your-world/features/magnifier/index.ts )",
"Bash(apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierStyle.ts )",
"Bash(apps/web/src/arcade-games/know-your-world/features/cursor/ )",
"Bash(apps/web/src/arcade-games/know-your-world/features/interaction/ )",
"Bash(apps/web/src/arcade-games/know-your-world/utils/heatStyles.ts)",
"Bash(ping:*)",
"WebFetch(domain:typst.app)",
"WebFetch(domain:finemotormath.com)",
"WebFetch(domain:learnabacusathome.com)",
"WebFetch(domain:totton.idirect.com)",
"Bash(git rebase:*)",
"Bash(git stash:*)",
"Bash(git stash pop:*)",
"Bash(npx drizzle-kit:*)",
"Bash(npm run db:migrate:*)",
"mcp__sqlite__list_tables",
"Bash(sqlite3:*)",
"Bash(npx eslint:*)",
"Bash(src/hooks/useDeviceCapabilities.ts )",
"Bash(src/arcade-games/know-your-world/hooks/useDeviceCapabilities.ts )",
"Bash(src/components/practice/hooks/useDeviceDetection.ts )",
"Bash(src/arcade-games/memory-quiz/components/InputPhase.tsx )",
"Bash(src/app/api/curriculum/*/sessions/plans/route.ts)",
"Bash(src/app/api/curriculum/*/sessions/plans/*/route.ts)",
"Bash(src/components/practice/SessionSummary.tsx )",
"Bash(src/components/practice/ )",
"Bash(src/app/practice/ )",
"Bash(src/app/api/curriculum/ )",
"Bash(src/hooks/usePlayerCurriculum.ts )",
"Bash(src/hooks/useSessionPlan.ts )",
"Bash(src/lib/curriculum/ )",
"Bash(src/db/schema/player-curriculum.ts )",
"Bash(src/db/schema/player-skill-mastery.ts )",
"Bash(src/db/schema/practice-sessions.ts )",
"Bash(src/db/schema/session-plans.ts )",
"Bash(src/db/schema/index.ts )",
"Bash(src/types/tutorial.ts )",
"Bash(src/utils/problemGenerator.ts )",
"Bash(drizzle/ )",
"Bash(docs/DAILY_PRACTICE_SYSTEM.md )",
"Bash(../../README.md )",
"Bash(.claude/CLAUDE.md)",
"Bash(mcp__sqlite__describe_table:*)",
"Bash(ls:*)"
"mcp__sqlite__read_query",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(DEBUG_COST_CALCULATOR=true npx vitest:*)",
"Bash(DEBUG_SESSION_PLANNER=true npx vitest run:*)",
"Bash(tee:*)",
"Bash(cat:*)",
"Bash(npm install:*)",
"Bash(pnpm add:*)",
"Bash(npx tsx:*)",
"Bash(find:*)",
"Bash(node:*)",
"Bash(src/app/blog/\\[slug\\]/page.tsx )",
"Bash(src/components/blog/ValidationCharts.tsx )",
"Bash(src/lib/curriculum/bkt/compute-bkt.ts )",
"Bash(src/lib/curriculum/bkt/conjunctive-bkt.ts )",
"Bash(src/lib/curriculum/bkt/index.ts )",
"Bash(src/test/journey-simulator/JourneyRunner.ts )",
"Bash(src/test/journey-simulator/types.ts )",
"Bash(src/test/journey-simulator/blame-attribution.test.ts )",
"Bash(src/test/journey-simulator/__snapshots__/blame-attribution.test.ts.snap)",
"Bash(\"src/app/blog/[slug]/page.tsx\" )",
"Bash(\"src/components/blog/ValidationCharts.tsx\" )",
"Bash(\"src/lib/curriculum/bkt/compute-bkt.ts\" )",
"Bash(\"src/lib/curriculum/bkt/conjunctive-bkt.ts\" )",
"Bash(\"src/lib/curriculum/bkt/index.ts\" )",
"Bash(\"src/test/journey-simulator/JourneyRunner.ts\" )",
"Bash(\"src/test/journey-simulator/types.ts\" )",
"Bash(\"src/test/journey-simulator/blame-attribution.test.ts\" )",
"WebSearch",
"Bash(npm run format:check:*)",
"Bash(ping:*)",
"Bash(dig:*)"
],
"deny": [],
"ask": []

View File

@@ -1,8 +1,16 @@
import { AbacusDisplayProvider } from '@soroban/abacus-react'
import type { Preview } from '@storybook/nextjs'
import { NextIntlClientProvider } from 'next-intl'
import React from 'react'
import { ThemeProvider } from '../src/contexts/ThemeContext'
import tutorialEn from '../src/i18n/locales/tutorial/en.json'
import '../styled-system/styles.css'
// Merge messages for Storybook (add more as needed)
const messages = {
tutorial: tutorialEn.tutorial,
}
const preview: Preview = {
parameters: {
controls: {
@@ -15,7 +23,11 @@ const preview: Preview = {
decorators: [
(Story) => (
<ThemeProvider>
<Story />
<NextIntlClientProvider locale="en" messages={messages}>
<AbacusDisplayProvider>
<Story />
</AbacusDisplayProvider>
</NextIntlClientProvider>
</ThemeProvider>
),
],

View File

@@ -29,17 +29,17 @@ npm run pre-commit
### Components
| Component | Description |
|-----------|-------------|
| Component | Description |
| ----------------------------------------------------------------- | ---------------------------------------------------- |
| [Decomposition Display](./src/components/decomposition/README.md) | Interactive mathematical decomposition visualization |
| [Worksheet Generator](./src/app/create/worksheets/README.md) | Math worksheet creation with Typst PDF generation |
| [Worksheet Generator](./src/app/create/worksheets/README.md) | Math worksheet creation with Typst PDF generation |
### Games
| Game | Description |
|------|-------------|
| [Arcade System](./src/arcade-games/README.md) | Modular multiplayer game architecture |
| [Know Your World](./src/arcade-games/know-your-world/README.md) | Geography quiz game |
| Game | Description |
| --------------------------------------------------------------- | ------------------------------------- |
| [Arcade System](./src/arcade-games/README.md) | Modular multiplayer game architecture |
| [Know Your World](./src/arcade-games/know-your-world/README.md) | Geography quiz game |
### Developer Documentation

View File

@@ -0,0 +1,610 @@
---
title: "Binary Outcomes, Granular Insights: How We Know Which Abacus Skill Needs Work"
description: "How we use conjunctive Bayesian Knowledge Tracing to infer which visual-motor patterns a student has automated when all we observe is 'problem correct' or 'problem incorrect'."
author: "Abaci.one Team"
publishedAt: "2025-12-14"
updatedAt: "2025-12-16"
tags:
[
"education",
"machine-learning",
"bayesian",
"soroban",
"knowledge-tracing",
"adaptive-learning",
]
featured: true
---
# Binary Outcomes, Granular Insights: How We Know Which Abacus Skill Needs Work
> **Abstract:** Soroban (Japanese abacus) pedagogy treats arithmetic as a sequence of visual-motor patterns to be drilled to automaticity. Each numeral operation (adding 1, adding 2, ...) in each column context is a distinct pattern; curricula explicitly sequence these patterns, requiring mastery of each before introducing the next. This creates a well-defined skill hierarchy of ~30 discrete patterns. We apply conjunctive Bayesian Knowledge Tracing to infer pattern mastery from binary problem outcomes. At problem-generation time, we simulate the abacus to tag each term with the specific patterns it exercises. Correct answers provide evidence for all tagged patterns; incorrect answers distribute blame proportionally to each pattern's estimated weakness. BKT drives both skill targeting (prioritizing weak skills for practice) and difficulty adjustment (scaling problem complexity to mastery level). Simulation studies suggest that adaptive targeting may reach mastery 25-33% faster than uniform skill distribution, though real-world validation with human learners is ongoing. Our 3-way comparison found that the benefit comes from BKT _targeting_, not the specific cost formula—using BKT for both concerns simplifies the architecture with no performance cost.
---
Soroban (Japanese abacus) pedagogy structures arithmetic as a sequence of visual-motor patterns. Each numeral operation in each column context is a distinct pattern to be drilled until automatic. Curricula explicitly sequence these patterns—master adding 1 before adding 2, master five's complements before ten's complements—creating a well-defined hierarchy of ~30 discrete skills.
This structure creates both an opportunity and a challenge for adaptive practice software. The opportunity: we know exactly which patterns each problem exercises. The challenge: when a student answers incorrectly, we observe only a binary outcome—**correct** or **incorrect**—but need to infer which of several patterns failed.
This post describes how we solve this inference problem using **Conjunctive Bayesian Knowledge Tracing (BKT)**, applied to the soroban's well-defined pattern hierarchy.
## Context-Dependent Patterns
On a soroban, adding "+4" isn't a single pattern. It's one of several distinct visual-motor sequences depending on the current state of the abacus column.
A soroban column has 4 earth beads and 1 heaven bead (worth 5). The earth beads that are "up" (toward the reckoning bar) contribute to the displayed value. When we say "column shows 3," that means 3 earth beads are already up—leaving only 1 earth bead available to push up.
**Scenario 1: Column shows 0**
- Earth beads available: 4 (none are up yet)
- To add 4: Push 4 earth beads up directly
- **Skill exercised**: `basic.directAddition`
**Scenario 2: Column shows 3**
- Earth beads available: 1 (3 are already up)
- To add 4: Can't push 4 beads directly—only 1 is available!
- Operation: Lower the heaven bead (+5), then raise 1 earth bead back (-1)
- **Skill exercised**: `fiveComplements.4=5-1`
**Scenario 3: Column shows 7**
- Column state: Heaven bead is down (5), 2 earth beads are up (5+2=7)
- To add 4: Result would be 11—overflows the column!
- Operation: Add 10 to the next column (carry), subtract 6 from this column
- **Skill exercised**: `tenComplements.4=10-6`
The same term "+4" requires completely different finger movements and visual patterns depending on the abacus state. A student who has automated `basic.directAddition` might still struggle with `tenComplements.4=10-6`—these are distinct patterns that must be drilled separately.
## The Soroban Pattern Hierarchy
Soroban curricula organize patterns into a strict progression, where each level must be mastered before advancing. We model this as approximately 30 distinct patterns:
### Basic Patterns (Complexity 0)
Direct bead manipulations—the foundation that must be automatic before advancing:
- `basic.directAddition` — Push 1-4 earth beads up
- `basic.directSubtraction` — Pull 1-4 earth beads down
- `basic.heavenBead` — Lower the heaven bead (add 5)
- `basic.heavenBeadSubtraction` — Raise the heaven bead (subtract 5)
- `basic.simpleCombinations` — Add 6-9 using earth + heaven beads together
### Five-Complement Patterns (Complexity 1)
Single-column patterns involving the heaven bead threshold—introduced only after basic patterns are automatic:
- `fiveComplements.4=5-1` — "Add 4" becomes "add 5, subtract 1"
- `fiveComplements.3=5-2` — "Add 3" becomes "add 5, subtract 2"
- `fiveComplements.2=5-3` — "Add 2" becomes "add 5, subtract 3"
- `fiveComplements.1=5-4` — "Add 1" becomes "add 5, subtract 4"
And the corresponding subtraction variants (`fiveComplementsSub.*`).
### Ten-Complement Patterns (Complexity 2)
Multi-column patterns involving carries and borrows—the final major category:
- `tenComplements.9=10-1` — "Add 9" becomes "carry 10, subtract 1"
- `tenComplements.8=10-2` — "Add 8" becomes "carry 10, subtract 2"
- ... through `tenComplements.1=10-9`
And the corresponding subtraction variants (`tenComplementsSub.*`).
### Mixed/Advanced Patterns (Complexity 3)
Cascading operations where carries or borrows propagate across multiple columns (e.g., 999 + 1 = 1000).
## Simulation-Based Pattern Tagging
At problem-generation time, we simulate the abacus to determine which patterns each term will exercise. This is more precise than tagging at the problem-type level (e.g., "all +4 problems use skill X")—we tag at the problem-instance level based on the actual column states encountered.
```
Problem: 7 + 4 + 2 = 13
Step 1: Start with 0, add 7
Column state: ones=0 → ones=7
Analysis: Adding 6-9 requires moving both heaven bead and earth beads together
Patterns: [basic.simpleCombinations]
Step 2: From 7, add 4
Column state: ones=7 → overflow!
Analysis: 7 + 4 = 11, exceeds column capacity (max 9)
Rule: Ten-complement (+10, -6)
Patterns: [tenComplements.4=10-6]
Step 3: From 11 (ones=1, tens=1), add 2
Column state: ones=1 → ones=3
Analysis: Only 1 earth bead is up; room to push 2 more
Patterns: [basic.directAddition]
Total patterns exercised: [basic.simpleCombinations, basic.directAddition, tenComplements.4=10-6]
```
This simulation happens at problem-generation time. The generated problem carries its pattern tags explicitly—static once generated, but computed precisely for this specific problem instance:
```typescript
interface GeneratedProblem {
terms: number[]; // [7, 4, 2]
answer: number; // 13
patternsExercised: string[]; // ['basic.simpleCombinations', 'basic.directAddition', 'tenComplements.4=10-6']
}
```
## The Inference Challenge
Now consider what happens when the student solves this problem:
**Observation**: Student answered **incorrectly**.
**Patterns involved**: `basic.simpleCombinations`, `basic.directAddition`, `tenComplements.4=10-6`
**The question**: Which pattern failed?
We have three possibilities:
1. The student made an error on the simple combination (adding 7)
2. The student made an error on the direct addition (adding 2)
3. The student made an error on the ten-complement operation (adding 4 via carry)
But we can't know for certain. All we observe is the binary outcome.
### Asymmetric Evidence
Here's a crucial insight:
**If the student answers correctly**, we have strong evidence that **all** patterns were executed successfully. You can't get the right answer if any pattern fails.
**If the student answers incorrectly**, we only know that **at least one** pattern failed. We don't know which one(s).
This asymmetry is fundamental to our inference approach.
## Conjunctive Bayesian Knowledge Tracing
Standard BKT (Bayesian Knowledge Tracing) models a single skill as a hidden Markov model:
- Hidden state: Does the student know the skill? (binary)
- Observation: Did the student answer correctly? (binary)
- Parameters: P(L₀) initial knowledge, P(T) learning rate, P(S) slip rate, P(G) guess rate
The update equations use Bayes' theorem:
```
P(known | correct) = P(correct | known) × P(known) / P(correct)
= (1 - P(slip)) × P(known) / P(correct)
P(known | incorrect) = P(incorrect | known) × P(known) / P(incorrect)
= P(slip) × P(known) / P(incorrect)
```
### Extension to Multi-Pattern Problems
For problems involving multiple patterns, we extend BKT with a **conjunctive model**:
**On a correct answer**: All patterns receive positive evidence. We update each pattern independently using the standard BKT correct-answer update.
**On an incorrect answer**: We distribute "blame" probabilistically. Patterns that the student is less likely to have automated receive more of the blame.
The blame distribution formula:
```
blame(pattern) ∝ (1 - P(known_pattern))
```
A pattern with P(known) = 0.3 gets more blame than a pattern with P(known) = 0.9. This is intuitive: if a student has demonstrated automaticity of a pattern many times, an error is less likely to be caused by that pattern.
### The Blame-Weighted Update
For each pattern in an incorrect multi-pattern problem:
```typescript
// Calculate blame weights
const totalUnknown = patterns.reduce((sum, p) => sum + (1 - p.pKnown), 0);
const blameWeight = (1 - pattern.pKnown) / totalUnknown;
// Calculate what the full negative update would be
const fullNegativeUpdate = bktUpdate(pattern.pKnown, false, params);
// Apply a weighted blend: more blame → more negative update
const newPKnown =
pattern.pKnown * (1 - blameWeight) + fullNegativeUpdate * blameWeight;
```
This creates a soft attribution: patterns that likely caused the error receive stronger negative evidence, while patterns that are probably automated receive only weak negative evidence.
### Edge Case: All Patterns Automated
What if all patterns have high P(known)? Then the error is probably a **slip** (random error despite knowledge), and we distribute blame evenly:
```typescript
if (totalUnknown < 0.001) {
// All patterns appear automated — must be a slip
const evenWeight = 1 / patterns.length;
// Apply full negative update with even distribution
}
```
### Methodological Note: Heuristic vs. True Bayesian Inference
The blame distribution formula above is a **heuristic approximation**, not proper Bayesian inference. True conjunctive BKT would compute the posterior probability that each skill is unknown given the failure:
```
P(¬known_i | fail) = P(fail ∧ ¬known_i) / P(fail)
```
This requires marginalizing over all 2^n possible knowledge states—computationally tractable for n ≤ 6 skills (our typical case), but more complex to implement.
We validated both approaches using our journey simulator across 5 random seeds and 3 learner profiles:
| Method | Mean BKT-Truth Correlation | Wins |
| ------------------ | -------------------------- | ---- |
| Heuristic (linear) | 0.394 | 3/5 |
| Bayesian (exact) | 0.356 | 2/5 |
| **t-test** | t = -0.41, **p > 0.05** | |
<!-- CHART: BlameAttribution -->
**Result**: No statistically significant difference. The heuristic's softer blame attribution appears equally effective—possibly more robust to the noise inherent in learning dynamics.
We retain the Bayesian implementation for reproducibility and potential future research ([source code](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/lib/curriculum/bkt/conjunctive-bkt.ts)), but the production system uses the simpler heuristic. Full validation data is available in our [blame attribution test suite](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/test/journey-simulator/blame-attribution.test.ts).
## Evidence Quality Modifiers
Not all observations are equally informative. We weight the evidence based on help level and response time.
<!-- CHART: EvidenceQuality -->
## Automaticity-Aware Problem Generation
Problem generation involves two concerns:
1. **Skill targeting** (BKT-based): Identifies which skills need practice and prioritizes them
2. **Cost calculation**: Controls problem difficulty by budgeting cognitive load
Both concerns now use BKT. We experimented with separating them—using BKT only for targeting while using fluency (recent streak consistency) for cost calculation—but found that using BKT for both produces equivalent results while simplifying the architecture.
### Complexity Budgeting
We budget problem complexity based on the student's estimated mastery from BKT. When BKT confidence is low (< 30%), we fall back to fluency-based estimates.
### Complexity Costing
Each pattern has a **base complexity cost**:
- Basic patterns: 0 (trivial)
- Five-complement patterns: 1 (one mental decomposition)
- Ten-complement patterns: 2 (cross-column operation)
- Mixed/cascading: 3 (multi-column propagation)
### Automaticity Multipliers
The cost is scaled by the student's estimated mastery from BKT. The multiplier uses a non-linear (squared) mapping from P(known) to provide better differentiation at high mastery levels. When BKT confidence is insufficient (< 30%), we fall back to discrete fluency states based on recent streaks.
<!-- CHART: AutomaticityMultipliers -->
### Adaptive Session Planning
A practice session has a **complexity budget**. The problem generator:
1. Selects terms that exercise the target patterns for the current curriculum phase
2. Simulates the problem to extract actual patterns exercised
3. Calculates total complexity: Σ(base_cost × automaticity_multiplier) for each pattern
4. Accepts the problem only if it fits the session's complexity budget
This creates natural adaptation:
- A student who has automated ten-complements gets harder problems (their multiplier is low)
- A student still learning ten-complements gets simpler problems (their multiplier is high)
```typescript
// Same problem, different complexity for different students:
const problem = [7, 6] // 7 + 6 = 13, requires tenComplements.6
// Student A: BKT P(known) = 0.95 for ten-complements
complexity_A = 2 × 1.3 = 2.6 // Easy for this student
// Student B: BKT P(known) = 0.50 for ten-complements
complexity_B = 2 × 3.3 = 6.6 // Challenging for this student
```
## Adaptive Skill Targeting
Beyond controlling difficulty, BKT identifies _which skills need practice_.
### Identifying Weak Skills
When planning a practice session, we analyze BKT results to find skills that are:
- **Confident**: The model has enough data (confidence ≥ 30%)
- **Weak**: The estimated P(known) is below threshold (< 50%)
```typescript
function identifyWeakSkills(bktResults: Map<string, BktResult>): string[] {
const weakSkills: string[] = [];
for (const [skillId, result] of bktResults) {
if (result.confidence >= 0.3 && result.pKnown < 0.5) {
weakSkills.push(skillId);
}
}
return weakSkills;
}
```
The confidence threshold prevents acting on insufficient data. A skill practiced only twice might show low P(known), but we don't have enough evidence to trust that estimate.
### Targeting Weak Skills in Problem Generation
Identified weak skills are added to the problem generator's `targetSkills` constraint. This biases problem generation toward exercises that include the weak pattern—not by making problems easier, but by ensuring the student gets practice on what they need.
```typescript
// In session planning:
const weakSkills = identifyWeakSkills(bktResults);
// Add weak skills to focus slot targets
for (const slot of focusSlots) {
slot.targetSkills = [...slot.targetSkills, ...weakSkills];
}
```
### The Budget Trap (and How We Avoided It)
When we first tried using BKT P(known) as a cost multiplier, we hit a problem: skills with low P(known) got high multipliers, making them expensive. If we only used cost filtering, the budget would exclude weak skills—students would never practice what they needed most.
The solution was **skill targeting**: BKT identifies weak skills and adds them to the problem generator's required targets. This ensures weak skills appear in problems _regardless_ of their cost. The complexity budget still applies, but it filters problem _structure_ (number of terms, digit ranges), not which skills can appear.
A student struggling with ten-complements gets problems that _include_ ten-complements (targeting), while the problem complexity stays within their budget (fewer terms, simpler starting values).
## Honest Uncertainty Reporting
Our system explicitly tracks and reports confidence alongside skill estimates.
### Confidence Calculation
Confidence increases with more data and more consistent observations:
```typescript
function calculateConfidence(
opportunities: number,
successRate: number,
): number {
// More data → more confidence (asymptotic to 1)
const dataConfidence = 1 - Math.exp(-opportunities / 20);
// Extreme success rates → more confidence
const extremity = Math.abs(successRate - 0.5) * 2;
const consistencyBonus = extremity * 0.2;
return Math.min(1, dataConfidence + consistencyBonus);
}
```
With 10 opportunities, we're ~40% confident. With 50 opportunities, we're ~92% confident.
### Uncertainty Ranges
We display P(known) with an uncertainty range that widens as confidence decreases:
```
Pattern: tenComplements.4=10-6
Estimated automaticity: ~73%
Confidence: moderate
Range: 58% - 88%
```
This honest framing prevents over-claiming. A "73% automaticity" with low confidence is very different from "73% automaticity" with high confidence.
### Staleness Indicators
We track when each pattern was last practiced and display warnings:
| Days Since Practice | Warning |
| ------------------- | ------------------------------ |
| < 7 | (none) |
| 7-14 | "Not practiced recently" |
| 14-30 | "Getting rusty" |
| > 30 | "Very stale — may need review" |
Importantly, we show staleness as a **separate indicator**, not by decaying P(known). The student might still have the pattern automated; we just haven't observed it recently.
## Architecture: Lazy Computation
A key architectural decision: we don't store BKT state persistently. Instead, we:
1. Store raw problem results (correct/incorrect, timestamp, response time, help level)
2. Compute BKT on-demand when viewing the skills dashboard
3. Replay history chronologically to build up current P(known) estimates
This has several advantages:
- No database migrations when we tune BKT parameters
- Can experiment with different algorithms without data loss
- User controls (confidence threshold slider) work instantly
- Estimated computation time: ~50ms for a full dashboard with 100+ problems
## Automaticity Classification
Once we have a P(known) estimate with sufficient confidence, we classify each skill into one of three zones:
- **Struggling** (P(known) < 50%): The student likely hasn't internalized this pattern yet. Problems using this skill will feel difficult and error-prone.
- **Learning** (P(known) 50-80%): The student is developing competence but hasn't achieved automaticity. They can usually get it right but need to think about it.
- **Automated** (P(known) > 80%): The pattern is internalized. The student can apply it quickly and reliably without conscious effort.
The confidence threshold is user-adjustable (default 50%), allowing teachers to be more or less strict about what counts as "confident enough to classify." Skills with insufficient data remain in "Learning" until more evidence accumulates.
<!-- CHART: Classification -->
## Skill-Specific Difficulty Model
Not all soroban patterns are equally difficult to master. Our student simulation model incorporates **skill-specific difficulty multipliers** based on pedagogical observation:
- **Basic skills** (direct bead manipulation): Easiest to master, multiplier 0.8-0.9x
- **Five-complements** (single-column decomposition): Moderate difficulty, multiplier 1.2-1.3x
- **Ten-complements** (cross-column carrying): Hardest, multiplier 1.6-2.1x
These multipliers affect the Hill function's K parameter (the exposure count where P(correct) = 50%). A skill with multiplier 2.0x requires twice as many practice exposures to reach the same mastery level.
The interactive charts below show how these difficulty multipliers affect learning trajectories. Data is derived from validated simulation tests ([source code](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/test/journey-simulator/skill-difficulty.test.ts)).
<!-- CHART: SkillDifficulty -->
## Validation: Does Adaptive Targeting Actually Work?
We built a journey simulator to compare three modes across controlled scenarios:
- **Classic**: Uniform skill distribution, fluency-based difficulty
- **Adaptive (fluency)**: BKT skill targeting, fluency-based difficulty
- **Adaptive (full BKT)**: BKT skill targeting, BKT-based difficulty
### Simulation Framework
The simulator models student learning using:
- **Hill function learning model**: `P(correct) = exposure^n / (K^n + exposure^n)`, where exposure is the number of times the student has practiced a skill
- **Conjunctive model**: Multi-skill problems require all skills to succeed—P(correct) is the product of individual skill probabilities
- **Per-skill deficiency profiles**: Each test case starts one skill at zero exposure, with all prerequisites mastered
- **Cognitive fatigue tracking**: Sum of difficulty multipliers for each skill in each problem—measures the mental effort required per session
The Hill function creates realistic learning curves: early practice yields slow improvement (building foundation), then understanding "clicks" (rapid gains), then asymptotic approach to mastery.
### The Measurement Challenge
Our first validation attempt measured overall problem accuracy—but this penalized adaptive mode for doing its job. When adaptive generates problems targeting weak skills, those problems have lower P(correct) by design.
The solution: **per-skill assessment without learning**. After practice sessions, we assess each student's mastery of the originally-deficient skill using trials that don't increment exposure. This measures true mastery independent of problem selection effects.
```typescript
// Assessment that doesn't pollute learning state
assessSkill(skillId: string, trials: number = 20): SkillAssessment {
const trueProbability = this.getTrueProbability(skillId)
// Run trials WITHOUT incrementing exposure
let correct = 0
for (let i = 0; i < trials; i++) {
if (this.rng.chance(trueProbability)) correct++
}
return { skillId, trueProbability, assessedAccuracy: correct / trials }
}
```
### Convergence Speed Results
The key question: How fast does each mode bring a weak skill to mastery? The data below is generated from our journey simulator test suite ([source code](https://github.com/antialias/soroban-abacus-flashcards/blob/main/apps/web/src/test/journey-simulator/journey-simulator.test.ts)).
<!-- CHART: ValidationResults -->
### 3-Way Comparison: BKT vs Fluency Multipliers
We also compared whether using BKT for cost calculation (in addition to targeting) provides additional benefit over fluency-based cost calculation.
<!-- CHART: ThreeWayComparison -->
### Why Adaptive Wins
The mechanism is straightforward:
1. BKT identifies skills with low P(known) and sufficient confidence
2. These skills are added to `targetSkills` in problem generation
3. The student gets more exposure to weak skills
4. More exposure → faster mastery (via Hill function)
In our simulations, adaptive mode provided ~5% more exposure to deficient skills on average. This modest increase compounds across sessions into significant mastery differences.
### Remaining Research Questions
1. **Real-world validation**: Do simulated results hold with actual students?
2. **Optimal thresholds**: Are P(known) < 0.5 and confidence ≥ 0.3 the right cutoffs?
3. **Targeting aggressiveness**: Should we weight weak skills more heavily in generation?
4. **Cross-student priors**: Can aggregate data improve initial estimates for new students?
If you're interested in the educational data mining aspects of this work, [reach out](mailto:contact@abaci.one).
## Limitations
### Simulation-Only Validation
The validation results reported here are derived entirely from **simulated students**, not human learners. Our simulator assumes:
- **Hill function learning curves**: Mastery probability increases with exposure according to `P = exposure^n / (K^n + exposure^n)`. Real students may exhibit plateau effects, regression, or non-monotonic learning.
- **Probabilistic slips**: Errors on mastered skills are random with fixed probability. Real errors may reflect systematic misconceptions that BKT handles poorly.
- **Independent skill application**: The conjunctive model assumes each skill is applied independently within a problem.
The "25-33% faster mastery" finding should be interpreted as: _given students who learn according to our model assumptions, adaptive targeting accelerates simulated progress_. Whether this transfers to human learners remains an open empirical question.
### The Technique Bypass Problem
BKT infers skill mastery from answer correctness, but correct answers don't guarantee proper technique. A student might:
- Use mental arithmetic instead of bead manipulation
- Count on fingers rather than applying complement rules
- Arrive at correct answers through inefficient multi-step processes
Our system cannot distinguish "correct via proper abacus technique" from "correct via alternative method." This is partially mitigated by:
- **Response time**: Properly automated technique should be faster than mental workarounds
- **Visualization mode**: When students use the on-screen abacus, we observe their actual bead movements
- **Pattern complexity**: Higher-digit problems are harder to solve via mental math, making technique bypass less viable
Definitive detection of technique usage would require video analysis or teacher observation—areas for future integration.
### Independent Failure Assumption
The blame attribution formula treats skill failures as independent parallel events:
```
blame(skill_i) ∝ (1 - P(known_i))
```
In reality, foundational skill failures may trigger cognitive cascades. If a student fails `basic.directAddition`, they may become confused and subsequently fail `fiveComplements` even if they "know" it. Our model cannot distinguish:
- "Failed because didn't know the complement rule"
- "Failed because earlier confusion disrupted working memory"
This is a known limitation of standard BKT. More sophisticated models (e.g., Deep Knowledge Tracing, or models with prerequisite dependencies) could potentially capture these effects, at the cost of interpretability and sample efficiency.
## Why We Built This (And What's Next)
This research was conducted to validate the core idea of **skill-targeted problem generation** before deploying it in [abaci.one](https://abaci.one)—an automatic proctoring system designed to run soroban practice sessions without requiring constant teacher supervision.
The simulation results gave us confidence that the approach is sound in principle. We've now deployed these algorithms in the live system, which is designed to collect detailed data from every practice session:
- Problem-by-problem response times and correctness
- Help usage patterns (hints, decomposition views, full solutions)
- Skill exposure sequences and mastery trajectories
- Session-level fatigue and engagement indicators
**We plan to publish a follow-up analysis** once we've collected sufficient data from real students. This will let us answer the questions our simulator cannot:
- Do real students learn according to Hill-like curves, or something else?
- Does adaptive targeting actually accelerate mastery in practice?
- How accurate are our BKT estimates compared to teacher assessments?
- What failure modes emerge that our simulation didn't anticipate?
Until then, the claims in this post should be understood as _validated in simulation, pending real-world confirmation_.
## Summary
Building an intelligent tutoring system for soroban arithmetic required solving a fundamental inference problem: how do you know which pattern failed when you only observe binary problem outcomes?
Our approach combines:
1. **Simulation-based pattern tagging** at problem-generation time
2. **Conjunctive BKT** with probabilistic blame distribution
3. **Evidence quality weighting** based on help level and response time
4. **Unified BKT architecture**: BKT drives both difficulty adjustment and skill targeting
5. **Honest uncertainty reporting** with confidence intervals
6. **Simulation-validated adaptive targeting** that may reach mastery 25-33% faster than uniform practice (pending real-world confirmation)
The key insight from our simulation studies: the benefit of adaptive practice comes from _targeting weak skills_, not from the specific formula used for difficulty adjustment. BKT targeting ensures students practice what they need; the complexity budget ensures they're not overwhelmed.
The result is a system that adapts to each student's actual pattern automaticity, not just their overall accuracy—focusing practice where it matters most while honestly communicating what it knows and doesn't know.
---
_This post describes the pattern tracing system built into [abaci.one](https://abaci.one), a free soroban practice application. The full source code is available on [GitHub](https://github.com/antialias/soroban-abacus-flashcards)._
## References
- Corbett, A. T., & Anderson, J. R. (1994). Knowledge tracing: Modeling the acquisition of procedural knowledge. _User Modeling and User-Adapted Interaction_, 4(4), 253-278.
- Pardos, Z. A., & Heffernan, N. T. (2011). KT-IDEM: Introducing item difficulty to the knowledge tracing model. In _International Conference on User Modeling, Adaptation, and Personalization_ (pp. 243-254). Springer.
- Baker, R. S., Corbett, A. T., & Aleven, V. (2008). More accurate student modeling through contextual estimation of slip and guess probabilities in Bayesian knowledge tracing. In _International Conference on Intelligent Tutoring Systems_ (pp. 406-415). Springer.

View File

@@ -16,6 +16,7 @@ Operations that don't require carrying/borrowing across columns.
**Addition (+1 through +9)**
For each number, practice in this order:
1. **Without friends of 5**: Direct bead movements only
- e.g., `2 + 1 = 3` (just move earth beads)
2. **With friends of 5**: Using the 5-complement technique
@@ -23,6 +24,7 @@ For each number, practice in this order:
**Subtraction (-9 through -1)**
For each number, practice in this order:
1. **Without friends of 5**: Direct bead movements only
- e.g., `7 - 2 = 5` (just remove earth beads)
2. **With friends of 5**: Using the 5-complement technique
@@ -34,6 +36,7 @@ Addition that requires carrying to the next column.
**Addition (+1 through +9)**
For each number:
1. **Without friends of 5**: Pure 10-complement
- e.g., `5 + 7 = 12` → needs `-3, +10` (no 5-bead manipulation in ones)
2. **With friends of 5**: Combined 10-complement and 5-complement
@@ -45,6 +48,7 @@ Subtraction that requires borrowing from the next column.
**Subtraction (-9 through -1)**
For each number:
1. **Without friends of 5**: Pure 10-complement
- e.g., `12 - 7 = 5` → needs `+3, -10`
2. **With friends of 5**: Combined 10-complement and 5-complement
@@ -60,26 +64,26 @@ For each number:
### What We Have
| Component | Location | Can Leverage |
|-----------|----------|--------------|
| Problem generator | `src/utils/problemGenerator.ts` | ✅ Core logic exists |
| Skill analysis | `analyzeColumnAddition()` | ✅ Pattern to follow |
| SkillSet types | `src/types/tutorial.ts` | ✅ Has 5/10 complements |
| Practice player | `src/components/tutorial/PracticeProblemPlayer.tsx` | ✅ UI exists |
| Constraint system | `requiredSkills`, `targetSkills`, `forbiddenSkills` | ✅ Ready to use |
| Component | Location | Can Leverage |
| ----------------- | --------------------------------------------------- | ----------------------- |
| Problem generator | `src/utils/problemGenerator.ts` | ✅ Core logic exists |
| Skill analysis | `analyzeColumnAddition()` | ✅ Pattern to follow |
| SkillSet types | `src/types/tutorial.ts` | ✅ Has 5/10 complements |
| Practice player | `src/components/tutorial/PracticeProblemPlayer.tsx` | ✅ UI exists |
| Constraint system | `allowedSkills`, `targetSkills`, `forbiddenSkills` | ✅ Ready to use |
### What We Need to Add
| Feature | Description | File(s) to Modify | Status |
|---------|-------------|-------------------|--------|
| Subtraction skill analysis | `analyzeColumnSubtraction()` | `src/utils/problemGenerator.ts` | ✅ Done |
| Subtraction in SkillSet | Add subtraction-specific skills | `src/types/tutorial.ts` | ✅ Done |
| Curriculum definitions | Level 1/2/3 PracticeStep configs | New: `src/curriculum/` | ⏳ Pending |
| Visualization mode | Hide abacus option | `PracticeProblemPlayer.tsx` | ⏳ Pending |
| Adaptive mastery | Continue until N consecutive correct | New logic | ⏳ Pending |
| Progress persistence | Track technique mastery | Database/localStorage | ⏳ Pending |
| **Student profiles** | Extend players with curriculum progress | New DB tables | ✅ Done |
| **Student selection UI** | Pick student before practice | `src/components/practice/` | ✅ Done |
| Feature | Description | File(s) to Modify | Status |
| -------------------------- | --------------------------------------- | ------------------------------- | ---------- |
| Subtraction skill analysis | `analyzeColumnSubtraction()` | `src/utils/problemGenerator.ts` | ✅ Done |
| Subtraction in SkillSet | Add subtraction-specific skills | `src/types/tutorial.ts` | ✅ Done |
| Curriculum definitions | Level 1/2/3 PracticeStep configs | New: `src/curriculum/` | ⏳ Pending |
| Visualization mode | Hide abacus option | `PracticeProblemPlayer.tsx` | ⏳ Pending |
| Adaptive mastery | Continue until N consecutive correct | New logic | ⏳ Pending |
| Progress persistence | Track technique mastery | Database/localStorage | ⏳ Pending |
| **Student profiles** | Extend players with curriculum progress | New DB tables | ✅ Done |
| **Student selection UI** | Pick student before practice | `src/components/practice/` | ✅ Done |
## Student Progress Architecture
@@ -135,38 +139,38 @@ This means a child's avatar in arcade games is the same avatar they use for prac
```typescript
// player_curriculum - Overall curriculum position for a player
interface PlayerCurriculum {
playerId: string // FK to players, PRIMARY KEY
currentLevel: 1 | 2 | 3 // Which level they're on
currentPhaseId: string // e.g., "L1.add.+3.withFive"
worksheetPreset: string // Saved worksheet difficulty profile
visualizationMode: boolean // Practice without visible abacus
updatedAt: Date
playerId: string; // FK to players, PRIMARY KEY
currentLevel: 1 | 2 | 3; // Which level they're on
currentPhaseId: string; // e.g., "L1.add.+3.withFive"
worksheetPreset: string; // Saved worksheet difficulty profile
visualizationMode: boolean; // Practice without visible abacus
updatedAt: Date;
}
// player_skill_mastery - Per-skill progress tracking
interface PlayerSkillMastery {
id: string
playerId: string // FK to players
skillId: string // e.g., "fiveComplements.4=5-1"
attempts: number // Total attempts using this skill
correct: number // Successful uses
consecutiveCorrect: number // Current streak (resets on error)
masteryLevel: 'learning' | 'practicing' | 'mastered'
lastPracticedAt: Date
id: string;
playerId: string; // FK to players
skillId: string; // e.g., "fiveComplements.4=5-1"
attempts: number; // Total attempts using this skill
correct: number; // Successful uses
consecutiveCorrect: number; // Current streak (resets on error)
masteryLevel: "learning" | "practicing" | "mastered";
lastPracticedAt: Date;
// UNIQUE constraint on (playerId, skillId)
}
// practice_sessions - Historical session data
interface PracticeSession {
id: string
playerId: string
phaseId: string // Which curriculum phase
problemsAttempted: number
problemsCorrect: number
averageTimeMs: number
skillsUsed: string[] // Skills exercised this session
startedAt: Date
completedAt: Date
id: string;
playerId: string;
phaseId: string; // Which curriculum phase
problemsAttempted: number;
problemsCorrect: number;
averageTimeMs: number;
skillsUsed: string[]; // Skills exercised this session
startedAt: Date;
completedAt: Date;
}
```
@@ -174,21 +178,23 @@ interface PracticeSession {
```typescript
const MASTERY_CONFIG = {
consecutiveForMastery: 5, // 5 correct in a row = mastered
minimumAttempts: 10, // Need at least 10 attempts
accuracyThreshold: 0.85, // 85% accuracy for practicing → mastered
}
consecutiveForMastery: 5, // 5 correct in a row = mastered
minimumAttempts: 10, // Need at least 10 attempts
accuracyThreshold: 0.85, // 85% accuracy for practicing → mastered
};
function updateMasteryLevel(skill: PlayerSkillMastery): MasteryLevel {
if (skill.consecutiveCorrect >= MASTERY_CONFIG.consecutiveForMastery
&& skill.attempts >= MASTERY_CONFIG.minimumAttempts
&& (skill.correct / skill.attempts) >= MASTERY_CONFIG.accuracyThreshold) {
return 'mastered'
if (
skill.consecutiveCorrect >= MASTERY_CONFIG.consecutiveForMastery &&
skill.attempts >= MASTERY_CONFIG.minimumAttempts &&
skill.correct / skill.attempts >= MASTERY_CONFIG.accuracyThreshold
) {
return "mastered";
}
if (skill.attempts >= 5) {
return 'practicing'
return "practicing";
}
return 'learning'
return "learning";
}
```
@@ -233,6 +239,7 @@ function updateMasteryLevel(skill: PlayerSkillMastery): MasteryLevel {
### Worksheet Integration
When generating worksheets:
1. **No student selected**: Manual difficulty selection (current behavior)
2. **Student selected**:
- Pre-populate settings based on their curriculum position
@@ -244,6 +251,7 @@ When generating worksheets:
### Overview
A "session plan" is the system's recommendation for what a student should practice, generated based on:
- Available time (specified by teacher)
- Student's current curriculum position
- Skill mastery levels (what needs work vs. what's mastered)
@@ -332,7 +340,7 @@ Both the **Plan Review** and **Active Session** screens include a "Config" butto
│ PROBLEM CONSTRAINTS (Current Slot) │
│ ├── slotIndex: 7 │
│ ├── purpose: "focus" │
│ ├── requiredSkills: { fiveComplements: { "3=5-2": true } } │
│ ├── allowedSkills: { fiveComplements: { "3=5-2": true } } │
│ ├── forbiddenSkills: { tenComplements: true } │
│ ├── digitRange: { min: 1, max: 2 } │
│ └── termCount: { min: 3, max: 5 } │
@@ -353,12 +361,12 @@ Both the **Plan Review** and **Active Session** screens include a "Config" butto
Real-time metrics visible to the teacher during the active session:
| Indicator | 🟢 Good | 🟡 Warning | 🔴 Struggling |
|-----------|---------|------------|---------------|
| **Accuracy** | >80% | 60-80% | <60% |
| **Pace** | On track or ahead | 10-30% behind | >30% behind |
| **Streak** | 3+ consecutive correct | Mixed results | 3+ consecutive wrong |
| **Engagement** | <60s per problem | 60-90s per problem | >90s or long pauses |
| Indicator | 🟢 Good | 🟡 Warning | 🔴 Struggling |
| -------------- | ---------------------- | ------------------ | -------------------- |
| **Accuracy** | >80% | 60-80% | <60% |
| **Pace** | On track or ahead | 10-30% behind | >30% behind |
| **Streak** | 3+ consecutive correct | Mixed results | 3+ consecutive wrong |
| **Engagement** | <60s per problem | 60-90s per problem | >90s or long pauses |
Overall session health is the worst of the four indicators.
@@ -366,14 +374,14 @@ Overall session health is the worst of the four indicators.
When the session isn't going well, the teacher can:
| Adjustment | Effect | When to Use |
|------------|--------|-------------|
| **Reduce Difficulty** | Switch remaining slots to easier problems | Accuracy < 60%, frustration visible |
| **Enable Scaffolding** | Turn on visualization mode (show abacus) | Conceptual confusion |
| **Narrow Focus** | Drop review/challenge, focus only on current skill | Overwhelmed by variety |
| **Take a Break** | Pause timer, allow discussion | Long pauses, emotional state |
| **Extend Session** | Add more problems | Going well, student wants more |
| **End Gracefully** | Complete current problem, show summary | Time constraint, fatigue |
| Adjustment | Effect | When to Use |
| ---------------------- | -------------------------------------------------- | ----------------------------------- |
| **Reduce Difficulty** | Switch remaining slots to easier problems | Accuracy < 60%, frustration visible |
| **Enable Scaffolding** | Turn on visualization mode (show abacus) | Conceptual confusion |
| **Narrow Focus** | Drop review/challenge, focus only on current skill | Overwhelmed by variety |
| **Take a Break** | Pause timer, allow discussion | Long pauses, emotional state |
| **Extend Session** | Add more problems | Going well, student wants more |
| **End Gracefully** | Complete current problem, show summary | Time constraint, fatigue |
All adjustments are logged in `SessionPlan.adjustments[]` for later analysis.
@@ -381,89 +389,95 @@ All adjustments are logged in `SessionPlan.adjustments[]` for later analysis.
```typescript
interface SessionPlan {
id: string
playerId: string
id: string;
playerId: string;
// Setup parameters
targetDurationMinutes: number
estimatedProblemCount: number
avgTimePerProblemSeconds: number // Calculated from student history
targetDurationMinutes: number;
estimatedProblemCount: number;
avgTimePerProblemSeconds: number; // Calculated from student history
// Problem slots (generated upfront, can be modified)
slots: ProblemSlot[]
slots: ProblemSlot[];
// Human-readable summary for plan review screen
summary: SessionSummary
summary: SessionSummary;
// State machine
status: 'draft' | 'approved' | 'in_progress' | 'completed' | 'abandoned'
status: "draft" | "approved" | "in_progress" | "completed" | "abandoned";
// Timestamps
createdAt: Date
approvedAt?: Date // When teacher/student clicked "Let's Go"
startedAt?: Date // When first problem displayed
completedAt?: Date
createdAt: Date;
approvedAt?: Date; // When teacher/student clicked "Let's Go"
startedAt?: Date; // When first problem displayed
completedAt?: Date;
// Live tracking
currentSlotIndex: number
sessionHealth: SessionHealth
adjustments: SessionAdjustment[]
currentSlotIndex: number;
sessionHealth: SessionHealth;
adjustments: SessionAdjustment[];
// Results (filled in as session progresses)
results: SlotResult[]
results: SlotResult[];
}
interface ProblemSlot {
index: number
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
index: number;
purpose: "focus" | "reinforce" | "review" | "challenge";
// Constraints passed to problem generator
constraints: {
requiredSkills?: Partial<SkillSet>
targetSkills?: Partial<SkillSet>
forbiddenSkills?: Partial<SkillSet>
digitRange?: { min: number; max: number }
termCount?: { min: number; max: number }
operator?: 'addition' | 'subtraction' | 'mixed'
}
allowedSkills?: Partial<SkillSet>;
targetSkills?: Partial<SkillSet>;
forbiddenSkills?: Partial<SkillSet>;
digitRange?: { min: number; max: number };
termCount?: { min: number; max: number };
operator?: "addition" | "subtraction" | "mixed";
};
// Generated problem (filled when slot is reached)
problem?: GeneratedProblem
problem?: GeneratedProblem;
}
interface SessionSummary {
focusDescription: string // "Adding +3 using five-complement"
focusCount: number
reviewSkills: string[] // Human-readable skill names
reviewCount: number
challengeCount: number
estimatedMinutes: number
focusDescription: string; // "Adding +3 using five-complement"
focusCount: number;
reviewSkills: string[]; // Human-readable skill names
reviewCount: number;
challengeCount: number;
estimatedMinutes: number;
}
interface SessionHealth {
overall: 'good' | 'warning' | 'struggling'
accuracy: number // 0-1
pacePercent: number // 100 = on track, <100 = behind
currentStreak: number // Positive = correct streak, negative = wrong streak
avgResponseTimeMs: number
overall: "good" | "warning" | "struggling";
accuracy: number; // 0-1
pacePercent: number; // 100 = on track, <100 = behind
currentStreak: number; // Positive = correct streak, negative = wrong streak
avgResponseTimeMs: number;
}
interface SessionAdjustment {
timestamp: Date
type: 'difficulty_reduced' | 'scaffolding_enabled' | 'focus_narrowed'
| 'paused' | 'resumed' | 'extended' | 'ended_early'
reason?: string // Optional teacher note
previousHealth: SessionHealth
timestamp: Date;
type:
| "difficulty_reduced"
| "scaffolding_enabled"
| "focus_narrowed"
| "paused"
| "resumed"
| "extended"
| "ended_early";
reason?: string; // Optional teacher note
previousHealth: SessionHealth;
}
interface SlotResult {
slotIndex: number
problem: GeneratedProblem
studentAnswer: number
isCorrect: boolean
responseTimeMs: number
skillsExercised: string[] // Which skills this problem tested
timestamp: Date
slotIndex: number;
problem: GeneratedProblem;
studentAnswer: number;
isCorrect: boolean;
responseTimeMs: number;
skillsExercised: string[]; // Which skills this problem tested
timestamp: Date;
}
```
@@ -472,100 +486,102 @@ interface SlotResult {
```typescript
interface PlanGenerationConfig {
// Distribution weights (should sum to 1.0)
focusWeight: number // Default: 0.60
reinforceWeight: number // Default: 0.20
reviewWeight: number // Default: 0.15
challengeWeight: number // Default: 0.05
focusWeight: number; // Default: 0.60
reinforceWeight: number; // Default: 0.20
reviewWeight: number; // Default: 0.15
challengeWeight: number; // Default: 0.05
// Timing
defaultSecondsPerProblem: number // Default: 45
defaultSecondsPerProblem: number; // Default: 45
// Spaced repetition
reviewIntervalDays: {
mastered: number // Default: 7 (review mastered skills weekly)
practicing: number // Default: 3 (review practicing skills every 3 days)
}
mastered: number; // Default: 7 (review mastered skills weekly)
practicing: number; // Default: 3 (review practicing skills every 3 days)
};
}
function generateSessionPlan(
playerId: string,
durationMinutes: number,
config: PlanGenerationConfig = DEFAULT_CONFIG
config: PlanGenerationConfig = DEFAULT_CONFIG,
): SessionPlan {
// 1. Load student state
const curriculum = await getPlayerCurriculum(playerId)
const skillMastery = await getAllSkillMastery(playerId)
const recentSessions = await getRecentSessions(playerId, 10)
const curriculum = await getPlayerCurriculum(playerId);
const skillMastery = await getAllSkillMastery(playerId);
const recentSessions = await getRecentSessions(playerId, 10);
// 2. Calculate personalized timing
const avgTime = calculateAvgTimePerProblem(recentSessions)
?? config.defaultSecondsPerProblem
const problemCount = Math.floor((durationMinutes * 60) / avgTime)
const avgTime =
calculateAvgTimePerProblem(recentSessions) ??
config.defaultSecondsPerProblem;
const problemCount = Math.floor((durationMinutes * 60) / avgTime);
// 3. Categorize skills by need
const currentPhaseSkills = getSkillsForPhase(curriculum.currentPhaseId)
const struggling = skillMastery.filter(s =>
currentPhaseSkills.includes(s.skillId) &&
s.correct / s.attempts < 0.7
)
const needsReview = skillMastery.filter(s =>
s.masteryLevel === 'mastered' &&
daysSince(s.lastPracticedAt) > config.reviewIntervalDays.mastered
)
const currentPhaseSkills = getSkillsForPhase(curriculum.currentPhaseId);
const struggling = skillMastery.filter(
(s) =>
currentPhaseSkills.includes(s.skillId) && s.correct / s.attempts < 0.7,
);
const needsReview = skillMastery.filter(
(s) =>
s.masteryLevel === "mastered" &&
daysSince(s.lastPracticedAt) > config.reviewIntervalDays.mastered,
);
// 4. Calculate slot distribution
const focusCount = Math.round(problemCount * config.focusWeight)
const reinforceCount = Math.round(problemCount * config.reinforceWeight)
const reviewCount = Math.round(problemCount * config.reviewWeight)
const challengeCount = problemCount - focusCount - reinforceCount - reviewCount
const focusCount = Math.round(problemCount * config.focusWeight);
const reinforceCount = Math.round(problemCount * config.reinforceWeight);
const reviewCount = Math.round(problemCount * config.reviewWeight);
const challengeCount =
problemCount - focusCount - reinforceCount - reviewCount;
// 5. Build slots with constraints
const slots: ProblemSlot[] = []
const slots: ProblemSlot[] = [];
// Focus slots: current phase, primary skill
for (let i = 0; i < focusCount; i++) {
slots.push({
index: slots.length,
purpose: 'focus',
constraints: buildConstraintsForPhase(curriculum.currentPhaseId)
})
purpose: "focus",
constraints: buildConstraintsForPhase(curriculum.currentPhaseId),
});
}
// Reinforce slots: struggling skills get extra practice
for (let i = 0; i < reinforceCount; i++) {
const skill = struggling[i % struggling.length]
const skill = struggling[i % struggling.length];
slots.push({
index: slots.length,
purpose: 'reinforce',
constraints: buildConstraintsForSkill(skill?.skillId)
})
purpose: "reinforce",
constraints: buildConstraintsForSkill(skill?.skillId),
});
}
// Review slots: spaced repetition of mastered skills
for (let i = 0; i < reviewCount; i++) {
const skill = needsReview[i % needsReview.length]
const skill = needsReview[i % needsReview.length];
slots.push({
index: slots.length,
purpose: 'review',
constraints: buildConstraintsForSkill(skill?.skillId)
})
purpose: "review",
constraints: buildConstraintsForSkill(skill?.skillId),
});
}
// Challenge slots: slightly harder or mixed
for (let i = 0; i < challengeCount; i++) {
slots.push({
index: slots.length,
purpose: 'challenge',
constraints: buildChallengeConstraints(curriculum)
})
purpose: "challenge",
constraints: buildChallengeConstraints(curriculum),
});
}
// 6. Shuffle to interleave purposes (but keep some focus problems together)
const shuffledSlots = intelligentShuffle(slots)
const shuffledSlots = intelligentShuffle(slots);
// 7. Build summary
const summary = buildHumanReadableSummary(shuffledSlots, curriculum)
const summary = buildHumanReadableSummary(shuffledSlots, curriculum);
return {
id: generateId(),
@@ -575,13 +591,19 @@ function generateSessionPlan(
avgTimePerProblemSeconds: avgTime,
slots: shuffledSlots,
summary,
status: 'draft',
status: "draft",
createdAt: new Date(),
currentSlotIndex: 0,
sessionHealth: { overall: 'good', accuracy: 1, pacePercent: 100, currentStreak: 0, avgResponseTimeMs: 0 },
sessionHealth: {
overall: "good",
accuracy: 1,
pacePercent: 100,
currentStreak: 0,
avgResponseTimeMs: 0,
},
adjustments: [],
results: []
}
results: [],
};
}
```
@@ -676,8 +698,6 @@ The practice experience is the actual problem-solving interface where the studen
│ │ ● ● ● ● ○ ○ ○ ○ ○ │ │
│ └───────────────────────┘ │
│ │
│ 3D Model: public/3d-models/simplified.abacus.stl │
│ │
└─────────────────────────────────────────────────────────────────┘
```
@@ -688,6 +708,7 @@ The curriculum uses two distinct problem formats:
#### 1. Vertical (Columnar) Format - Primary
This is the main format from the workbooks. Numbers are stacked vertically:
- **Plus sign omitted** - Addition is implicit
- **Minus sign shown** - Only subtraction is marked
- **Answer box at bottom** - Student fills in the result
@@ -755,23 +776,24 @@ After visualization practice, students progress to linear problems - sequences p
Based on the workbook format, a typical daily practice session has three parts:
| Part | Format | Abacus | Purpose |
|------|--------|--------|---------|
| **Part 1: Skill Building** | Vertical | Physical abacus | Build muscle memory, learn techniques |
| **Part 2: Visualization** | Vertical | Hidden/mental | Internalize bead movements mentally |
| **Part 3: Mental Math** | Linear | None | Pure mental calculation, no visual aid |
| Part | Format | Abacus | Purpose |
| -------------------------- | -------- | --------------- | -------------------------------------- |
| **Part 1: Skill Building** | Vertical | Physical abacus | Build muscle memory, learn techniques |
| **Part 2: Visualization** | Vertical | Hidden/mental | Internalize bead movements mentally |
| **Part 3: Mental Math** | Linear | None | Pure mental calculation, no visual aid |
### Input Methods
| Device | Primary Input | Implementation |
|--------|---------------|----------------|
| **Desktop/Laptop** | Native keyboard | `<input type="number">` with auto-focus |
| **Tablet with keyboard** | Native keyboard | Same as desktop |
| **Phone/Touch tablet** | Virtual keypad | `react-simple-keyboard` numeric layout |
| Device | Primary Input | Implementation |
| ------------------------ | --------------- | --------------------------------------- |
| **Desktop/Laptop** | Native keyboard | `<input type="number">` with auto-focus |
| **Tablet with keyboard** | Native keyboard | Same as desktop |
| **Phone/Touch tablet** | Virtual keypad | `react-simple-keyboard` numeric layout |
#### Phone Keypad Implementation
Reference existing implementations:
- **Know Your World**: `src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx`
- Uses `react-simple-keyboard` v3.8.139
- Configured for letter input in learning mode
@@ -782,19 +804,14 @@ Reference existing implementations:
```typescript
// Simplified numeric keypad for practice
const numericLayout = {
default: [
'7 8 9',
'4 5 6',
'1 2 3',
'{bksp} 0 {enter}'
]
}
default: ["7 8 9", "4 5 6", "1 2 3", "{bksp} 0 {enter}"],
};
// Use device detection from memory quiz
const useDeviceType = () => {
// Returns 'desktop' | 'tablet' | 'phone'
// Based on screen size and touch capability
}
};
```
### Abacus Access
@@ -849,6 +866,7 @@ When `visualizationMode: true` in the student's curriculum settings:
```
**Visualization mode behaviors**:
- Hide "Show Abacus" button entirely
- Add gentle reminder: "Picture the beads in your mind"
- If student struggles (2+ wrong in a row):
@@ -862,7 +880,8 @@ When `visualizationMode: true` in the student's curriculum settings:
**CRITICAL**: Never present problems requiring skills the student hasn't learned yet.
The problem generator (`src/utils/problemGenerator.ts`) already supports:
- `requiredSkills` - Skills the problem MUST use
- `allowedSkills` - Skills the problem MUST use
- `targetSkills` - Skills we're trying to practice
- `forbiddenSkills` - Skills the problem must NOT use
@@ -870,18 +889,19 @@ The problem generator (`src/utils/problemGenerator.ts`) already supports:
// For a Level 1 student who has only learned +1, +2, +3 direct addition:
const constraints = {
forbiddenSkills: {
fiveComplements: true, // No five-complement techniques
tenComplements: true, // No ten-complement techniques
tenComplementsSub: true, // No subtraction borrowing
fiveComplementsSub: true, // No subtraction with fives
fiveComplements: true, // No five-complement techniques
tenComplements: true, // No ten-complement techniques
tenComplementsSub: true, // No subtraction borrowing
fiveComplementsSub: true, // No subtraction with fives
},
requiredSkills: {
basic: { directAddition: true }
}
}
allowedSkills: {
basic: { directAddition: true },
},
};
```
**Audit checklist for problem generation**:
1.`analyzeRequiredSkills()` accurately categorizes all techniques needed
2.`problemMatchesSkills()` correctly validates against constraints
3. ⏳ Create curriculum phase → constraints mapping
@@ -889,34 +909,33 @@ const constraints = {
### Existing Components to Leverage
| Component | Location | Purpose |
|-----------|----------|---------|
| `PracticeProblemPlayer` | `src/components/tutorial/PracticeProblemPlayer.tsx` | Existing practice UI (abacus-based input) |
| `SimpleLetterKeyboard` | `src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx` | `react-simple-keyboard` integration |
| `InputPhase` | `src/arcade-games/memory-quiz/components/InputPhase.tsx` | Custom numeric keypad + device detection |
| `problemGenerator` | `src/utils/problemGenerator.ts` | Skill-constrained problem generation |
| `AbacusReact` | `@soroban/abacus-react` | On-screen abacus (last resort) |
| 3D Abacus Model | `public/3d-models/simplified.abacus.stl` | Physical abacus recommendation |
| Component | Location | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------- |
| `PracticeProblemPlayer` | `src/components/tutorial/PracticeProblemPlayer.tsx` | Existing practice UI (abacus-based input) |
| `SimpleLetterKeyboard` | `src/arcade-games/know-your-world/components/SimpleLetterKeyboard.tsx` | `react-simple-keyboard` integration |
| `InputPhase` | `src/arcade-games/memory-quiz/components/InputPhase.tsx` | Custom numeric keypad + device detection |
| `problemGenerator` | `src/utils/problemGenerator.ts` | Skill-constrained problem generation |
| `AbacusReact` | `@soroban/abacus-react` | On-screen abacus (last resort) |
### Data Model Extensions
```typescript
interface PracticeAnswer {
slotIndex: number
studentAnswer: number
isCorrect: boolean
responseTimeMs: number
inputMethod: 'keyboard' | 'virtual_keypad' | 'touch'
usedOnScreenAbacus: boolean // Track abacus usage
visualizationMode: boolean // Was this in visualization mode?
slotIndex: number;
studentAnswer: number;
isCorrect: boolean;
responseTimeMs: number;
inputMethod: "keyboard" | "virtual_keypad" | "touch";
usedOnScreenAbacus: boolean; // Track abacus usage
visualizationMode: boolean; // Was this in visualization mode?
}
// For identifying students who may need a physical abacus
interface StudentAbacusUsage {
onScreenAbacusUsed: number // Count of problems using on-screen
totalProblems: number
usageRate: number // Percentage
suggestPhysicalAbacus: boolean // true if usage rate > 30%
onScreenAbacusUsed: number; // Count of problems using on-screen
totalProblems: number;
usageRate: number; // Percentage
suggestPhysicalAbacus: boolean; // true if usage rate > 30%
}
```
@@ -991,6 +1010,7 @@ interface StudentAbacusUsage {
**Goal**: Create database tables and basic UI for tracking student progress through the curriculum.
**Tasks**:
1. ✅ Create `player_curriculum` table schema - `src/db/schema/player-curriculum.ts`
2. ✅ Create `player_skill_mastery` table schema - `src/db/schema/player-skill-mastery.ts`
3. ✅ Create `practice_sessions` table schema - `src/db/schema/practice-sessions.ts`
@@ -1003,6 +1023,7 @@ interface StudentAbacusUsage {
10. ✅ Create `/practice` page - `src/app/practice/page.tsx`
**Files Created**:
-`src/db/schema/player-curriculum.ts` - Curriculum position tracking
-`src/db/schema/player-skill-mastery.ts` - Per-skill mastery tracking with `MASTERY_CONFIG` and `calculateMasteryLevel()`
-`src/db/schema/practice-sessions.ts` - Practice session history
@@ -1024,6 +1045,7 @@ interface StudentAbacusUsage {
**Goal**: Enable the problem generator to handle subtraction and properly categorize "with/without friends of 5".
**Tasks**:
1. ✅ Add `analyzeColumnSubtraction()` function - `src/utils/problemGenerator.ts:148`
2. ✅ Add subtraction skills to `SkillSet` type - `src/types/tutorial.ts:36`
- `fiveComplementsSub`: `-4=-5+1`, `-3=-5+2`, `-2=-5+3`, `-1=-5+4`
@@ -1039,23 +1061,26 @@ interface StudentAbacusUsage {
**Goal**: Define the Level 1/2/3 structure as data that drives practice.
**Tasks**:
1. Create curriculum data structure:
```typescript
interface CurriculumLevel {
id: string
name: string
description: string
phases: CurriculumPhase[]
id: string;
name: string;
description: string;
phases: CurriculumPhase[];
}
interface CurriculumPhase {
targetNumber: number // +1, +2, ... +9 or -9, -8, ... -1
operation: 'addition' | 'subtraction'
useFiveComplement: boolean
usesTenComplement: boolean
practiceStep: PracticeStep // Existing type
targetNumber: number; // +1, +2, ... +9 or -9, -8, ... -1
operation: "addition" | "subtraction";
useFiveComplement: boolean;
usesTenComplement: boolean;
practiceStep: PracticeStep; // Existing type
}
```
2. Define all phases for Level 1, 2, 3
3. Create helper to convert curriculum phase to PracticeStep constraints
@@ -1064,6 +1089,7 @@ interface StudentAbacusUsage {
**Goal**: A `/practice` page that guides students through the curriculum with intelligent session planning.
**Tasks**:
1. ✅ Create `/app/practice/page.tsx` - Basic structure done
2. ✅ Track current position in curriculum - Database schema done
3. ⏳ Create session plan generator (`src/lib/curriculum/session-planner.ts`)
@@ -1080,18 +1106,21 @@ interface StudentAbacusUsage {
**Sub-phases**:
#### Phase 3a: Session Plan Generation
- Create `SessionPlan` type definitions
- Implement `generateSessionPlan()` algorithm
- Create `session_plans` table schema
- API: POST `/api/curriculum/{playerId}/sessions/plan`
#### Phase 3b: Plan Review UI
- Plan summary display
- Configuration inspector (debug panel)
- "Adjust Plan" controls
- "Let's Go" approval flow
#### Phase 3c: Active Session UI (Practice Experience)
- One-problem-at-a-time display with progress bar
- Timer and pace tracking
- Device-appropriate input:
@@ -1111,6 +1140,7 @@ interface StudentAbacusUsage {
- Configuration inspector (current slot details)
#### Phase 3d: Session Completion
- Summary display with results
- Mastery level changes
- Skill update and persistence
@@ -1121,6 +1151,7 @@ interface StudentAbacusUsage {
**Goal**: Generate printable worksheets targeting specific techniques.
**Tasks**:
1. Add "technique mode" to worksheet config
2. Allow selecting specific curriculum phase for worksheet
3. Generate problems using same constraints as online practice
@@ -1130,6 +1161,7 @@ interface StudentAbacusUsage {
### Skill Analysis Logic
**Current addition analysis** (from `analyzeColumnAddition`):
- Checks if adding `termDigit` to `currentDigit` requires:
- Direct addition (result ≤ 4)
- Heaven bead (involves 5)
@@ -1137,6 +1169,7 @@ interface StudentAbacusUsage {
- Ten complement (needs -n+10)
**Subtraction analysis** (to implement):
- Check if subtracting `termDigit` from `currentDigit` requires:
- Direct subtraction (have enough earth beads)
- Heaven bead removal (have 5-bead to remove)
@@ -1150,18 +1183,28 @@ Use `forbiddenSkills` to exclude five-complement techniques:
```typescript
// Level 1, +3, WITHOUT friends of 5
const practiceStep: PracticeStep = {
requiredSkills: { basic: { directAddition: true, heavenBead: true } },
targetSkills: { /* target +3 specifically */ },
forbiddenSkills: {
fiveComplements: { '3=5-2': true, '2=5-3': true, '1=5-4': true, '4=5-1': true }
allowedSkills: { basic: { directAddition: true, heavenBead: true } },
targetSkills: {
/* target +3 specifically */
},
}
forbiddenSkills: {
fiveComplements: {
"3=5-2": true,
"2=5-3": true,
"1=5-4": true,
"4=5-1": true,
},
},
};
// Level 1, +3, WITH friends of 5
const practiceStep: PracticeStep = {
requiredSkills: { basic: { directAddition: true, heavenBead: true }, fiveComplements: { '2=5-3': true } },
targetSkills: { fiveComplements: { '2=5-3': true } }, // Specifically target +3 via +5-2
}
allowedSkills: {
basic: { directAddition: true, heavenBead: true },
fiveComplements: { "2=5-3": true },
},
targetSkills: { fiveComplements: { "2=5-3": true } }, // Specifically target +3 via +5-2
};
```
## Assessment Data to Track
@@ -1181,12 +1224,12 @@ const practiceStep: PracticeStep = {
## Questions Resolved
| Question | Answer |
|----------|--------|
| Problem format? | Multi-term sequences (3-7 terms), like the books |
| Single-digit first? | No, double-digit from the start |
| Question | Answer |
| ------------------- | --------------------------------------------------- |
| Problem format? | Multi-term sequences (3-7 terms), like the books |
| Single-digit first? | No, double-digit from the start |
| Visualization mode? | No abacus visible - that's the point of mental math |
| Adaptive mastery? | Yes, continue until demonstrated proficiency |
| Adaptive mastery? | Yes, continue until demonstrated proficiency |
## Sources

View File

@@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Add mastered_skill_ids column to session_plans for skill mismatch detection
ALTER TABLE `session_plans` ADD `mastered_skill_ids` text DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1,6 @@
-- Custom SQL migration file, put your code below! --
-- Add response time tracking columns to player_skill_mastery table
ALTER TABLE `player_skill_mastery` ADD `total_response_time_ms` integer DEFAULT 0 NOT NULL;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` ADD `response_time_count` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Add is_practicing boolean column to player_skill_mastery
-- This replaces the 3-state mastery_level with a simple boolean
-- Fluency state (effortless/fluent/rusty/practicing) is now computed from practice history
ALTER TABLE `player_skill_mastery` ADD `is_practicing` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Populate is_practicing from existing mastery_level data
-- mastered or practicing -> is_practicing = 1 (true)
-- learning -> is_practicing = 0 (false)
UPDATE `player_skill_mastery` SET `is_practicing` = 1 WHERE `mastery_level` IN ('mastered', 'practicing');

View File

@@ -0,0 +1,5 @@
-- Custom SQL migration file, put your code below! --
-- Drop the deprecated mastery_level column from player_skill_mastery table
-- This column has been replaced by isPracticing + computed fluency state
ALTER TABLE `player_skill_mastery` DROP COLUMN `mastery_level`;

View File

@@ -0,0 +1,5 @@
-- Custom SQL migration file, put your code below! --
-- Add problem generation mode column to player_curriculum table
-- 'adaptive' = BKT-based continuous scaling (default)
-- 'classic' = Fluency-based discrete states
ALTER TABLE `player_curriculum` ADD `problem_generation_mode` text DEFAULT 'adaptive' NOT NULL;

View File

@@ -0,0 +1,26 @@
-- Custom SQL migration for skill_tutorial_progress table
-- Tracks tutorial completion status for each skill per player
CREATE TABLE `skill_tutorial_progress` (
`id` text PRIMARY KEY NOT NULL,
`player_id` text NOT NULL,
`skill_id` text NOT NULL,
`tutorial_completed` integer DEFAULT 0 NOT NULL,
`completed_at` integer,
`teacher_override` integer DEFAULT 0 NOT NULL,
`override_at` integer,
`override_reason` text,
`skip_count` integer DEFAULT 0 NOT NULL,
`last_skipped_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`player_id`) REFERENCES `players`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
-- Index for fast lookups by player
CREATE INDEX `skill_tutorial_progress_player_id_idx` ON `skill_tutorial_progress` (`player_id`);
--> statement-breakpoint
-- Unique constraint: one record per player per skill
CREATE UNIQUE INDEX `skill_tutorial_progress_player_skill_unique` ON `skill_tutorial_progress` (`player_id`, `skill_id`);

View File

@@ -0,0 +1,9 @@
-- App-wide settings table (single row)
CREATE TABLE `app_settings` (
`id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
`bkt_confidence_threshold` real DEFAULT 0.3 NOT NULL
);
--> statement-breakpoint
-- Insert the default row
INSERT INTO `app_settings` (`id`, `bkt_confidence_threshold`) VALUES ('default', 0.3);

View File

@@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Add notes column to players table for teacher notes
ALTER TABLE `players` ADD `notes` text;

View File

@@ -0,0 +1,5 @@
-- Drop the practice_sessions table
-- This table was replaced by session_plans which stores richer session data
-- The table has 0 rows in production - all session data is in session_plans
DROP TABLE IF EXISTS `practice_sessions`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,76 @@
"when": 1765055035935,
"tag": "0027_help_system_schema",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1765331044112,
"tag": "0028_medical_wolfsbane",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1765496987070,
"tag": "0029_first_black_tarantula",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1765586703691,
"tag": "0030_tan_jean_grey",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1765586735162,
"tag": "0031_boring_namora",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1765594487576,
"tag": "0032_drop_mastery_level_column",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1765747888277,
"tag": "0033_swift_eddie_brock",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1765939218325,
"tag": "0034_skill_tutorial_progress",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1765988633495,
"tag": "0035_cold_slapstick",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1766059382290,
"tag": "0036_lonely_roland_deschain",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1766068695014,
"tag": "0037_drop_practice_sessions",
"breakpoints": true
}
]
}

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && npx tsx scripts/generateAllDayIcons.tsx && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
"build": "node scripts/generate-build-info.js && npx tsx scripts/generateAllDayIcons.tsx && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && npm run build:seed-script && next build",
"start": "NODE_ENV=production node server.js",
"lint": "npx @biomejs/biome lint . && npx eslint .",
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
@@ -22,7 +22,9 @@
"db:migrate": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:drop": "drizzle-kit drop"
"db:drop": "drizzle-kit drop",
"seed:test-students": "npx tsx scripts/seedTestStudents.ts",
"build:seed-script": "npx esbuild scripts/seedTestStudents.ts --bundle --platform=node --packages=external --outfile=dist/seedTestStudents.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -64,8 +66,11 @@
"@use-gesture/react": "^10.3.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.4.1",
"canvas-confetti": "^1.9.4",
"d3-force": "^3.0.0",
"drizzle-orm": "^0.44.6",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"emojibase-data": "^16.0.3",
@@ -79,7 +84,6 @@
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",
"next-intl": "^4.4.0",
"openscad-wasm-prebuilt": "^1.2.0",
"python-bridge": "^1.1.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
@@ -89,6 +93,7 @@
"react-resizable-panels": "^3.0.6",
"react-simple-keyboard": "^3.8.139",
"react-textfit": "^1.1.1",
"react-use-measure": "^2.1.7",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
"rehype-slug": "^6.0.0",
@@ -113,6 +118,7 @@
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/canvas-confetti": "^1.9.0",
"@types/d3-force": "^3.0.10",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
@@ -123,6 +129,7 @@
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^8.2.2",
"drizzle-kit": "^0.31.5",
"esbuild": "^0.27.2",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0",
"eslint-plugin-storybook": "^9.1.7",

View File

@@ -261,6 +261,16 @@ export default defineConfig({
'0%, 100%': { filter: 'hue-rotate(0deg)' },
'50%': { filter: 'hue-rotate(20deg)' },
},
// Accordion slide down - expand content smoothly
accordionSlideDown: {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
// Accordion slide up - collapse content smoothly
accordionSlideUp: {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
},
},

View File

@@ -1,47 +0,0 @@
// Inline version of abacus.scad that doesn't require BOSL2
// This version uses a hardcoded bounding box size instead of the bounding_box() function
// ---- USER CUSTOMIZABLE PARAMETERS ----
// These can be overridden via command line: -D 'columns=7' etc.
columns = 13; // Total number of columns (1-13, mirrored book design)
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
// -----------------------------------------
stl_path = "/3d-models/simplified.abacus.stl";
// Known bounding box dimensions of the simplified.abacus.stl file
// These were measured from the original file
bbox_size = [186, 60, 120]; // [width, depth, height] in STL units
// Calculate parameters based on column count
// The full STL has 13 columns. We want columns/2 per side (mirrored).
total_columns_in_stl = 13;
columns_per_side = columns / 2;
width_scale = columns_per_side / total_columns_in_stl;
// Column spacing: distance between mirrored halves
units_per_column = bbox_size[0] / total_columns_in_stl; // ~14.3 units per column
column_spacing = columns_per_side * units_per_column;
// --- actual model ---
module imported() {
import(stl_path, convexity = 10);
}
// Create a bounding box manually instead of using BOSL2's bounding_box()
module bounding_box_manual() {
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
cube(bbox_size);
}
module half_abacus() {
intersection() {
scale([width_scale, 1, 1]) bounding_box_manual();
imported();
}
}
scale([scale_factor, scale_factor, scale_factor]) {
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
half_abacus();
}

View File

@@ -1,39 +0,0 @@
include <BOSL2/std.scad>; // BOSL2 v2.0 or newer
// ---- USER CUSTOMIZABLE PARAMETERS ----
// These can be overridden via command line: -D 'columns=7' etc.
columns = 13; // Total number of columns (1-13, mirrored book design)
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
// -----------------------------------------
stl_path = "./simplified.abacus.stl";
// Calculate parameters based on column count
// The full STL has 13 columns. We want columns/2 per side (mirrored).
// The original bounding box intersection: scale([35/186, 1, 1])
// 35/186 ≈ 0.188 = ~2.44 columns, so 186 units ≈ 13 columns, ~14.3 units per column
total_columns_in_stl = 13;
columns_per_side = columns / 2;
width_scale = columns_per_side / total_columns_in_stl;
// Column spacing: distance between mirrored halves
// Original spacing of 69 for ~2.4 columns/side
// Calculate proportional spacing based on columns
units_per_column = 186 / total_columns_in_stl; // ~14.3 units per column
column_spacing = columns_per_side * units_per_column;
// --- actual model ---
module imported()
import(stl_path, convexity = 10);
module half_abacus() {
intersection() {
scale([width_scale, 1, 1]) bounding_box() imported();
imported();
}
}
scale([scale_factor, scale_factor, scale_factor]) {
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
half_abacus();
}

View File

@@ -0,0 +1,161 @@
{
"generatedAt": "2025-12-16T19:26:34.484Z",
"version": "1.0",
"config": {
"seed": 98765,
"sessionCount": 12,
"sessionDurationMinutes": 15
},
"summary": {
"totalSkills": 6,
"adaptiveWins50": 4,
"classicWins50": 0,
"ties50": 2,
"adaptiveWins80": 6,
"classicWins80": 0,
"ties80": 0
},
"sessions": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
"skills": [
{
"id": "fiveComplements.3=5-2",
"label": "5-comp: 3=5-2",
"category": "fiveComplement",
"color": "#eab308",
"adaptive": {
"data": [25, 75, 85, 89, 93, 94, 95, 96, 97, 97, 98, 98],
"sessionsTo50": 2,
"sessionsTo80": 3
},
"classic": {
"data": [25, 54, 67, 82, 87, 90, 93, 94, 96, 97, 97, 98],
"sessionsTo50": 2,
"sessionsTo80": 4
}
},
{
"id": "fiveComplementsSub.-3=-5+2",
"label": "5-comp sub: -3=-5+2",
"category": "fiveComplement",
"color": "#facc15",
"adaptive": {
"data": [2, 27, 57, 80, 89, 90, 92, 93, 94, 95, 96, 96],
"sessionsTo50": 3,
"sessionsTo80": 4
},
"classic": {
"data": [2, 27, 32, 54, 63, 70, 79, 84, 87, 88, 90, 92],
"sessionsTo50": 4,
"sessionsTo80": 8
}
},
{
"id": "tenComplements.9=10-1",
"label": "10-comp: 9=10-1",
"category": "tenComplement",
"color": "#dc2626",
"adaptive": {
"data": [20, 63, 85, 89, 93, 94, 95, 96, 97, 97, 98, 98],
"sessionsTo50": 2,
"sessionsTo80": 3
},
"classic": {
"data": [20, 50, 69, 78, 86, 90, 93, 95, 96, 96, 97, 98],
"sessionsTo50": 2,
"sessionsTo80": 5
}
},
{
"id": "tenComplements.5=10-5",
"label": "10-comp: 5=10-5",
"category": "tenComplement",
"color": "#ea580c",
"adaptive": {
"data": [5, 44, 71, 82, 88, 90, 91, 92, 93, 94, 95, 95],
"sessionsTo50": 3,
"sessionsTo80": 4
},
"classic": {
"data": [5, 10, 16, 31, 44, 47, 64, 72, 77, 83, 87, 87],
"sessionsTo50": 7,
"sessionsTo80": 10
}
},
{
"id": "tenComplementsSub.-9=+1-10",
"label": "10-comp sub: -9=+1-10",
"category": "tenComplement",
"color": "#ef4444",
"adaptive": {
"data": [3, 40, 70, 72, 79, 80, 83, 87, 89, 91, 92, 92],
"sessionsTo50": 3,
"sessionsTo80": 6
},
"classic": {
"data": [3, 11, 22, 33, 53, 56, 63, 68, 72, 76, 77, 80],
"sessionsTo50": 5,
"sessionsTo80": 12
}
},
{
"id": "tenComplementsSub.-5=+5-10",
"label": "10-comp sub: -5=+5-10",
"category": "tenComplement",
"color": "#f97316",
"adaptive": {
"data": [1, 6, 44, 67, 78, 81, 83, 85, 87, 88, 89, 90],
"sessionsTo50": 4,
"sessionsTo80": 6
},
"classic": {
"data": [1, 6, 15, 25, 29, 38, 44, 50, 61, 67, 70, 74],
"sessionsTo50": 8,
"sessionsTo80": null
}
}
],
"comparisonTable": [
{
"skill": "5-comp: 3=5-2",
"category": "fiveComplement",
"adaptiveTo80": 3,
"classicTo80": 4,
"advantage": "Adaptive +1 sessions"
},
{
"skill": "5-comp sub: -3=-5+2",
"category": "fiveComplement",
"adaptiveTo80": 4,
"classicTo80": 8,
"advantage": "Adaptive +4 sessions"
},
{
"skill": "10-comp: 9=10-1",
"category": "tenComplement",
"adaptiveTo80": 3,
"classicTo80": 5,
"advantage": "Adaptive +2 sessions"
},
{
"skill": "10-comp: 5=10-5",
"category": "tenComplement",
"adaptiveTo80": 4,
"classicTo80": 10,
"advantage": "Adaptive +6 sessions"
},
{
"skill": "10-comp sub: -9=+1-10",
"category": "tenComplement",
"adaptiveTo80": 6,
"classicTo80": 12,
"advantage": "Adaptive +6 sessions"
},
{
"skill": "10-comp sub: -5=+5-10",
"category": "tenComplement",
"adaptiveTo80": 6,
"classicTo80": null,
"advantage": "Adaptive (Classic never reached 80%)"
}
]
}

View File

@@ -0,0 +1,209 @@
{
"generatedAt": "2025-12-16T15:51:01.133Z",
"version": "1.0",
"summary": {
"basicAvgExposures": 16.666666666666668,
"fiveCompAvgExposures": 24,
"tenCompAvgExposures": 36,
"gapAt20Exposures": "36.2 percentage points",
"exposureRatioForEqualMastery": "1.92"
},
"masteryCurves": {
"exposurePoints": [5, 10, 15, 20, 25, 30, 40, 50],
"skills": [
{
"id": "basic.directAddition",
"label": "Basic (0.8x)",
"category": "basic",
"color": "#22c55e",
"data": [28.000000000000004, 61, 78, 86, 91, 93, 96, 98]
},
{
"id": "fiveComplements.4=5-1",
"label": "Five-Complement (1.2x)",
"category": "fiveComplement",
"color": "#eab308",
"data": [15, 41, 61, 74, 81, 86, 92, 95]
},
{
"id": "tenComplements.9=10-1",
"label": "Ten-Complement Easy (1.6x)",
"category": "tenComplement",
"color": "#f97316",
"data": [9, 28.000000000000004, 47, 61, 71, 78, 86, 91]
},
{
"id": "tenComplements.1=10-9",
"label": "Ten-Complement Hard (2.0x)",
"category": "tenComplement",
"color": "#ef4444",
"data": [6, 20, 36, 50, 61, 69, 80, 86]
}
]
},
"abComparison": {
"exposurePoints": [5, 10, 15, 20, 25, 30, 40, 50],
"withDifficulty": {
"basic.directAddition": {
"avgAt20": 0.86
},
"fiveComplements.4=5-1": {
"avgAt20": 0.74
},
"tenComplements.1=10-9": {
"avgAt20": 0.5
},
"tenComplements.9=10-1": {
"avgAt20": 0.61
}
},
"withoutDifficulty": {
"basic.directAddition": {
"avgAt20": 0.8
},
"fiveComplements.4=5-1": {
"avgAt20": 0.8
},
"tenComplements.1=10-9": {
"avgAt20": 0.8
},
"tenComplements.9=10-1": {
"avgAt20": 0.8
}
}
},
"exposuresToMastery": {
"target": "80%",
"categories": [
{
"name": "Basic Skills",
"avgExposures": 16.666666666666668,
"color": "#22c55e",
"skills": [
{
"id": "basic.directAddition",
"exposures": 16
},
{
"id": "basic.directSubtraction",
"exposures": 16
},
{
"id": "basic.heavenBead",
"exposures": 18
}
]
},
{
"name": "Five-Complements",
"avgExposures": 24,
"color": "#eab308",
"skills": [
{
"id": "fiveComplements.1=5-4",
"exposures": 24
},
{
"id": "fiveComplements.3=5-2",
"exposures": 24
},
{
"id": "fiveComplements.4=5-1",
"exposures": 24
}
]
},
{
"name": "Ten-Complements",
"avgExposures": 36,
"color": "#ef4444",
"skills": [
{
"id": "tenComplements.1=10-9",
"exposures": 40
},
{
"id": "tenComplements.6=10-4",
"exposures": 36
},
{
"id": "tenComplements.9=10-1",
"exposures": 32
}
]
}
]
},
"fiftyPercentThresholds": {
"exposuresFor50Percent": {
"basic.directAddition": 8,
"fiveComplements.4=5-1": 12,
"tenComplements.1=10-9": 20,
"tenComplements.9=10-1": 16
},
"ratiosRelativeToBasic": {
"basic.directAddition": "1.00",
"fiveComplements.4=5-1": "1.50",
"tenComplements.1=10-9": "2.50",
"tenComplements.9=10-1": "2.00"
}
},
"masteryTable": [
{
"Basic (0.8x)": "0%",
"Five-Comp (1.2x)": "0%",
"Ten-Comp Easy (1.6x)": "0%",
"Ten-Comp Hard (2.0x)": "0%",
"exposures": 0
},
{
"Basic (0.8x)": "28%",
"Five-Comp (1.2x)": "15%",
"Ten-Comp Easy (1.6x)": "9%",
"Ten-Comp Hard (2.0x)": "6%",
"exposures": 5
},
{
"Basic (0.8x)": "61%",
"Five-Comp (1.2x)": "41%",
"Ten-Comp Easy (1.6x)": "28%",
"Ten-Comp Hard (2.0x)": "20%",
"exposures": 10
},
{
"Basic (0.8x)": "78%",
"Five-Comp (1.2x)": "61%",
"Ten-Comp Easy (1.6x)": "47%",
"Ten-Comp Hard (2.0x)": "36%",
"exposures": 15
},
{
"Basic (0.8x)": "86%",
"Five-Comp (1.2x)": "74%",
"Ten-Comp Easy (1.6x)": "61%",
"Ten-Comp Hard (2.0x)": "50%",
"exposures": 20
},
{
"Basic (0.8x)": "93%",
"Five-Comp (1.2x)": "86%",
"Ten-Comp Easy (1.6x)": "78%",
"Ten-Comp Hard (2.0x)": "69%",
"exposures": 30
},
{
"Basic (0.8x)": "96%",
"Five-Comp (1.2x)": "92%",
"Ten-Comp Easy (1.6x)": "86%",
"Ten-Comp Hard (2.0x)": "80%",
"exposures": 40
},
{
"Basic (0.8x)": "98%",
"Five-Comp (1.2x)": "95%",
"Ten-Comp Easy (1.6x)": "91%",
"Ten-Comp Hard (2.0x)": "86%",
"exposures": 50
}
]
}

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env tsx
/**
* Generate JSON data from A/B mastery trajectory test snapshots.
*
* This script reads the Vitest snapshot file and extracts the multi-skill
* A/B trajectory data into a JSON format for the blog post charts.
*
* Usage: npx tsx scripts/generateMasteryTrajectoryData.ts
* Output: public/data/ab-mastery-trajectories.json
*/
import fs from 'fs'
import path from 'path'
const SNAPSHOT_PATH = path.join(
process.cwd(),
'src/test/journey-simulator/__snapshots__/skill-difficulty.test.ts.snap'
)
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/ab-mastery-trajectories.json')
interface TrajectoryPoint {
session: number
mastery: number
}
interface SkillTrajectory {
adaptive: TrajectoryPoint[]
classic: TrajectoryPoint[]
sessionsTo50Adaptive: number | null
sessionsTo50Classic: number | null
sessionsTo80Adaptive: number | null
sessionsTo80Classic: number | null
}
interface ABMasterySnapshot {
config: {
seed: number
sessionCount: number
sessionDurationMinutes: number
}
summary: {
skills: string[]
adaptiveWins50: number
classicWins50: number
ties50: number
adaptiveWins80: number
classicWins80: number
ties80: number
}
trajectories: Record<string, SkillTrajectory>
}
function parseSnapshotFile(content: string): ABMasterySnapshot | null {
// Extract the ab-mastery-trajectories snapshot using regex
const regex = /exports\[`[^\]]*ab-mastery-trajectories[^\]]*`\]\s*=\s*`([\s\S]*?)`\s*;/m
const match = content.match(regex)
if (!match) {
console.warn('Warning: Could not find ab-mastery-trajectories snapshot')
return null
}
try {
// The snapshot content is a JavaScript object literal, parse it
// biome-ignore lint/security/noGlobalEval: parsing vitest snapshot format requires eval
return eval(`(${match[1]})`) as ABMasterySnapshot
} catch (e) {
console.error('Error parsing snapshot:', e)
return null
}
}
// Categorize skill IDs for display
function getSkillCategory(skillId: string): 'fiveComplement' | 'tenComplement' | 'basic' {
if (skillId.startsWith('fiveComplements') || skillId.startsWith('fiveComplementsSub')) {
return 'fiveComplement'
}
if (skillId.startsWith('tenComplements') || skillId.startsWith('tenComplementsSub')) {
return 'tenComplement'
}
return 'basic'
}
// Generate a human-readable label for skill IDs
function getSkillLabel(skillId: string): string {
// Extract the formula part after the dot
const parts = skillId.split('.')
if (parts.length < 2) return skillId
const formula = parts[1]
// Categorize by type
if (skillId.startsWith('fiveComplements.')) {
return `5-comp: ${formula}`
}
if (skillId.startsWith('fiveComplementsSub.')) {
return `5-comp sub: ${formula}`
}
if (skillId.startsWith('tenComplements.')) {
return `10-comp: ${formula}`
}
if (skillId.startsWith('tenComplementsSub.')) {
return `10-comp sub: ${formula}`
}
return skillId
}
// Get color for skill based on category
function getSkillColor(skillId: string, index: number): string {
const category = getSkillCategory(skillId)
// Color palettes by category
const colors = {
fiveComplement: ['#eab308', '#facc15'], // yellows
tenComplement: ['#ef4444', '#f97316', '#dc2626', '#ea580c'], // reds/oranges
basic: ['#22c55e', '#16a34a'], // greens
}
const palette = colors[category]
return palette[index % palette.length]
}
function generateReport(data: ABMasterySnapshot) {
const skills = data.summary.skills
return {
generatedAt: new Date().toISOString(),
version: '1.0',
// Config used to generate this data
config: data.config,
// Summary statistics
summary: {
totalSkills: skills.length,
adaptiveWins50: data.summary.adaptiveWins50,
classicWins50: data.summary.classicWins50,
ties50: data.summary.ties50,
adaptiveWins80: data.summary.adaptiveWins80,
classicWins80: data.summary.classicWins80,
ties80: data.summary.ties80,
},
// Session labels (x-axis)
sessions: Array.from({ length: data.config.sessionCount }, (_, i) => i + 1),
// Skills with their trajectory data
skills: skills.map((skillId, i) => {
const trajectory = data.trajectories[skillId]
return {
id: skillId,
label: getSkillLabel(skillId),
category: getSkillCategory(skillId),
color: getSkillColor(skillId, i),
adaptive: {
data: trajectory.adaptive.map((p) => Math.round(p.mastery * 100)),
sessionsTo50: trajectory.sessionsTo50Adaptive,
sessionsTo80: trajectory.sessionsTo80Adaptive,
},
classic: {
data: trajectory.classic.map((p) => Math.round(p.mastery * 100)),
sessionsTo50: trajectory.sessionsTo50Classic,
sessionsTo80: trajectory.sessionsTo80Classic,
},
}
}),
// Summary table for comparison
comparisonTable: skills.map((skillId) => {
const trajectory = data.trajectories[skillId]
const sessionsTo80Adaptive = trajectory.sessionsTo80Adaptive
const sessionsTo80Classic = trajectory.sessionsTo80Classic
// Calculate advantage
let advantage: string | null = null
if (sessionsTo80Adaptive !== null && sessionsTo80Classic !== null) {
const diff = sessionsTo80Classic - sessionsTo80Adaptive
if (diff > 0) {
advantage = `Adaptive +${diff} sessions`
} else if (diff < 0) {
advantage = `Classic +${Math.abs(diff)} sessions`
} else {
advantage = 'Tie'
}
} else if (sessionsTo80Adaptive !== null && sessionsTo80Classic === null) {
advantage = 'Adaptive (Classic never reached 80%)'
} else if (sessionsTo80Adaptive === null && sessionsTo80Classic !== null) {
advantage = 'Classic (Adaptive never reached 80%)'
}
return {
skill: getSkillLabel(skillId),
category: getSkillCategory(skillId),
adaptiveTo80: sessionsTo80Adaptive,
classicTo80: sessionsTo80Classic,
advantage,
}
}),
}
}
async function main() {
console.log('Reading snapshot file...')
if (!fs.existsSync(SNAPSHOT_PATH)) {
console.error(`Snapshot file not found: ${SNAPSHOT_PATH}`)
console.log(
'Run the tests first: npx vitest run src/test/journey-simulator/skill-difficulty.test.ts'
)
process.exit(1)
}
const snapshotContent = fs.readFileSync(SNAPSHOT_PATH, 'utf-8')
console.log('Parsing snapshots...')
const data = parseSnapshotFile(snapshotContent)
if (!data) {
console.error('Failed to parse snapshot data')
process.exit(1)
}
console.log('Generating report...')
const report = generateReport(data)
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_PATH)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(report, null, 2))
console.log(`Report written to: ${OUTPUT_PATH}`)
// Print summary
console.log('\n--- Summary ---')
console.log(`Skills analyzed: ${report.summary.totalSkills}`)
console.log(`Sessions: ${report.config.sessionCount}`)
console.log(`\nAt 50% mastery threshold:`)
console.log(` Adaptive wins: ${report.summary.adaptiveWins50}`)
console.log(` Classic wins: ${report.summary.classicWins50}`)
console.log(` Ties: ${report.summary.ties50}`)
console.log(`\nAt 80% mastery threshold:`)
console.log(` Adaptive wins: ${report.summary.adaptiveWins80}`)
console.log(` Classic wins: ${report.summary.classicWins80}`)
console.log(` Ties: ${report.summary.ties80}`)
console.log('\n--- Comparison Table ---')
for (const row of report.comparisonTable) {
const a80 = row.adaptiveTo80 !== null ? row.adaptiveTo80 : 'never'
const c80 = row.classicTo80 !== null ? row.classicTo80 : 'never'
console.log(`${row.skill}: Adaptive ${a80}, Classic ${c80}${row.advantage}`)
}
}
main().catch(console.error)

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env tsx
/**
* Generate JSON data from skill difficulty test snapshots.
*
* This script reads the Vitest snapshot file and extracts the data
* into a JSON format that can be consumed by the blog post charts.
*
* Usage: npx tsx scripts/generateSkillDifficultyData.ts
* Output: public/data/skill-difficulty-report.json
*/
import fs from 'fs'
import path from 'path'
const SNAPSHOT_PATH = path.join(
process.cwd(),
'src/test/journey-simulator/__snapshots__/skill-difficulty.test.ts.snap'
)
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/skill-difficulty-report.json')
interface SnapshotData {
learningTrajectory: {
exposuresToMastery: Record<string, number>
categoryAverages: Record<string, number>
}
masteryCurves: {
table: Array<{
exposures: number
[key: string]: string | number
}>
}
fiftyPercentThresholds: {
exposuresFor50Percent: Record<string, number>
ratiosRelativeToBasic: Record<string, string>
}
abComparison: {
withDifficulty: Record<string, number[]>
withoutDifficulty: Record<string, number[]>
summary: {
withDifficulty: Record<string, { avgAt20: number }>
withoutDifficulty: Record<string, { avgAt20: number }>
}
}
learningExpectations: {
at20Exposures: Record<string, string>
gapBetweenEasiestAndHardest: string
}
exposureRatio: {
basicExposures: number
tenCompExposures: number
ratio: string
targetMastery: string
}
}
function parseSnapshotFile(content: string): SnapshotData {
// Extract each snapshot export using regex
const extractSnapshot = (name: string): unknown => {
const regex = new RegExp(
`exports\\[\`[^\\]]*${name}[^\\]]*\`\\]\\s*=\\s*\`([\\s\\S]*?)\`;`,
'm'
)
const match = content.match(regex)
if (!match) {
console.warn(`Warning: Could not find snapshot: ${name}`)
return null
}
try {
// The snapshot content is a JavaScript object literal, parse it
// eslint-disable-next-line no-eval
return eval(`(${match[1]})`)
} catch (e) {
console.error(`Error parsing snapshot ${name}:`, e)
return null
}
}
const learningTrajectory = extractSnapshot('learning-trajectory-by-category') as {
exposuresToMastery: Record<string, number>
categoryAverages: Record<string, number>
}
const masteryCurvesRaw = extractSnapshot('mastery-curves-table') as {
table: Array<Record<string, string | number>>
}
const fiftyPercent = extractSnapshot('fifty-percent-threshold-ratios') as {
exposuresFor50Percent: Record<string, number>
ratiosRelativeToBasic: Record<string, string>
}
const abComparison = extractSnapshot('skill-difficulty-ab-comparison') as {
withDifficulty: Record<string, number[]>
withoutDifficulty: Record<string, number[]>
summary: {
withDifficulty: Record<string, { avgAt20: number }>
withoutDifficulty: Record<string, { avgAt20: number }>
}
}
const learningExpectations = extractSnapshot('learning-expectations-validation') as {
at20Exposures: Record<string, string>
gapBetweenEasiestAndHardest: string
}
const exposureRatio = extractSnapshot('exposure-ratio-for-equal-mastery') as {
basicExposures: number
tenCompExposures: number
ratio: string
targetMastery: string
}
return {
learningTrajectory,
masteryCurves: masteryCurvesRaw,
fiftyPercentThresholds: fiftyPercent,
abComparison,
learningExpectations,
exposureRatio,
}
}
function generateReport(data: SnapshotData) {
const exposurePoints = [5, 10, 15, 20, 25, 30, 40, 50]
return {
generatedAt: new Date().toISOString(),
version: '1.0',
// Summary stats
summary: {
basicAvgExposures: data.learningTrajectory?.categoryAverages?.basic ?? 17,
fiveCompAvgExposures: data.learningTrajectory?.categoryAverages?.fiveComplement ?? 24,
tenCompAvgExposures: data.learningTrajectory?.categoryAverages?.tenComplement ?? 36,
gapAt20Exposures:
data.learningExpectations?.gapBetweenEasiestAndHardest ?? '36.2 percentage points',
exposureRatioForEqualMastery: data.exposureRatio?.ratio ?? '1.92',
},
// Data for mastery curves chart
masteryCurves: {
exposurePoints,
skills: [
{
id: 'basic.directAddition',
label: 'Basic (0.8x)',
category: 'basic',
color: '#22c55e', // green
data: data.abComparison?.withDifficulty?.['basic.directAddition']?.map(
(v) => v * 100
) ?? [28, 61, 78, 86, 91, 93, 96, 98],
},
{
id: 'fiveComplements.4=5-1',
label: 'Five-Complement (1.2x)',
category: 'fiveComplement',
color: '#eab308', // yellow
data: data.abComparison?.withDifficulty?.['fiveComplements.4=5-1']?.map(
(v) => v * 100
) ?? [15, 41, 61, 74, 81, 86, 92, 95],
},
{
id: 'tenComplements.9=10-1',
label: 'Ten-Complement Easy (1.6x)',
category: 'tenComplement',
color: '#f97316', // orange
data: data.abComparison?.withDifficulty?.['tenComplements.9=10-1']?.map(
(v) => v * 100
) ?? [9, 28, 47, 61, 71, 78, 86, 91],
},
{
id: 'tenComplements.1=10-9',
label: 'Ten-Complement Hard (2.0x)',
category: 'tenComplement',
color: '#ef4444', // red
data: data.abComparison?.withDifficulty?.['tenComplements.1=10-9']?.map(
(v) => v * 100
) ?? [6, 20, 36, 50, 61, 69, 80, 86],
},
],
},
// Data for A/B comparison chart
abComparison: {
exposurePoints,
withDifficulty: data.abComparison?.summary?.withDifficulty ?? {},
withoutDifficulty: data.abComparison?.summary?.withoutDifficulty ?? {},
},
// Data for exposures to mastery bar chart
exposuresToMastery: {
target: '80%',
categories: [
{
name: 'Basic Skills',
avgExposures: data.learningTrajectory?.categoryAverages?.basic ?? 17,
color: '#22c55e',
skills: Object.entries(data.learningTrajectory?.exposuresToMastery ?? {})
.filter(([k]) => k.startsWith('basic.'))
.map(([k, v]) => ({ id: k, exposures: v })),
},
{
name: 'Five-Complements',
avgExposures: data.learningTrajectory?.categoryAverages?.fiveComplement ?? 24,
color: '#eab308',
skills: Object.entries(data.learningTrajectory?.exposuresToMastery ?? {})
.filter(([k]) => k.startsWith('fiveComplements.'))
.map(([k, v]) => ({ id: k, exposures: v })),
},
{
name: 'Ten-Complements',
avgExposures: data.learningTrajectory?.categoryAverages?.tenComplement ?? 36,
color: '#ef4444',
skills: Object.entries(data.learningTrajectory?.exposuresToMastery ?? {})
.filter(([k]) => k.startsWith('tenComplements.'))
.map(([k, v]) => ({ id: k, exposures: v })),
},
],
},
// Data for 50% threshold comparison
fiftyPercentThresholds: data.fiftyPercentThresholds ?? {
exposuresFor50Percent: {
'basic.directAddition': 8,
'fiveComplements.4=5-1': 12,
'tenComplements.9=10-1': 16,
'tenComplements.1=10-9': 20,
},
ratiosRelativeToBasic: {
'basic.directAddition': '1.00',
'fiveComplements.4=5-1': '1.50',
'tenComplements.9=10-1': '2.00',
'tenComplements.1=10-9': '2.50',
},
},
// Mastery table for tabular display
masteryTable: data.masteryCurves?.table ?? [],
}
}
async function main() {
console.log('Reading snapshot file...')
if (!fs.existsSync(SNAPSHOT_PATH)) {
console.error(`Snapshot file not found: ${SNAPSHOT_PATH}`)
console.log(
'Run the tests first: npx vitest run src/test/journey-simulator/skill-difficulty.test.ts'
)
process.exit(1)
}
const snapshotContent = fs.readFileSync(SNAPSHOT_PATH, 'utf-8')
console.log('Parsing snapshots...')
const data = parseSnapshotFile(snapshotContent)
console.log('Generating report...')
const report = generateReport(data)
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_PATH)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(report, null, 2))
console.log(`Report written to: ${OUTPUT_PATH}`)
// Print summary
console.log('\n--- Summary ---')
console.log(`Basic skills avg: ${report.summary.basicAvgExposures} exposures to 80%`)
console.log(`Five-complements avg: ${report.summary.fiveCompAvgExposures} exposures to 80%`)
console.log(`Ten-complements avg: ${report.summary.tenCompAvgExposures} exposures to 80%`)
console.log(`Gap at 20 exposures: ${report.summary.gapAt20Exposures}`)
console.log(`Exposure ratio (ten-comp/basic): ${report.summary.exposureRatioForEqualMastery}x`)
}
main().catch(console.error)

View File

@@ -0,0 +1,345 @@
#!/usr/bin/env npx tsx
/**
* Generate A/B mastery trajectory data for all skills.
* Runs simulations directly without vitest overhead.
*
* Usage: npx tsx scripts/generateTrajectoryData.ts
* Output: public/data/ab-mastery-trajectories.json
*/
import fs from 'fs'
import path from 'path'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from '../src/db/schema'
import { SeededRandom } from '../src/test/journey-simulator/SeededRandom'
import { SimulatedStudent } from '../src/test/journey-simulator/SimulatedStudent'
import type { StudentProfile, JourneyConfig } from '../src/test/journey-simulator/types'
// All skills in the curriculum
const ALL_SKILLS = [
// Basic skills (6)
'basic.directAddition',
'basic.directSubtraction',
'basic.heavenBead',
'basic.heavenBeadSubtraction',
'basic.simpleCombinations',
'basic.simpleCombinationsSub',
// Five complements addition (4)
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
// Five complements subtraction (4)
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
// Ten complements addition (9)
'tenComplements.9=10-1',
'tenComplements.8=10-2',
'tenComplements.7=10-3',
'tenComplements.6=10-4',
'tenComplements.5=10-5',
'tenComplements.4=10-6',
'tenComplements.3=10-7',
'tenComplements.2=10-8',
'tenComplements.1=10-9',
// Ten complements subtraction (9)
'tenComplementsSub.-9=+1-10',
'tenComplementsSub.-8=+2-10',
'tenComplementsSub.-7=+3-10',
'tenComplementsSub.-6=+4-10',
'tenComplementsSub.-5=+5-10',
'tenComplementsSub.-4=+6-10',
'tenComplementsSub.-3=+7-10',
'tenComplementsSub.-2=+8-10',
'tenComplementsSub.-1=+9-10',
// Advanced (2)
'advanced.cascadingCarry',
'advanced.cascadingBorrow',
]
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/ab-mastery-trajectories.json')
interface TrajectoryPoint {
session: number
mastery: number
}
interface SkillTrajectory {
adaptive: TrajectoryPoint[]
classic: TrajectoryPoint[]
sessionsTo50Adaptive: number | null
sessionsTo50Classic: number | null
sessionsTo80Adaptive: number | null
sessionsTo80Classic: number | null
}
// Simplified journey runner that just tracks mastery over sessions
function runSimplifiedJourney(
skillId: string,
profile: StudentProfile,
sessionCount: number,
seed: number
): TrajectoryPoint[] {
const rng = new SeededRandom(seed)
const student = new SimulatedStudent(profile, rng)
const trajectory: TrajectoryPoint[] = []
for (let session = 1; session <= sessionCount; session++) {
// Simulate ~20 problems per session that exercise this skill
for (let problem = 0; problem < 20; problem++) {
// Simulate answering a problem with this skill
const probability = student.getTrueProbability([skillId])
const isCorrect = rng.chance(probability)
// Increment exposure (learning happens from practice)
student.incrementExposure(skillId)
}
// Record mastery at end of session
const mastery = student.getTrueProbability([skillId])
trajectory.push({ session, mastery })
}
return trajectory
}
function findSessionForMastery(trajectory: TrajectoryPoint[], threshold: number): number | null {
for (const point of trajectory) {
if (point.mastery >= threshold) {
return point.session
}
}
return null
}
function getSkillCategory(
skillId: string
): 'basic' | 'fiveComplement' | 'tenComplement' | 'advanced' {
if (skillId.startsWith('basic.')) return 'basic'
if (skillId.startsWith('fiveComplement')) return 'fiveComplement'
if (skillId.startsWith('tenComplement')) return 'tenComplement'
return 'advanced'
}
function getSkillLabel(skillId: string): string {
const parts = skillId.split('.')
if (parts.length < 2) return skillId
const formula = parts[1]
if (skillId.startsWith('basic.')) return `basic: ${formula}`
if (skillId.startsWith('fiveComplements.')) return `5-comp: ${formula}`
if (skillId.startsWith('fiveComplementsSub.')) return `5-comp sub: ${formula}`
if (skillId.startsWith('tenComplements.')) return `10-comp: ${formula}`
if (skillId.startsWith('tenComplementsSub.')) return `10-comp sub: ${formula}`
if (skillId.startsWith('advanced.')) return `advanced: ${formula}`
return skillId
}
function getSkillColor(category: string, index: number): string {
const palettes: Record<string, string[]> = {
basic: ['#22c55e', '#16a34a', '#15803d', '#166534', '#14532d', '#052e16'],
fiveComplement: ['#eab308', '#facc15', '#fde047', '#fef08a'],
tenComplement: [
'#ef4444',
'#f97316',
'#dc2626',
'#ea580c',
'#b91c1c',
'#c2410c',
'#991b1b',
'#9a3412',
'#7f1d1d',
],
advanced: ['#8b5cf6', '#a78bfa'],
}
const palette = palettes[category] || palettes.basic
return palette[index % palette.length]
}
async function main() {
console.log('Generating A/B mastery trajectory data for full curriculum...')
console.log(`Skills to process: ${ALL_SKILLS.length}`)
console.log('')
const sessionCount = 12
const seed = 98765
// Profile for adaptive mode (BKT targeting)
const adaptiveProfile: StudentProfile = {
name: 'Adaptive Learner',
description: 'Student using adaptive mode',
halfMaxExposure: 10,
hillCoefficient: 2.0,
initialExposures: {}, // Start from zero
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02],
helpBonuses: [0, 0.05, 0.12, 0.25],
baseResponseTimeMs: 5000,
responseTimeVariance: 0.3,
}
// Profile for classic mode (no BKT targeting, same learning rate)
const classicProfile: StudentProfile = {
...adaptiveProfile,
name: 'Classic Learner',
description: 'Student using classic mode',
}
const trajectories: Record<string, SkillTrajectory> = {}
const startTime = Date.now()
for (let i = 0; i < ALL_SKILLS.length; i++) {
const skillId = ALL_SKILLS[i]
const skillStart = Date.now()
process.stdout.write(`[${i + 1}/${ALL_SKILLS.length}] ${skillId}... `)
// Run adaptive simulation
const adaptiveTrajectory = runSimplifiedJourney(skillId, adaptiveProfile, sessionCount, seed)
// Run classic simulation (different seed for variety)
const classicTrajectory = runSimplifiedJourney(
skillId,
classicProfile,
sessionCount,
seed + 1000
)
trajectories[skillId] = {
adaptive: adaptiveTrajectory,
classic: classicTrajectory,
sessionsTo50Adaptive: findSessionForMastery(adaptiveTrajectory, 0.5),
sessionsTo50Classic: findSessionForMastery(classicTrajectory, 0.5),
sessionsTo80Adaptive: findSessionForMastery(adaptiveTrajectory, 0.8),
sessionsTo80Classic: findSessionForMastery(classicTrajectory, 0.8),
}
const elapsed = Date.now() - skillStart
console.log(`done (${elapsed}ms)`)
}
// Compute summary
let adaptiveWins50 = 0,
classicWins50 = 0,
ties50 = 0
let adaptiveWins80 = 0,
classicWins80 = 0,
ties80 = 0
for (const skillId of ALL_SKILLS) {
const t = trajectories[skillId]
// 50% comparison
if (t.sessionsTo50Adaptive !== null && t.sessionsTo50Classic !== null) {
if (t.sessionsTo50Adaptive < t.sessionsTo50Classic) adaptiveWins50++
else if (t.sessionsTo50Adaptive > t.sessionsTo50Classic) classicWins50++
else ties50++
} else if (t.sessionsTo50Adaptive !== null) {
adaptiveWins50++
} else if (t.sessionsTo50Classic !== null) {
classicWins50++
} else {
ties50++
}
// 80% comparison
if (t.sessionsTo80Adaptive !== null && t.sessionsTo80Classic !== null) {
if (t.sessionsTo80Adaptive < t.sessionsTo80Classic) adaptiveWins80++
else if (t.sessionsTo80Adaptive > t.sessionsTo80Classic) classicWins80++
else ties80++
} else if (t.sessionsTo80Adaptive !== null) {
adaptiveWins80++
} else if (t.sessionsTo80Classic !== null) {
classicWins80++
} else {
ties80++
}
}
// Build output
const categoryIndices: Record<string, number> = {}
const output = {
generatedAt: new Date().toISOString(),
version: '2.0',
config: { seed, sessionCount, sessionDurationMinutes: 15 },
summary: {
totalSkills: ALL_SKILLS.length,
adaptiveWins50,
classicWins50,
ties50,
adaptiveWins80,
classicWins80,
ties80,
},
sessions: Array.from({ length: sessionCount }, (_, i) => i + 1),
skills: ALL_SKILLS.map((skillId) => {
const category = getSkillCategory(skillId)
categoryIndices[category] = categoryIndices[category] || 0
const colorIndex = categoryIndices[category]++
const t = trajectories[skillId]
return {
id: skillId,
label: getSkillLabel(skillId),
category,
color: getSkillColor(category, colorIndex),
adaptive: {
data: t.adaptive.map((p) => Math.round(p.mastery * 100)),
sessionsTo50: t.sessionsTo50Adaptive,
sessionsTo80: t.sessionsTo80Adaptive,
},
classic: {
data: t.classic.map((p) => Math.round(p.mastery * 100)),
sessionsTo50: t.sessionsTo50Classic,
sessionsTo80: t.sessionsTo80Classic,
},
}
}),
comparisonTable: ALL_SKILLS.map((skillId) => {
const t = trajectories[skillId]
let advantage: string | null = null
if (t.sessionsTo80Adaptive !== null && t.sessionsTo80Classic !== null) {
const diff = t.sessionsTo80Classic - t.sessionsTo80Adaptive
if (diff > 0) advantage = `Adaptive +${diff} sessions`
else if (diff < 0) advantage = `Classic +${Math.abs(diff)} sessions`
else advantage = 'Tie'
} else if (t.sessionsTo80Adaptive !== null) {
advantage = 'Adaptive (Classic never reached 80%)'
} else if (t.sessionsTo80Classic !== null) {
advantage = 'Classic (Adaptive never reached 80%)'
}
return {
skill: getSkillLabel(skillId),
category: getSkillCategory(skillId),
adaptiveTo80: t.sessionsTo80Adaptive,
classicTo80: t.sessionsTo80Classic,
advantage,
}
}),
}
// Write output
const outputDir = path.dirname(OUTPUT_PATH)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2))
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
console.log('')
console.log(`=== Complete in ${totalTime}s ===`)
console.log(`Output: ${OUTPUT_PATH}`)
console.log('')
console.log('Summary:')
console.log(` 50% mastery: Adaptive ${adaptiveWins50}, Classic ${classicWins50}, Ties ${ties50}`)
console.log(` 80% mastery: Adaptive ${adaptiveWins80}, Classic ${classicWins80}, Ties ${ties80}`)
}
main().catch(console.error)

View File

@@ -0,0 +1,744 @@
#!/usr/bin/env npx tsx
/**
* Seed script to create multiple test students with different BKT scenarios.
*
* Creates students with realistic curriculum progressions:
* - "🔴 Multi-Skill Deficient" - Early L1, struggling with basics
* - "🟡 Single-Skill Blocker" - Mid L1, one five-complement blocking
* - "🟢 Progressing Nicely" - Mid L1, healthy mix
* - "⭐ Ready to Level Up" - End of L1 addition, all strong
* - "🚀 Overdue for Promotion" - Has mastered L1, ready for L2
*
* Session Mode Test Profiles:
* - "🎯 Remediation Test" - REMEDIATION mode (weak skills blocking promotion)
* - "📚 Progression Tutorial Test" - PROGRESSION mode (tutorial required)
* - "🚀 Progression Ready Test" - PROGRESSION mode (tutorial done)
* - "🏆 Maintenance Test" - MAINTENANCE mode (all skills strong)
*
* Usage: npm run seed:test-students
*/
import { createId } from '@paralleldrive/cuid2'
import { desc, eq } from 'drizzle-orm'
import { db, schema } from '../src/db'
import { computeBktFromHistory } from '../src/lib/curriculum/bkt'
import { BKT_THRESHOLDS } from '../src/lib/curriculum/config/bkt-integration'
import { getRecentSessionResults } from '../src/lib/curriculum/session-planner'
import type {
GeneratedProblem,
SessionPart,
SessionSummary,
SlotResult,
} from '../src/db/schema/session-plans'
// =============================================================================
// Test Student Profiles
// =============================================================================
interface SkillConfig {
skillId: string
targetAccuracy: number
problems: number
}
interface TestStudentProfile {
name: string
emoji: string
color: string
description: string
notes: string
/** Skills that should have isPracticing = true (realistic curriculum progression) */
practicingSkills: string[]
/** Skills with problem history (can include non-practicing for testing edge cases) */
skillHistory: SkillConfig[]
/** Curriculum phase this student is nominally at */
currentPhaseId: string
/** Skills that should have their tutorial marked as completed */
tutorialCompletedSkills?: string[]
/** Expected session mode for this profile */
expectedSessionMode?: 'remediation' | 'progression' | 'maintenance'
}
// =============================================================================
// Realistic Curriculum Skill Progressions
// =============================================================================
/** Early Level 1 - just learning basics */
const EARLY_L1_SKILLS = ['basic.directAddition', 'basic.heavenBead']
/** Mid Level 1 - basics strong, learning five complements */
const MID_L1_SKILLS = [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
]
/** Late Level 1 Addition - all addition skills */
const LATE_L1_ADD_SKILLS = [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
]
/** Complete Level 1 - includes subtraction basics */
const COMPLETE_L1_SKILLS = [
...LATE_L1_ADD_SKILLS,
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
]
/** Level 2 skills (ten complements for addition) */
const L2_ADD_SKILLS = [
'tenComplements.9=10-1',
'tenComplements.8=10-2',
'tenComplements.7=10-3',
'tenComplements.6=10-4',
]
// All test student profiles
const TEST_PROFILES: TestStudentProfile[] = [
{
name: '🔴 Multi-Skill Deficient',
emoji: '😰',
color: '#ef4444', // red
description: 'Struggling with many skills - needs intervention',
currentPhaseId: 'L1.add.+3.direct',
practicingSkills: EARLY_L1_SKILLS,
notes: `TEST STUDENT: Multi-Skill Deficient
This student is in early Level 1 and struggling with basic bead movements. Their BKT estimates show multiple weak skills in the foundational "basic" category.
Curriculum position: Early L1 (L1.add.+3.direct)
Practicing skills: basic.directAddition, basic.heavenBead
This profile represents a student who:
- Is struggling with the very basics of abacus operation
- May need hands-on teacher guidance
- Could benefit from slower progression and more scaffolding
- Might have difficulty with fine motor skills or conceptual understanding
Use this student to test how the UI handles intervention alerts for foundational skill deficits.`,
skillHistory: [
// Weak in basics - this is concerning at this stage
{ skillId: 'basic.directAddition', targetAccuracy: 0.35, problems: 15 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.28, problems: 12 },
],
},
{
name: '🟡 Single-Skill Blocker',
emoji: '🤔',
color: '#f59e0b', // amber
description: 'One weak skill blocking progress, others are fine',
currentPhaseId: 'L1.add.+2.five',
practicingSkills: MID_L1_SKILLS,
notes: `TEST STUDENT: Single-Skill Blocker
This student is progressing well through Level 1 but has ONE specific five-complement skill that's blocking advancement. Most skills are strong, but fiveComplements.3=5-2 is weak.
Curriculum position: Mid L1 (L1.add.+2.five)
Practicing skills: basics + first two five complements
The blocking skill is: fiveComplements.3=5-2 (adding 3 via +5-2)
This profile represents a student who:
- Understands the general concepts well
- Has a specific gap that needs targeted practice
- Should NOT be held back on other skills
- May benefit from focused tutoring on the specific technique
Use this student to test targeted intervention recommendations.`,
skillHistory: [
// Strong basics
{ skillId: 'basic.directAddition', targetAccuracy: 0.92, problems: 20 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.88, problems: 18 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.85, problems: 15 },
// Strong in first five complement
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.87, problems: 16 },
// THE BLOCKER - weak despite practice
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.22, problems: 18 },
],
},
{
name: '🟢 Progressing Nicely',
emoji: '😊',
color: '#22c55e', // green
description: 'Healthy progression - mix of developing and strong skills',
currentPhaseId: 'L1.add.+3.five',
practicingSkills: MID_L1_SKILLS,
notes: `TEST STUDENT: Progressing Nicely
This student shows a healthy learning trajectory - basics are solid, middle skills are developing, and newest skill is appropriately weak (just started).
Curriculum position: Mid L1 (L1.add.+3.five)
Practicing skills: basics + first two five complements
Skill status:
• Strong: basic.directAddition, basic.heavenBead (mastered)
• Developing: basic.simpleCombinations, fiveComplements.4=5-1
• Weak: fiveComplements.3=5-2 (just introduced, expected)
This is what a "healthy" student looks like - no intervention needed, just continue the curriculum.
Use this student to verify normal dashboard display without intervention flags.`,
skillHistory: [
// Strong basics (mastered)
{ skillId: 'basic.directAddition', targetAccuracy: 0.94, problems: 25 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.91, problems: 22 },
// Developing
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.55, problems: 10 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.52, problems: 8 },
// Just started (expected to be weak)
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.25, problems: 6 },
],
},
{
name: '⭐ Ready to Level Up',
emoji: '🌟',
color: '#8b5cf6', // violet
description: 'All skills strong - ready for next curriculum phase',
currentPhaseId: 'L1.add.+1.five',
practicingSkills: LATE_L1_ADD_SKILLS,
notes: `TEST STUDENT: Ready to Level Up
This student has mastered ALL Level 1 addition skills and is ready to move to subtraction or Level 2.
Curriculum position: End of L1 Addition (L1.add.+1.five - last addition phase)
Practicing skills: All Level 1 addition skills
All skills at strong mastery (85%+):
• basic.directAddition, heavenBead, simpleCombinations
• All four fiveComplements
This student should be promoted to L1 subtraction or could start L2 addition with carrying.
Use this student to test:
- "Ready to advance" indicators
- Promotion recommendations
- Session planning when all skills are strong`,
skillHistory: [
// All strong
{ skillId: 'basic.directAddition', targetAccuracy: 0.95, problems: 25 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.93, problems: 25 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.9, problems: 22 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.88, problems: 20 },
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.86, problems: 20 },
{ skillId: 'fiveComplements.2=5-3', targetAccuracy: 0.85, problems: 18 },
{ skillId: 'fiveComplements.1=5-4', targetAccuracy: 0.84, problems: 18 },
],
},
{
name: '🚀 Overdue for Promotion',
emoji: '🏆',
color: '#06b6d4', // cyan
description: 'All skills mastered long ago - should have leveled up already',
currentPhaseId: 'L2.add.+9.ten',
practicingSkills: [...COMPLETE_L1_SKILLS, ...L2_ADD_SKILLS],
notes: `TEST STUDENT: Overdue for Promotion
This student has MASSIVELY exceeded mastery requirements. They've mastered ALL of Level 1 (addition AND subtraction) plus several Level 2 skills!
Curriculum position: Should be deep in L2 (L2.add.+9.ten)
Practicing skills: Complete L1 + early L2
All skills at very high mastery (88-98%):
• ALL basic skills (addition and subtraction)
• ALL four fiveComplements (addition)
• ALL four fiveComplementsSub (subtraction)
• Four tenComplements (L2 addition with carrying)
This is a "red flag" scenario - the system should have advanced this student long ago.
Use this student to test:
- Urgent promotion alerts
- Detection of stale curriculum placement
- Over-mastery warnings`,
skillHistory: [
// Extremely strong basics
{ skillId: 'basic.directAddition', targetAccuracy: 0.98, problems: 35 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.97, problems: 35 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.96, problems: 30 },
{ skillId: 'basic.directSubtraction', targetAccuracy: 0.95, problems: 30 },
{ skillId: 'basic.heavenBeadSubtraction', targetAccuracy: 0.94, problems: 28 },
{ skillId: 'basic.simpleCombinationsSub', targetAccuracy: 0.93, problems: 28 },
// All five complements mastered
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.95, problems: 30 },
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.94, problems: 30 },
{ skillId: 'fiveComplements.2=5-3', targetAccuracy: 0.93, problems: 28 },
{ skillId: 'fiveComplements.1=5-4', targetAccuracy: 0.92, problems: 28 },
// Subtraction five complements too
{ skillId: 'fiveComplementsSub.-4=-5+1', targetAccuracy: 0.91, problems: 25 },
{ skillId: 'fiveComplementsSub.-3=-5+2', targetAccuracy: 0.9, problems: 25 },
{ skillId: 'fiveComplementsSub.-2=-5+3', targetAccuracy: 0.89, problems: 22 },
{ skillId: 'fiveComplementsSub.-1=-5+4', targetAccuracy: 0.88, problems: 22 },
// Even L2 ten complements
{ skillId: 'tenComplements.9=10-1', targetAccuracy: 0.9, problems: 20 },
{ skillId: 'tenComplements.8=10-2', targetAccuracy: 0.88, problems: 20 },
{ skillId: 'tenComplements.7=10-3', targetAccuracy: 0.87, problems: 18 },
{ skillId: 'tenComplements.6=10-4', targetAccuracy: 0.85, problems: 18 },
],
},
// =============================================================================
// Session Mode Test Profiles
// =============================================================================
{
name: '🎯 Remediation Test',
emoji: '🎯',
color: '#dc2626', // red-600
description: 'REMEDIATION MODE - Weak skills blocking promotion',
currentPhaseId: 'L1.add.+3.five',
practicingSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
],
expectedSessionMode: 'remediation',
notes: `TEST STUDENT: Remediation Mode
This student is specifically configured to trigger REMEDIATION mode.
Session Mode: REMEDIATION (with blocked promotion)
What you should see:
• SessionModeBanner shows "Skills need practice" with weak skills listed
• Banner shows blocked promotion: "Ready for +3 (five-complement) once skills are strong"
• StartPracticeModal shows remediation-focused CTA
How it works:
• Has 4 skills practicing: basic.directAddition, heavenBead, simpleCombinations, fiveComplements.4=5-1
• Two skills have low accuracy (< 50%) with enough problems to be confident
• The next skill (fiveComplements.3=5-2) is available but blocked by weak skills
Use this to test the remediation UI in dashboard and modal.`,
tutorialCompletedSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
],
skillHistory: [
// Strong skills
{ skillId: 'basic.directAddition', targetAccuracy: 0.92, problems: 20 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.88, problems: 18 },
// WEAK skills - will trigger remediation
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.35, problems: 15 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.28, problems: 18 },
],
},
{
name: '📚 Progression Tutorial Test',
emoji: '📚',
color: '#7c3aed', // violet-600
description: 'PROGRESSION MODE - Ready for new skill, tutorial required',
currentPhaseId: 'L1.add.+3.five',
practicingSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
],
expectedSessionMode: 'progression',
notes: `TEST STUDENT: Progression Mode (Tutorial Required)
This student is specifically configured to trigger PROGRESSION mode with tutorial gate.
Session Mode: PROGRESSION (tutorialRequired: true)
What you should see:
• SessionModeBanner shows "New Skill Available" with next skill name
• Banner has "Start Tutorial" button (not "Start Practice")
• StartPracticeModal shows tutorial CTA with skill description
How it works:
• Has 4 skills practicing, ALL are strong (>= 80% accuracy)
• The next skill in curriculum (fiveComplements.3=5-2) is available
• Tutorial for that skill has NOT been completed
Use this to test the progression UI and tutorial gate flow.`,
tutorialCompletedSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
// NOTE: fiveComplements.3=5-2 tutorial NOT completed - triggers tutorial gate
],
skillHistory: [
// All skills STRONG (>= 80% accuracy)
{ skillId: 'basic.directAddition', targetAccuracy: 0.95, problems: 25 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.92, problems: 22 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.88, problems: 20 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.85, problems: 20 },
],
},
{
name: '🚀 Progression Ready Test',
emoji: '🚀',
color: '#059669', // emerald-600
description: 'PROGRESSION MODE - Tutorial done, ready to practice',
currentPhaseId: 'L1.add.+3.five',
practicingSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
],
expectedSessionMode: 'progression',
notes: `TEST STUDENT: Progression Mode (Tutorial Already Done)
This student is specifically configured to trigger PROGRESSION mode with tutorial satisfied.
Session Mode: PROGRESSION (tutorialRequired: false)
What you should see:
• SessionModeBanner shows "New Skill Available" with next skill name
• Banner has "Start Practice" button (tutorial already done)
• StartPracticeModal shows practice CTA (may show skip count if any)
How it works:
• Has 4 skills practicing, ALL are strong (>= 80% accuracy)
• The next skill in curriculum (fiveComplements.3=5-2) is available
• Tutorial for that skill HAS been completed (tutorialCompleted: true)
Use this to test the progression UI when tutorial is already satisfied.`,
tutorialCompletedSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2', // Tutorial already completed!
],
skillHistory: [
// All skills STRONG (>= 80% accuracy)
{ skillId: 'basic.directAddition', targetAccuracy: 0.95, problems: 25 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.92, problems: 22 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.88, problems: 20 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.85, problems: 20 },
],
},
{
name: '🏆 Maintenance Test',
emoji: '🏆',
color: '#0891b2', // cyan-600
description: 'MAINTENANCE MODE - All skills strong, mixed practice',
currentPhaseId: 'L1.add.+4.five',
practicingSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
],
expectedSessionMode: 'maintenance',
notes: `TEST STUDENT: Maintenance Mode
This student is specifically configured to trigger MAINTENANCE mode.
Session Mode: MAINTENANCE
What you should see:
• SessionModeBanner shows "Mixed Practice" or similar
• Banner indicates all skills are strong
• StartPracticeModal shows general practice CTA
How it works:
• Has 7 skills practicing (all L1 addition), ALL are strong (>= 80%)
• All practicing skills have enough history to be confident
• There IS a next skill available but this student is at a natural "pause" point
(actually to force maintenance, we make the next skill's tutorial NOT exist)
NOTE: True maintenance mode is rare in practice - usually there's always a next skill.
This profile demonstrates the maintenance case.
Use this to test the maintenance mode UI in dashboard and modal.`,
tutorialCompletedSkills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
],
skillHistory: [
// All skills STRONG (>= 80% accuracy) with high confidence
{ skillId: 'basic.directAddition', targetAccuracy: 0.95, problems: 30 },
{ skillId: 'basic.heavenBead', targetAccuracy: 0.93, problems: 28 },
{ skillId: 'basic.simpleCombinations', targetAccuracy: 0.9, problems: 25 },
{ skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.88, problems: 25 },
{ skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.87, problems: 22 },
{ skillId: 'fiveComplements.2=5-3', targetAccuracy: 0.86, problems: 22 },
{ skillId: 'fiveComplements.1=5-4', targetAccuracy: 0.85, problems: 20 },
],
},
]
// =============================================================================
// Helpers
// =============================================================================
function shuffleArray<T>(array: T[]): T[] {
const result = [...array]
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[result[i], result[j]] = [result[j], result[i]]
}
return result
}
function generateSlotResults(
config: SkillConfig,
startIndex: number,
sessionStartTime: Date
): SlotResult[] {
const correctCount = Math.round(config.problems * config.targetAccuracy)
const results: boolean[] = []
for (let i = 0; i < correctCount; i++) results.push(true)
for (let i = correctCount; i < config.problems; i++) results.push(false)
const shuffled = shuffleArray(results)
return shuffled.map((isCorrect, i) => {
const problem: GeneratedProblem = {
terms: [5, 4],
answer: 9,
skillsRequired: [config.skillId],
}
return {
partNumber: 1 as const,
slotIndex: startIndex + i,
problem,
studentAnswer: isCorrect ? 9 : 8,
isCorrect,
responseTimeMs: 4000 + Math.random() * 2000,
skillsExercised: [config.skillId],
usedOnScreenAbacus: false,
timestamp: new Date(sessionStartTime.getTime() + (startIndex + i) * 10000),
helpLevelUsed: 0 as const,
incorrectAttempts: isCorrect ? 0 : 1,
helpTrigger: 'none' as const,
}
})
}
async function createTestStudent(
profile: TestStudentProfile,
userId: string
): Promise<{ playerId: string; classifications: Record<string, number> }> {
// Delete existing player with this name
const existing = await db.query.players.findFirst({
where: eq(schema.players.name, profile.name),
})
if (existing) {
await db.delete(schema.players).where(eq(schema.players.id, existing.id))
}
// Create player
const playerId = createId()
await db.insert(schema.players).values({
id: playerId,
userId,
name: profile.name,
emoji: profile.emoji,
color: profile.color,
isActive: true,
notes: profile.notes,
})
// Create skill mastery records for practicing skills
for (const skillId of profile.practicingSkills) {
await db.insert(schema.playerSkillMastery).values({
id: createId(),
playerId,
skillId,
isPracticing: true,
attempts: 0,
correct: 0,
consecutiveCorrect: 0,
})
}
// Create tutorial progress records for completed tutorials
if (profile.tutorialCompletedSkills) {
for (const skillId of profile.tutorialCompletedSkills) {
await db.insert(schema.skillTutorialProgress).values({
id: createId(),
playerId,
skillId,
tutorialCompleted: true,
completedAt: new Date(Date.now() - 48 * 60 * 60 * 1000), // 2 days ago
teacherOverride: false,
skipCount: 0,
})
}
}
// Generate results from skill history
const sessionStartTime = new Date(Date.now() - 24 * 60 * 60 * 1000)
const allResults: SlotResult[] = []
let currentIndex = 0
for (const config of profile.skillHistory) {
const results = generateSlotResults(config, currentIndex, sessionStartTime)
allResults.push(...results)
currentIndex += config.problems
}
const shuffledResults = shuffleArray(allResults).map((r, i) => ({
...r,
slotIndex: i,
timestamp: new Date(sessionStartTime.getTime() + i * 10000),
}))
// Create session
const sessionId = createId()
const sessionEndTime = new Date(sessionStartTime.getTime() + shuffledResults.length * 10000)
const slots = shuffledResults.map((r, i) => ({
index: i,
purpose: 'focus' as const,
constraints: {},
problem: r.problem,
}))
const parts: SessionPart[] = [
{
partNumber: 1,
type: 'linear',
format: 'linear',
useAbacus: false,
slots,
estimatedMinutes: 30,
},
]
const summary: SessionSummary = {
focusDescription: `Test session for ${profile.name}`,
totalProblemCount: shuffledResults.length,
estimatedMinutes: 30,
parts: [
{
partNumber: 1,
type: 'linear',
description: 'Mental Math (Linear)',
problemCount: shuffledResults.length,
estimatedMinutes: 30,
},
],
}
await db.insert(schema.sessionPlans).values({
id: sessionId,
playerId,
targetDurationMinutes: 30,
estimatedProblemCount: shuffledResults.length,
avgTimePerProblemSeconds: 5,
parts,
summary,
masteredSkillIds: profile.practicingSkills,
status: 'completed',
currentPartIndex: 1,
currentSlotIndex: 0,
sessionHealth: {
overall: 'good',
accuracy: 0.6,
pacePercent: 100,
currentStreak: 0,
avgResponseTimeMs: 5000,
},
adjustments: [],
results: shuffledResults,
createdAt: sessionStartTime,
approvedAt: sessionStartTime,
startedAt: sessionStartTime,
completedAt: sessionEndTime,
})
// Get classifications
const problemHistory = await getRecentSessionResults(playerId, 50)
const bktResult = computeBktFromHistory(problemHistory, {
confidenceThreshold: BKT_THRESHOLDS.confidence,
})
const classifications: Record<string, number> = { weak: 0, developing: 0, strong: 0 }
for (const skill of bktResult.skills) {
if (skill.masteryClassification) {
classifications[skill.masteryClassification]++
}
}
return { playerId, classifications }
}
// =============================================================================
// Main
// =============================================================================
async function main() {
console.log('🧪 Seeding Test Students for BKT Testing...\n')
// Find the most recent browser session by looking at most recently created player
// (Players are created when users visit /practice, so this reflects the latest browser activity)
console.log('1. Finding most recent browser session...')
const recentPlayer = await db.query.players.findFirst({
where: (players, { not, like }) => not(like(players.name, '%Test%')),
orderBy: [desc(schema.players.createdAt)],
})
if (!recentPlayer) {
console.error('❌ No players found! Create a student at /practice first.')
process.exit(1)
}
const userId = recentPlayer.userId
console.log(` Found user via most recent player: ${recentPlayer.name}`)
// Create each test profile
console.log('\n2. Creating test students...\n')
for (const profile of TEST_PROFILES) {
const { playerId, classifications } = await createTestStudent(profile, userId)
const { weak, developing, strong } = classifications
console.log(` ${profile.emoji} ${profile.name}`)
console.log(` ${profile.description}`)
console.log(` Phase: ${profile.currentPhaseId}`)
console.log(` Practicing: ${profile.practicingSkills.length} skills`)
console.log(
` Classifications: 🔴 ${weak} weak, 🟡 ${developing} developing, 🟢 ${strong} strong`
)
if (profile.expectedSessionMode) {
console.log(` Expected Mode: ${profile.expectedSessionMode.toUpperCase()}`)
}
if (profile.tutorialCompletedSkills) {
console.log(` Tutorials Completed: ${profile.tutorialCompletedSkills.length} skills`)
}
console.log(` Player ID: ${playerId}`)
console.log('')
}
console.log('✅ All test students created!')
console.log('\n Visit http://localhost:3000/practice to see them.')
}
main().catch((err) => {
console.error('Error seeding test students:', err)
process.exit(1)
})

View File

@@ -0,0 +1,590 @@
'use client'
import Link from 'next/link'
import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PageWithNav } from '@/components/PageWithNav'
import { BktProvider, useBktConfig, useSkillsByClassification } from '@/contexts/BktContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useBktSettings, useUpdateBktSettings } from '@/hooks/useBktSettings'
import { api } from '@/lib/queryClient'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { css } from '../../../../styled-system/css'
interface Student {
id: string
name: string
}
interface BktSettingsClientProps {
students: Student[]
}
/**
* Fetch problem history for a specific student
*/
async function fetchStudentProblemHistory(studentId: string): Promise<ProblemResultWithContext[]> {
const res = await api(`curriculum/${studentId}/problem-history`)
if (!res.ok) return []
const data = await res.json()
return data.history ?? []
}
/**
* Fetch aggregate BKT stats across all students
*/
async function fetchAggregateBktStats(threshold: number): Promise<{
totalStudents: number
totalSkills: number
struggling: number
learning: number
mastered: number
}> {
const res = await api(`settings/bkt/aggregate?threshold=${threshold}`)
if (!res.ok) {
return { totalStudents: 0, totalSkills: 0, struggling: 0, learning: 0, mastered: 0 }
}
return res.json()
}
export function BktSettingsClient({ students }: BktSettingsClientProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Fetch saved threshold
const { data: settings, isLoading: isLoadingSettings } = useBktSettings()
const savedThreshold = settings?.bktConfidenceThreshold ?? 0.3
// Update mutation
const updateMutation = useUpdateBktSettings()
// Local preview threshold
const [previewThreshold, setPreviewThreshold] = useState<number | null>(null)
const effectiveThreshold = previewThreshold ?? savedThreshold
// View mode: aggregate or single student
const [viewMode, setViewMode] = useState<'aggregate' | 'student'>('aggregate')
const [selectedStudentId, setSelectedStudentId] = useState<string | null>(null)
// Fetch student problem history when a student is selected
const { data: studentHistory, isLoading: isLoadingHistory } = useQuery({
queryKey: ['student-problem-history', selectedStudentId],
queryFn: () => fetchStudentProblemHistory(selectedStudentId!),
enabled: viewMode === 'student' && !!selectedStudentId,
staleTime: 60000,
})
// Fetch aggregate stats
const { data: aggregateStats, isLoading: isLoadingAggregate } = useQuery({
queryKey: ['aggregate-bkt-stats', effectiveThreshold],
queryFn: () => fetchAggregateBktStats(effectiveThreshold),
enabled: viewMode === 'aggregate',
staleTime: 30000,
})
const hasChanges = previewThreshold !== null && previewThreshold !== savedThreshold
const handleSave = useCallback(() => {
if (previewThreshold !== null) {
updateMutation.mutate(previewThreshold, {
onSuccess: () => {
setPreviewThreshold(null)
},
})
}
}, [previewThreshold, updateMutation])
const handleReset = useCallback(() => {
setPreviewThreshold(null)
}, [])
const handleSliderChange = useCallback((value: number) => {
setPreviewThreshold(value)
}, [])
return (
<PageWithNav>
<main
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
padding: '2rem',
})}
>
<div className={css({ maxWidth: '800px', margin: '0 auto' })}>
{/* Header */}
<header className={css({ marginBottom: '2rem' })}>
<Link
href="/practice"
className={css({
fontSize: '0.875rem',
color: isDark ? 'blue.400' : 'blue.600',
textDecoration: 'none',
_hover: { textDecoration: 'underline' },
marginBottom: '0.5rem',
display: 'inline-block',
})}
>
Back to Practice
</Link>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
})}
>
BKT Confidence Threshold
</h1>
<p className={css({ color: isDark ? 'gray.400' : 'gray.600', marginTop: '0.5rem' })}>
Configure how much evidence is required before trusting skill classifications.
</p>
</header>
{/* Settings Card */}
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '1.5rem',
marginBottom: '1.5rem',
})}
>
{/* Slider */}
<div className={css({ marginBottom: '1.5rem' })}>
<label className={css({ display: 'block', marginBottom: '0.75rem' })}>
<span
className={css({
fontWeight: '600',
color: isDark ? 'white' : 'gray.800',
display: 'block',
marginBottom: '0.25rem',
})}
>
Confidence Threshold
</span>
<span
className={css({ fontSize: '0.875rem', color: isDark ? 'gray.400' : 'gray.600' })}
>
Higher values require more practice data before classifying skills.
</span>
</label>
<div className={css({ display: 'flex', alignItems: 'center', gap: '1rem' })}>
<input
type="range"
min="0.1"
max="0.9"
step="0.05"
value={effectiveThreshold}
onChange={(e) => handleSliderChange(Number(e.target.value))}
disabled={isLoadingSettings}
className={css({ flex: 1, accentColor: isDark ? 'blue.400' : 'blue.600' })}
/>
<span
className={css({
fontWeight: 'bold',
fontSize: '1.25rem',
color: isDark ? 'white' : 'gray.800',
minWidth: '4rem',
textAlign: 'right',
})}
>
{isLoadingSettings ? '...' : `${(effectiveThreshold * 100).toFixed(0)}%`}
</span>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginTop: '0.25rem',
})}
>
<span>Aggressive (10%)</span>
<span>Conservative (90%)</span>
</div>
</div>
{/* Save/Reset buttons */}
<div className={css({ display: 'flex', gap: '0.75rem', alignItems: 'center' })}>
<button
type="button"
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
className={css({
padding: '0.5rem 1rem',
backgroundColor: hasChanges ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
color: hasChanges ? 'white' : isDark ? 'gray.500' : 'gray.500',
borderRadius: '6px',
border: 'none',
fontWeight: '600',
cursor: hasChanges ? 'pointer' : 'not-allowed',
_hover: hasChanges ? { backgroundColor: 'blue.600' } : {},
})}
>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</button>
{hasChanges && (
<button
type="button"
onClick={handleReset}
className={css({
padding: '0.5rem 1rem',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.600',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
cursor: 'pointer',
_hover: { borderColor: isDark ? 'gray.500' : 'gray.400' },
})}
>
Reset to {(savedThreshold * 100).toFixed(0)}%
</button>
)}
{hasChanges && (
<span className={css({ fontSize: '0.875rem', color: 'orange.500' })}>
Unsaved changes
</span>
)}
</div>
</div>
{/* Preview Section */}
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '1.5rem',
})}
>
<h2
className={css({
fontWeight: '600',
color: isDark ? 'white' : 'gray.800',
marginBottom: '1rem',
})}
>
Preview
</h2>
{/* View mode toggle */}
<div className={css({ display: 'flex', gap: '0.5rem', marginBottom: '1rem' })}>
<button
type="button"
onClick={() => setViewMode('aggregate')}
className={css({
padding: '0.5rem 1rem',
backgroundColor:
viewMode === 'aggregate' ? 'blue.500' : isDark ? 'gray.700' : 'gray.200',
color: viewMode === 'aggregate' ? 'white' : isDark ? 'gray.300' : 'gray.700',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
fontWeight: viewMode === 'aggregate' ? '600' : 'normal',
})}
>
All Students
</button>
<button
type="button"
onClick={() => setViewMode('student')}
className={css({
padding: '0.5rem 1rem',
backgroundColor:
viewMode === 'student' ? 'blue.500' : isDark ? 'gray.700' : 'gray.200',
color: viewMode === 'student' ? 'white' : isDark ? 'gray.300' : 'gray.700',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
fontWeight: viewMode === 'student' ? '600' : 'normal',
})}
>
Single Student
</button>
</div>
{/* Student selector (when in student mode) */}
{viewMode === 'student' && (
<div className={css({ marginBottom: '1rem' })}>
<select
value={selectedStudentId ?? ''}
onChange={(e) => setSelectedStudentId(e.target.value || null)}
className={css({
width: '100%',
padding: '0.5rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'white' : 'gray.800',
})}
>
<option value="">Select a student...</option>
{students.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
{/* Preview content */}
{viewMode === 'aggregate' ? (
<AggregatePreview
stats={aggregateStats}
isLoading={isLoadingAggregate}
isDark={isDark}
/>
) : selectedStudentId ? (
<BktProvider problemHistory={studentHistory ?? []}>
<StudentPreview
studentName={students.find((s) => s.id === selectedStudentId)?.name ?? 'Student'}
isLoading={isLoadingHistory}
isDark={isDark}
previewThreshold={effectiveThreshold}
/>
</BktProvider>
) : (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500', fontStyle: 'italic' })}>
Select a student to preview their skill classifications.
</p>
)}
</div>
{/* Explanation */}
<div
className={css({
marginTop: '1.5rem',
padding: '1rem',
backgroundColor: isDark ? 'gray.800/50' : 'blue.50',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'blue.100',
})}
>
<h3
className={css({
fontWeight: '600',
color: isDark ? 'blue.300' : 'blue.800',
marginBottom: '0.5rem',
})}
>
How it works
</h3>
<ul
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
listStyleType: 'disc',
paddingLeft: '1.25rem',
'& li': { marginBottom: '0.25rem' },
})}
>
<li>
<strong>Confidence</strong> measures how much practice data we have for a skill
</li>
<li>
Skills below the threshold are classified as <strong>"Developing"</strong> (not
enough data)
</li>
<li>
Skills above the threshold are classified by their P(known) estimate:
<ul
className={css({
listStyleType: 'circle',
paddingLeft: '1rem',
marginTop: '0.25rem',
})}
>
<li>Weak: P(known) &lt; 50%</li>
<li>Developing: 50% P(known) &lt; 80%</li>
<li>Strong: P(known) 80%</li>
</ul>
</li>
<li>Lower threshold = more aggressive (classifies skills with less data)</li>
<li>Higher threshold = more conservative (needs more practice before classifying)</li>
</ul>
</div>
</div>
</main>
</PageWithNav>
)
}
/**
* Aggregate stats preview
*/
function AggregatePreview({
stats,
isLoading,
isDark,
}: {
stats?: {
totalStudents: number
totalSkills: number
struggling: number
learning: number
mastered: number
}
isLoading: boolean
isDark: boolean
}) {
if (isLoading) {
return <p className={css({ color: isDark ? 'gray.500' : 'gray.500' })}>Loading...</p>
}
if (!stats || stats.totalStudents === 0) {
return (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500', fontStyle: 'italic' })}>
No students with practice data found.
</p>
)
}
return (
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1rem' })}>
<StatCard label="Total Skills" value={stats.totalSkills} color="blue" isDark={isDark} />
<StatCard label="Weak" value={stats.struggling} color="red" isDark={isDark} />
<StatCard label="Developing" value={stats.learning} color="yellow" isDark={isDark} />
<StatCard label="Strong" value={stats.mastered} color="green" isDark={isDark} />
</div>
)
}
/**
* Single student preview using BKT context
*/
function StudentPreview({
studentName,
isLoading,
isDark,
previewThreshold,
}: {
studentName: string
isLoading: boolean
isDark: boolean
previewThreshold: number
}) {
const { setPreviewThreshold } = useBktConfig()
const { struggling, learning, mastered, hasData } = useSkillsByClassification()
// Set preview threshold in context
useMemo(() => {
setPreviewThreshold(previewThreshold)
}, [previewThreshold, setPreviewThreshold])
if (isLoading) {
return (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500' })}>
Loading {studentName}'s data...
</p>
)
}
if (!hasData) {
return (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500', fontStyle: 'italic' })}>
{studentName} has no practice data yet.
</p>
)
}
return (
<div>
<p className={css({ marginBottom: '1rem', color: isDark ? 'gray.300' : 'gray.700' })}>
{studentName}'s skills at {(previewThreshold * 100).toFixed(0)}% confidence:
</p>
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' })}>
<StatCard label="Weak" value={struggling.length} color="red" isDark={isDark} />
<StatCard label="Developing" value={learning.length} color="yellow" isDark={isDark} />
<StatCard label="Strong" value={mastered.length} color="green" isDark={isDark} />
</div>
{/* List struggling skills */}
{struggling.length > 0 && (
<div className={css({ marginTop: '1rem' })}>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: '600',
color: isDark ? 'red.300' : 'red.700',
marginBottom: '0.5rem',
})}
>
Weak Skills:
</h4>
<ul
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
listStyleType: 'disc',
paddingLeft: '1.25rem',
})}
>
{struggling.map((skill) => (
<li key={skill.skillId}>
{skill.displayName} ({(skill.pKnown * 100).toFixed(0)}% known)
</li>
))}
</ul>
</div>
)}
</div>
)
}
/**
* Stat card component
*/
function StatCard({
label,
value,
color,
isDark,
}: {
label: string
value: number
color: 'blue' | 'red' | 'yellow' | 'green'
isDark: boolean
}) {
const colorMap = {
blue: { bg: isDark ? 'blue.900/50' : 'blue.50', text: isDark ? 'blue.300' : 'blue.700' },
red: { bg: isDark ? 'red.900/50' : 'red.50', text: isDark ? 'red.300' : 'red.700' },
yellow: {
bg: isDark ? 'yellow.900/50' : 'yellow.50',
text: isDark ? 'yellow.300' : 'yellow.700',
},
green: { bg: isDark ? 'green.900/50' : 'green.50', text: isDark ? 'green.300' : 'green.700' },
}
return (
<div
className={css({
backgroundColor: colorMap[color].bg,
borderRadius: '8px',
padding: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: colorMap[color].text,
})}
>
{value}
</div>
<div className={css({ fontSize: '0.75rem', color: isDark ? 'gray.400' : 'gray.600' })}>
{label}
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { db } from '@/db'
import { players } from '@/db/schema'
import { BktSettingsClient } from './BktSettingsClient'
/**
* Admin page for configuring BKT confidence threshold.
*
* This setting affects how skills are classified across the entire app:
* - Skills with confidence below threshold are classified as 'learning'
* - Skills above threshold are classified by pKnown (struggling/learning/mastered)
*/
export default async function BktSettingsPage() {
// Fetch all students for the preview dropdown
const allStudents = await db
.select({ id: players.id, name: players.name })
.from(players)
.orderBy(players.name)
return <BktSettingsClient students={allStudents} />
}

View File

@@ -41,7 +41,14 @@ export async function GET() {
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Handle empty or invalid JSON body gracefully
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid or empty request body' }, { status: 400 })
}
// Security: Strip userId from request body - it must come from session only
const { userId: _, ...updates } = body

View File

@@ -0,0 +1,38 @@
/**
* API route for getting skill anomalies for teacher review
*
* GET /api/curriculum/[playerId]/anomalies
*
* Returns anomalies such as:
* - Skills that have been repeatedly skipped (student avoiding tutorials)
* - Skills that are mastered according to BKT but not being practiced
*/
import { NextResponse } from 'next/server'
import { getSkillAnomalies } from '@/lib/curriculum/skill-unlock'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET - Get skill anomalies for teacher review
*/
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 anomalies = await getSkillAnomalies(playerId)
return NextResponse.json({
anomalies,
})
} catch (error) {
console.error('Error fetching skill anomalies:', error)
return NextResponse.json({ error: 'Failed to fetch skill anomalies' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
/**
* API route for getting the next skill a student should learn
*
* GET /api/curriculum/[playerId]/next-skill
*
* Returns the next skill in curriculum order that:
* - Is not yet mastered (according to BKT)
* - Is not currently being practiced
* - Has a tutorial available
*/
import { NextResponse } from 'next/server'
import { getNextSkillToLearn } from '@/lib/curriculum/skill-unlock'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET - Get the next skill the student should learn
*/
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 suggestion = await getNextSkillToLearn(playerId)
return NextResponse.json({
suggestion,
})
} catch (error) {
console.error('Error fetching next skill:', error)
return NextResponse.json({ error: 'Failed to fetch next skill' }, { status: 500 })
}
}

View File

@@ -0,0 +1,24 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRecentSessionResults } from '@/lib/curriculum/session-planner'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET /api/curriculum/[playerId]/problem-history
*
* Returns the recent problem history for a player.
* Used for BKT computation and skill classification preview.
*/
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { playerId } = await params
try {
const history = await getRecentSessionResults(playerId, 50)
return NextResponse.json({ history })
} catch (error) {
console.error('Error fetching problem history:', error)
return NextResponse.json({ error: 'Failed to fetch problem history' }, { status: 500 })
}
}

View File

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

View File

@@ -1,45 +0,0 @@
/**
* API route for completing practice sessions
*
* POST /api/curriculum/[playerId]/sessions/[sessionId]/complete - Complete a session
*/
import { NextResponse } from 'next/server'
import { completePracticeSession } from '@/lib/curriculum/progress-manager'
interface RouteParams {
params: Promise<{ playerId: string; sessionId: string }>
}
/**
* POST - Complete a practice session
*/
export async function POST(request: Request, { params }: RouteParams) {
try {
const { playerId, sessionId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
if (!sessionId) {
return NextResponse.json({ error: 'Session ID required' }, { status: 400 })
}
const body = await request.json()
const { problemsAttempted, problemsCorrect, skillsUsed, averageTimeMs, totalTimeMs } = body
const session = await completePracticeSession(sessionId, {
...(problemsAttempted !== undefined && { problemsAttempted }),
...(problemsCorrect !== undefined && { problemsCorrect }),
...(skillsUsed !== undefined && { skillsUsed }),
...(averageTimeMs !== undefined && { averageTimeMs }),
...(totalTimeMs !== undefined && { totalTimeMs }),
})
return NextResponse.json(session)
} catch (error) {
console.error('Error completing session:', error)
return NextResponse.json({ error: 'Failed to complete session' }, { status: 500 })
}
}

View File

@@ -93,7 +93,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
default:
return NextResponse.json(
{ error: 'Invalid action. Must be: approve, start, record, end_early, or abandon' },
{
error: 'Invalid action. Must be: approve, start, record, end_early, or abandon',
},
{ status: 400 }
)
}

View File

@@ -1,10 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server'
import type { SessionPlan } from '@/db/schema/session-plans'
import {
ActiveSessionExistsError,
type EnabledParts,
type GenerateSessionPlanOptions,
generateSessionPlan,
getActiveSessionPlan,
NoSkillsEnabledError,
} from '@/lib/curriculum'
import type { ProblemGenerationMode } from '@/lib/curriculum/config'
import type { SessionMode } from '@/lib/curriculum/session-mode'
interface RouteParams {
params: Promise<{ playerId: string }>
@@ -42,12 +47,19 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
/**
* POST /api/curriculum/[playerId]/sessions/plans
* Generate a new three-part session plan
* Generate a new session plan
*
* Body:
* - durationMinutes: number (required) - Total session duration
* - abacusTermCount?: { min: number, max: number } - Term count for abacus part
* (visualization auto-calculates as 75% of abacus)
* - enabledParts?: { abacus: boolean, visualization: boolean, linear: boolean } - Which parts to include
* (default: all enabled)
* - problemGenerationMode?: 'adaptive' | 'classic' - Problem generation algorithm
* - 'adaptive': BKT-based continuous scaling (default)
* - 'classic': Fluency-based discrete states
*
* The plan will automatically include all three parts:
* The plan will include the selected parts:
* - Part 1: Abacus (use physical abacus, vertical format)
* - Part 2: Visualization (mental math, vertical format)
* - Part 3: Linear (mental math, sentence format)
@@ -57,7 +69,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const body = await request.json()
const { durationMinutes } = body
const {
durationMinutes,
abacusTermCount,
enabledParts,
problemGenerationMode,
confidenceThreshold,
sessionMode,
} = body
if (!durationMinutes || typeof durationMinutes !== 'number') {
return NextResponse.json(
@@ -66,14 +85,61 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
)
}
// Validate enabledParts if provided
if (enabledParts) {
const validParts = ['abacus', 'visualization', 'linear']
const enabledCount = validParts.filter((p) => enabledParts[p] === true).length
if (enabledCount === 0) {
return NextResponse.json({ error: 'At least one part must be enabled' }, { status: 400 })
}
}
const options: GenerateSessionPlanOptions = {
playerId,
durationMinutes,
// Pass enabled parts
enabledParts: enabledParts as EnabledParts | undefined,
// Pass problem generation mode if specified
problemGenerationMode: problemGenerationMode as ProblemGenerationMode | undefined,
// Pass BKT confidence threshold if specified
confidenceThreshold:
typeof confidenceThreshold === 'number' ? confidenceThreshold : undefined,
// Pass session mode for single source of truth targeting
sessionMode: sessionMode as SessionMode | undefined,
// Pass config overrides if abacusTermCount is specified
...(abacusTermCount && {
config: {
abacusTermCount,
},
}),
}
const plan = await generateSessionPlan(options)
return NextResponse.json({ plan: serializePlan(plan) }, { status: 201 })
} catch (error) {
// Handle active session conflict
if (error instanceof ActiveSessionExistsError) {
return NextResponse.json(
{
error: 'Active session exists',
code: 'ACTIVE_SESSION_EXISTS',
existingPlan: serializePlan(error.existingSession),
},
{ status: 409 }
)
}
// Handle no skills enabled
if (error instanceof NoSkillsEnabledError) {
return NextResponse.json(
{
error: error.message,
code: 'NO_SKILLS_ENABLED',
},
{ status: 400 }
)
}
console.error('Error generating session plan:', error)
return NextResponse.json({ error: 'Failed to generate session plan' }, { status: 500 })
}

View File

@@ -1,39 +0,0 @@
/**
* API route for practice sessions
*
* POST /api/curriculum/[playerId]/sessions - Start a new practice session
*/
import { NextResponse } from 'next/server'
import { startPracticeSession } from '@/lib/curriculum/progress-manager'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* POST - Start a new practice session
*/
export async function POST(request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
const body = await request.json()
const { phaseId, visualizationMode = false } = body
if (!phaseId) {
return NextResponse.json({ error: 'Phase ID required' }, { status: 400 })
}
const session = await startPracticeSession(playerId, phaseId, visualizationMode)
return NextResponse.json(session)
} catch (error) {
console.error('Error starting session:', error)
return NextResponse.json({ error: 'Failed to start session' }, { status: 500 })
}
}

View File

@@ -0,0 +1,22 @@
import { type NextRequest, NextResponse } from 'next/server'
import { analyzeSkillPerformance } from '@/lib/curriculum/progress-manager'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET /api/curriculum/[playerId]/skills/performance
* Get skill performance analysis for a player (response times, strengths/weaknesses)
*/
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { playerId } = await params
try {
const analysis = await analyzeSkillPerformance(playerId)
return NextResponse.json({ analysis })
} catch (error) {
console.error('Error fetching skill performance:', error)
return NextResponse.json({ error: 'Failed to fetch skill performance' }, { status: 500 })
}
}

View File

@@ -1,11 +1,17 @@
/**
* API route for recording skill attempts
* API route for skill mastery operations
*
* POST /api/curriculum/[playerId]/skills - Record a skill attempt
* PUT /api/curriculum/[playerId]/skills - Set mastered skills (manual override)
* PATCH /api/curriculum/[playerId]/skills - Refresh skill recency (sets lastPracticedAt to now)
*/
import { NextResponse } from 'next/server'
import { recordSkillAttempt } from '@/lib/curriculum/progress-manager'
import {
recordSkillAttempt,
refreshSkillRecency,
setMasteredSkills,
} from '@/lib/curriculum/progress-manager'
interface RouteParams {
params: Promise<{ playerId: string }>
@@ -41,3 +47,72 @@ export async function POST(request: Request, { params }: RouteParams) {
return NextResponse.json({ error: 'Failed to record skill attempt' }, { status: 500 })
}
}
/**
* PUT - Set which skills are mastered (teacher manual override)
* Body: { masteredSkillIds: string[] }
*/
export async function PUT(request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
const body = await request.json()
const { masteredSkillIds } = body
if (!Array.isArray(masteredSkillIds)) {
return NextResponse.json({ error: 'masteredSkillIds must be an array' }, { status: 400 })
}
// Validate that all items are strings
if (!masteredSkillIds.every((id) => typeof id === 'string')) {
return NextResponse.json({ error: 'All skill IDs must be strings' }, { status: 400 })
}
const result = await setMasteredSkills(playerId, masteredSkillIds)
return NextResponse.json(result)
} catch (error) {
console.error('Error setting mastered skills:', error)
return NextResponse.json({ error: 'Failed to set mastered skills' }, { status: 500 })
}
}
/**
* PATCH - Refresh skill recency (sets lastPracticedAt to now)
* Body: { skillId: string }
*
* Use this when a teacher wants to mark a skill as "recently practiced"
* (e.g., student did offline workbooks). This updates the recency state
* from "rusty" to "fluent" without changing mastery statistics.
*/
export async function PATCH(request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
const body = await request.json()
const { skillId } = body
if (!skillId || typeof skillId !== 'string') {
return NextResponse.json({ error: 'Skill ID required (string)' }, { status: 400 })
}
const result = await refreshSkillRecency(playerId, skillId)
if (!result) {
return NextResponse.json({ error: 'Skill not found for this player' }, { status: 404 })
}
return NextResponse.json(result)
} catch (error) {
console.error('Error refreshing skill recency:', error)
return NextResponse.json({ error: 'Failed to refresh skill recency' }, { status: 500 })
}
}

View File

@@ -0,0 +1,126 @@
/**
* API route for skill tutorial management
*
* POST /api/curriculum/[playerId]/tutorial - Handle tutorial actions
* - action: 'complete' - Mark a tutorial as completed
* - action: 'skip' - Record that the tutorial was skipped
* - action: 'override' - Teacher override (requires reason)
*
* GET /api/curriculum/[playerId]/tutorial?skillId=xxx - Get tutorial status
*/
import { NextResponse } from 'next/server'
import {
getSkillTutorialProgress,
markTutorialComplete,
recordTutorialSkip,
applyTutorialOverride,
enableSkillForPractice,
} from '@/lib/curriculum/progress-manager'
import { getSkillTutorialConfig } from '@/lib/curriculum/skill-unlock'
interface RouteParams {
params: Promise<{ playerId: string }>
}
/**
* GET - Get tutorial progress for a specific skill
*/
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 { searchParams } = new URL(request.url)
const skillId = searchParams.get('skillId')
if (!skillId) {
return NextResponse.json({ error: 'Skill ID required' }, { status: 400 })
}
const progress = await getSkillTutorialProgress(playerId, skillId)
const config = getSkillTutorialConfig(skillId)
return NextResponse.json({
progress,
tutorialAvailable: !!config,
config: config
? {
title: config.title,
description: config.description,
problemCount: config.exampleProblems.length,
}
: null,
})
} catch (error) {
console.error('Error fetching tutorial progress:', error)
return NextResponse.json({ error: 'Failed to fetch tutorial progress' }, { status: 500 })
}
}
/**
* POST - Handle tutorial actions
*/
export async function POST(request: Request, { params }: RouteParams) {
try {
const { playerId } = await params
if (!playerId) {
return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
}
const body = await request.json()
const { skillId, action, reason } = body
if (!skillId) {
return NextResponse.json({ error: 'Skill ID required' }, { status: 400 })
}
if (!action || !['complete', 'skip', 'override'].includes(action)) {
return NextResponse.json(
{ error: 'Valid action required (complete, skip, or override)' },
{ status: 400 }
)
}
let progress
switch (action) {
case 'complete':
// Mark tutorial complete and enable skill for practice
progress = await markTutorialComplete(playerId, skillId)
// Automatically enable the skill for practice after completing tutorial
await enableSkillForPractice(playerId, skillId)
break
case 'skip':
// Record that the tutorial was skipped
progress = await recordTutorialSkip(playerId, skillId)
break
case 'override':
// Teacher override - requires reason
if (!reason) {
return NextResponse.json(
{ error: 'Reason required for teacher override' },
{ status: 400 }
)
}
progress = await applyTutorialOverride(playerId, skillId, reason)
// Also enable the skill for practice
await enableSkillForPractice(playerId, skillId)
break
}
return NextResponse.json({
success: true,
progress,
})
} catch (error) {
console.error('Error handling tutorial action:', error)
return NextResponse.json({ error: 'Failed to handle tutorial action' }, { status: 500 })
}
}

View File

@@ -49,6 +49,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
...(body.emoji !== undefined && { emoji: body.emoji }),
...(body.color !== undefined && { color: body.color }),
...(body.isActive !== undefined && { isActive: body.isActive }),
...(body.notes !== undefined && { notes: body.notes }),
// userId is explicitly NOT included - it comes from session
})
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))

View File

@@ -0,0 +1,101 @@
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { players } from '@/db/schema'
import {
computeBktFromHistory,
DEFAULT_BKT_OPTIONS,
type SkillBktResult,
} from '@/lib/curriculum/bkt'
import { BKT_THRESHOLDS } from '@/lib/curriculum/config/bkt-integration'
import { getRecentSessionResults } from '@/lib/curriculum/session-planner'
/**
* GET /api/settings/bkt/aggregate
*
* Returns aggregate BKT stats across all students.
*
* Query params:
* - threshold: confidence threshold (default from BKT_THRESHOLDS.confidence)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const threshold = parseFloat(searchParams.get('threshold') ?? String(BKT_THRESHOLDS.confidence))
// Get all players
const allPlayers = await db.select({ id: players.id }).from(players)
if (allPlayers.length === 0) {
return NextResponse.json({
totalStudents: 0,
totalSkills: 0,
weak: 0,
developing: 0,
strong: 0,
// Legacy aliases for backwards compatibility
struggling: 0,
learning: 0,
mastered: 0,
})
}
// Track aggregate counts
let totalStudents = 0
let totalSkills = 0
let weak = 0
let developing = 0
let strong = 0
// Process each player
for (const player of allPlayers) {
// Fetch problem history using the session-planner's helper
const problemHistory = await getRecentSessionResults(player.id, 500)
if (problemHistory.length === 0) continue
// Compute BKT
const bktResult = computeBktFromHistory(problemHistory, {
...DEFAULT_BKT_OPTIONS,
confidenceThreshold: threshold,
})
// Count classifications
totalStudents++
for (const skill of bktResult.skills) {
totalSkills++
const classification = classifySkill(skill, threshold)
if (classification === 'weak') weak++
else if (classification === 'developing') developing++
else if (classification === 'strong') strong++
}
}
return NextResponse.json({
totalStudents,
totalSkills,
weak,
developing,
strong,
// Legacy aliases for backwards compatibility with BktSettingsClient
struggling: weak,
learning: developing,
mastered: strong,
})
} catch (error) {
console.error('Error computing aggregate BKT stats:', error)
return NextResponse.json({ error: 'Failed to compute stats' }, { status: 500 })
}
}
function classifySkill(skill: SkillBktResult, threshold: number): 'weak' | 'developing' | 'strong' {
if (skill.confidence < threshold) {
return 'developing' // Not enough data - safest default
}
if (skill.pKnown >= BKT_THRESHOLDS.strong) {
return 'strong'
}
if (skill.pKnown < BKT_THRESHOLDS.weak) {
return 'weak'
}
return 'developing'
}

View File

@@ -0,0 +1,90 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { appSettings } from '@/db/schema'
/** Default BKT confidence threshold */
const DEFAULT_THRESHOLD = 0.3
/**
* Ensure the default settings row exists.
* Creates it if missing (handles fresh databases).
*/
async function ensureDefaultSettings() {
const existing = await db.select().from(appSettings).where(eq(appSettings.id, 'default')).limit(1)
if (existing.length === 0) {
await db.insert(appSettings).values({
id: 'default',
bktConfidenceThreshold: DEFAULT_THRESHOLD,
})
}
}
/**
* GET /api/settings/bkt
*
* Returns the current BKT confidence threshold setting.
* Creates the default row if it doesn't exist.
*/
export async function GET() {
try {
await ensureDefaultSettings()
const [settings] = await db
.select()
.from(appSettings)
.where(eq(appSettings.id, 'default'))
.limit(1)
return NextResponse.json({
bktConfidenceThreshold: settings?.bktConfidenceThreshold ?? DEFAULT_THRESHOLD,
})
} catch (error) {
console.error('Error fetching BKT settings:', error)
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 })
}
}
/**
* PATCH /api/settings/bkt
*
* Updates the BKT confidence threshold setting.
*
* Body:
* - bktConfidenceThreshold: number (0.1 to 0.9)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const { bktConfidenceThreshold } = body
// Validate the threshold
if (typeof bktConfidenceThreshold !== 'number') {
return NextResponse.json(
{ error: 'bktConfidenceThreshold must be a number' },
{ status: 400 }
)
}
if (bktConfidenceThreshold < 0.1 || bktConfidenceThreshold > 0.9) {
return NextResponse.json(
{ error: 'bktConfidenceThreshold must be between 0.1 and 0.9' },
{ status: 400 }
)
}
await ensureDefaultSettings()
// Update the setting
await db
.update(appSettings)
.set({ bktConfidenceThreshold })
.where(eq(appSettings.id, 'default'))
return NextResponse.json({ bktConfidenceThreshold })
} catch (error) {
console.error('Error updating BKT settings:', error)
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 })
}
}

View File

@@ -1,9 +1,40 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getPostBySlug, getAllPostSlugs } from '@/lib/blog'
import { notFound } from 'next/navigation'
import { SkillDifficultyCharts } from '@/components/blog/SkillDifficultyCharts'
import {
AutomaticityMultiplierCharts,
BlameAttributionCharts,
ClassificationCharts,
EvidenceQualityCharts,
ThreeWayComparisonCharts,
ValidationResultsCharts,
} from '@/components/blog/ValidationCharts'
import { getAllPostSlugs, getPostBySlug } from '@/lib/blog'
import { css } from '../../../../styled-system/css'
interface ChartInjection {
component: React.ComponentType
/** Marker ID to find in the markdown (e.g., "EvidenceQuality" matches <!-- CHART: EvidenceQuality -->) */
markerId: string
}
/** Blog posts that have interactive chart sections */
const POSTS_WITH_CHARTS: Record<string, ChartInjection[]> = {
'conjunctive-bkt-skill-tracing': [
{ component: EvidenceQualityCharts, markerId: 'EvidenceQuality' },
{
component: AutomaticityMultiplierCharts,
markerId: 'AutomaticityMultipliers',
},
{ component: ClassificationCharts, markerId: 'Classification' },
{ component: SkillDifficultyCharts, markerId: 'SkillDifficulty' },
{ component: ThreeWayComparisonCharts, markerId: 'ThreeWayComparison' },
{ component: ValidationResultsCharts, markerId: 'ValidationResults' },
{ component: BlameAttributionCharts, markerId: 'BlameAttribution' },
],
}
interface Props {
params: {
slug: string
@@ -214,130 +245,7 @@ export default async function BlogPost({ params }: Props) {
</header>
{/* Article Content */}
<div
data-section="article-content"
className={css({
fontSize: { base: '1rem', md: '1.125rem' },
lineHeight: '1.75',
color: 'text.primary',
// Typography styles for markdown content
'& h1': {
fontSize: { base: '1.875rem', md: '2.25rem' },
fontWeight: 'bold',
mt: '2.5rem',
mb: '1rem',
lineHeight: '1.25',
color: 'text.primary',
},
'& h2': {
fontSize: { base: '1.5rem', md: '1.875rem' },
fontWeight: 'bold',
mt: '2rem',
mb: '0.875rem',
lineHeight: '1.3',
color: 'accent.emphasis',
},
'& h3': {
fontSize: { base: '1.25rem', md: '1.5rem' },
fontWeight: 600,
mt: '1.75rem',
mb: '0.75rem',
lineHeight: '1.4',
color: 'accent.default',
},
'& p': {
mb: '1.25rem',
},
'& strong': {
fontWeight: 600,
color: 'text.primary',
},
'& a': {
color: 'accent.emphasis',
textDecoration: 'underline',
_hover: {
color: 'accent.default',
},
},
'& ul, & ol': {
pl: '1.5rem',
mb: '1.25rem',
},
'& li': {
mb: '0.5rem',
},
'& code': {
bg: 'bg.muted',
px: '0.375rem',
py: '0.125rem',
borderRadius: '0.25rem',
fontSize: '0.875em',
fontFamily: 'monospace',
color: 'accent.emphasis',
border: '1px solid',
borderColor: 'accent.default',
},
'& pre': {
bg: 'bg.surface',
border: '1px solid',
borderColor: 'border.default',
color: 'text.primary',
p: '1rem',
borderRadius: '0.5rem',
overflow: 'auto',
mb: '1.25rem',
},
'& pre code': {
bg: 'transparent',
p: '0',
border: 'none',
color: 'inherit',
fontSize: '0.875rem',
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'accent.default',
pl: '1rem',
py: '0.5rem',
my: '1.5rem',
color: 'text.secondary',
fontStyle: 'italic',
bg: 'accent.subtle',
borderRadius: '0 0.25rem 0.25rem 0',
},
'& hr': {
my: '2rem',
borderColor: 'border.muted',
},
'& table': {
width: '100%',
mb: '1.25rem',
borderCollapse: 'collapse',
},
'& th': {
bg: 'accent.muted',
px: '1rem',
py: '0.75rem',
textAlign: 'left',
fontWeight: 600,
borderBottom: '2px solid',
borderColor: 'accent.default',
color: 'accent.emphasis',
},
'& td': {
px: '1rem',
py: '0.75rem',
borderBottom: '1px solid',
borderColor: 'border.muted',
color: 'text.secondary',
},
'& tr:hover td': {
bg: 'accent.subtle',
},
})}
dangerouslySetInnerHTML={{ __html: post.html }}
/>
<BlogContent slug={params.slug} html={post.html} />
</article>
{/* JSON-LD Structured Data */}
@@ -363,3 +271,218 @@ export default async function BlogPost({ params }: Props) {
</div>
)
}
/** Content component that handles chart injection */
function BlogContent({ slug, html }: { slug: string; html: string }) {
const chartConfigs = POSTS_WITH_CHARTS[slug]
// If no charts for this post, render full content
if (!chartConfigs || chartConfigs.length === 0) {
return (
<div
data-section="article-content"
className={articleContentStyles}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
// Build injection points: find each marker comment and its position
// Markers look like: <!-- CHART: EvidenceQuality -->
const injections: Array<{
position: number
length: number
component: React.ComponentType
}> = []
for (const config of chartConfigs) {
// Match the marker comment exactly
const markerPattern = new RegExp(`<!--\\s*CHART:\\s*${config.markerId}\\s*-->`, 'i')
const match = html.match(markerPattern)
if (match && match.index !== undefined) {
// Replace the marker with the chart (position is where marker starts, length is marker length)
injections.push({
position: match.index,
length: match[0].length,
component: config.component,
})
}
}
// Sort by position (ascending) so we process in order
injections.sort((a, b) => a.position - b.position)
// If no injections found, render full content
if (injections.length === 0) {
return (
<div
data-section="article-content"
className={articleContentStyles}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
// Split HTML at injection points and render with charts
const segments: React.ReactNode[] = []
let lastPosition = 0
for (let i = 0; i < injections.length; i++) {
const { position, length, component: ChartComponent } = injections[i]
// Add HTML segment before this injection (up to the marker)
const htmlSegment = html.slice(lastPosition, position)
if (htmlSegment) {
segments.push(
<div
key={`html-${i}`}
data-section={`article-content-${i}`}
className={articleContentStyles}
dangerouslySetInnerHTML={{ __html: htmlSegment }}
/>
)
}
// Add the chart component (replacing the marker)
segments.push(<ChartComponent key={`chart-${i}`} />)
// Skip past the marker
lastPosition = position + length
}
// Add remaining HTML after last injection
const remainingHtml = html.slice(lastPosition)
if (remainingHtml) {
segments.push(
<div
key="html-final"
data-section="article-content-final"
className={articleContentStyles}
dangerouslySetInnerHTML={{ __html: remainingHtml }}
/>
)
}
return <>{segments}</>
}
const articleContentStyles = css({
fontSize: { base: '1rem', md: '1.125rem' },
lineHeight: '1.75',
color: 'text.primary',
// Typography styles for markdown content
'& h1': {
fontSize: { base: '1.875rem', md: '2.25rem' },
fontWeight: 'bold',
mt: '2.5rem',
mb: '1rem',
lineHeight: '1.25',
color: 'text.primary',
},
'& h2': {
fontSize: { base: '1.5rem', md: '1.875rem' },
fontWeight: 'bold',
mt: '2rem',
mb: '0.875rem',
lineHeight: '1.3',
color: 'accent.emphasis',
},
'& h3': {
fontSize: { base: '1.25rem', md: '1.5rem' },
fontWeight: 600,
mt: '1.75rem',
mb: '0.75rem',
lineHeight: '1.4',
color: 'accent.default',
},
'& p': {
mb: '1.25rem',
},
'& strong': {
fontWeight: 600,
color: 'text.primary',
},
'& a': {
color: 'accent.emphasis',
textDecoration: 'underline',
_hover: {
color: 'accent.default',
},
},
'& ul, & ol': {
pl: '1.5rem',
mb: '1.25rem',
},
'& li': {
mb: '0.5rem',
},
'& code': {
bg: 'bg.muted',
px: '0.375rem',
py: '0.125rem',
borderRadius: '0.25rem',
fontSize: '0.875em',
fontFamily: 'monospace',
color: 'accent.emphasis',
border: '1px solid',
borderColor: 'accent.default',
},
'& pre': {
bg: 'bg.surface',
border: '1px solid',
borderColor: 'border.default',
color: 'text.primary',
p: '1rem',
borderRadius: '0.5rem',
overflow: 'auto',
mb: '1.25rem',
},
'& pre code': {
bg: 'transparent',
p: '0',
border: 'none',
color: 'inherit',
fontSize: '0.875rem',
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'accent.default',
pl: '1rem',
py: '0.5rem',
my: '1.5rem',
color: 'text.secondary',
fontStyle: 'italic',
bg: 'accent.subtle',
borderRadius: '0 0.25rem 0.25rem 0',
},
'& hr': {
my: '2rem',
borderColor: 'border.muted',
},
'& table': {
width: '100%',
mb: '1.25rem',
borderCollapse: 'collapse',
},
'& th': {
bg: 'accent.muted',
px: '1rem',
py: '0.75rem',
textAlign: 'left',
fontWeight: 600,
borderBottom: '2px solid',
borderColor: 'accent.default',
color: 'accent.emphasis',
},
'& td': {
px: '1rem',
py: '0.75rem',
borderBottom: '1px solid',
borderColor: 'border.muted',
color: 'text.secondary',
},
'& tr:hover td': {
bg: 'accent.subtle',
},
})

View File

@@ -60,7 +60,10 @@ function CreatorCard({
p: { base: 5, sm: 6, md: 8 },
border: '1px solid',
borderColor: 'border.default',
boxShadow: { base: '0 10px 40px rgba(0,0,0,0.15)', md: '0 20px 60px rgba(0,0,0,0.2)' },
boxShadow: {
base: '0 10px 40px rgba(0,0,0,0.15)',
md: '0 20px 60px rgba(0,0,0,0.2)',
},
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
@@ -69,8 +72,14 @@ function CreatorCard({
display: 'flex',
flexDirection: 'column',
_hover: {
transform: { base: 'translateY(-4px)', md: 'translateY(-8px) scale(1.01)' },
boxShadow: { base: '0 16px 50px rgba(0,0,0,0.2)', md: '0 30px 80px rgba(0,0,0,0.25)' },
transform: {
base: 'translateY(-4px)',
md: 'translateY(-8px) scale(1.01)',
},
boxShadow: {
base: '0 16px 50px rgba(0,0,0,0.2)',
md: '0 30px 80px rgba(0,0,0,0.25)',
},
borderColor: 'border.emphasized',
},
})}

View File

@@ -1,6 +1,7 @@
# Answer Key Feature Implementation Plan
## Design Decisions
1. **Format**: Compact list (e.g., `1. 45 + 27 = 72`)
2. **Placement**: End of PDF (after all worksheet pages)
3. **Problem numbers**: Match worksheet config - show if `displayRules.problemNumbers !== 'never'`
@@ -8,19 +9,23 @@
## Implementation Steps
### 1. Add config option
- **File**: `types.ts`
- Add `includeAnswerKey?: boolean` to `WorksheetFormState`
- Default: `false`
### 2. Update validation
- **File**: `validation.ts`
- Pass through `includeAnswerKey` in validated config
### 3. Create answer key generator
- **File**: `typstGenerator.ts` (new function)
- Function: `generateAnswerKeyTypst(config, problems, showProblemNumbers)`
- Output: Typst source for answer key page(s)
- Format: Compact multi-column list
```
Answer Key
@@ -30,20 +35,27 @@
```
### 4. Integrate into page generation
- **File**: `typstGenerator.ts`
- After worksheet pages, if `includeAnswerKey`:
```typescript
if (config.includeAnswerKey) {
const answerKeyPages = generateAnswerKeyTypst(config, problems, showProblemNumbers)
return [...worksheetPages, ...answerKeyPages]
const answerKeyPages = generateAnswerKeyTypst(
config,
problems,
showProblemNumbers,
);
return [...worksheetPages, ...answerKeyPages];
}
```
### 5. Add UI toggle
- **File**: Find worksheet config form component
- Add checkbox: "Include Answer Key"
### 6. Update preview (optional)
- Show answer key pages in preview carousel
## Answer Key Typst Template
@@ -72,6 +84,7 @@
```
## Files to Modify
1. `types.ts` - Add `includeAnswerKey` field
2. `validation.ts` - Pass through new field
3. `typstGenerator.ts` - Add answer key generation

View File

@@ -162,23 +162,48 @@ function DiceIcon({
{/* Front face (1) */}
<div style={{ ...faceStyle, transform: `translateZ(${halfSize}px)` }}>{renderDots(1)}</div>
{/* Back face (6) */}
<div style={{ ...faceStyle, transform: `rotateY(180deg) translateZ(${halfSize}px)` }}>
<div
style={{
...faceStyle,
transform: `rotateY(180deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(6)}
</div>
{/* Right face (2) */}
<div style={{ ...faceStyle, transform: `rotateY(90deg) translateZ(${halfSize}px)` }}>
<div
style={{
...faceStyle,
transform: `rotateY(90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(2)}
</div>
{/* Left face (5) */}
<div style={{ ...faceStyle, transform: `rotateY(-90deg) translateZ(${halfSize}px)` }}>
<div
style={{
...faceStyle,
transform: `rotateY(-90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(5)}
</div>
{/* Top face (3) */}
<div style={{ ...faceStyle, transform: `rotateX(90deg) translateZ(${halfSize}px)` }}>
<div
style={{
...faceStyle,
transform: `rotateX(90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(3)}
</div>
{/* Bottom face (4) */}
<div style={{ ...faceStyle, transform: `rotateX(-90deg) translateZ(${halfSize}px)` }}>
<div
style={{
...faceStyle,
transform: `rotateX(-90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(4)}
</div>
</animated.div>
@@ -252,7 +277,10 @@ export function PreviewCenter({
const portalDiceRef = useRef<HTMLDivElement>(null)
// Compute target rotation for the current face (needed by physics simulation)
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || { rotateX: 0, rotateY: 0 }
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || {
rotateX: 0,
rotateY: 0,
}
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
useEffect(() => {
@@ -519,7 +547,11 @@ export function PreviewCenter({
}
dragStartPos.current = { x: e.clientX, y: e.clientY }
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: performance.now() }
lastPointerPos.current = {
x: e.clientX,
y: e.clientY,
time: performance.now(),
}
velocitySamples.current = [] // Reset velocity tracking
dicePhysics.current = {
x: 0,

View File

@@ -75,3 +75,48 @@ body {
transform: translateY(-10px);
}
}
/* Tooltip animations (Radix UI) */
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -68,6 +68,11 @@ export const metadata: Metadata = {
title: 'Abaci.One',
},
// Modern web app capable meta tag (non-Apple browsers)
other: {
'mobile-web-app-capable': 'yes',
},
// Category
category: 'education',
}

View File

@@ -0,0 +1,101 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players'
import { css } from '../../../styled-system/css'
interface PracticeClientProps {
initialPlayers: Player[]
}
/**
* Practice page client component
*
* Receives prefetched player data as props from the server component.
* This avoids SSR hydration issues with React Query.
*/
export function PracticeClient({ initialPlayers }: PracticeClientProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Use initial data from server
const players = initialPlayers
// Convert players to StudentWithProgress format
const students: StudentWithProgress[] = players.map((player) => ({
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
createdAt: player.createdAt,
notes: player.notes,
}))
// Handle student selection - navigate to student's resume page
// The /resume route shows "Welcome back" for in-progress sessions
const handleSelectStudent = useCallback(
(student: StudentWithProgress) => {
router.push(`/practice/${student.id}/resume`, { scroll: false })
},
[router]
)
return (
<PageWithNav>
<main
data-component="practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: 'calc(80px + 2rem)',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.5rem',
})}
>
Daily Practice
</h1>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Build your soroban skills one step at a time
</p>
</header>
{/* Student Selector */}
<StudentSelector students={students} onSelectStudent={handleSelectStudent} />
</div>
</main>
</PageWithNav>
)
}

View File

@@ -0,0 +1,106 @@
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../styled-system/css'
/**
* Skeleton component shown while practice page data is loading
*
* This is used as a fallback for the Suspense boundary, but in practice
* it should rarely be seen since data is prefetched on the server.
*/
export function PracticeSkeleton() {
return (
<PageWithNav>
<main
data-component="practice-page-skeleton"
className={css({
minHeight: '100vh',
backgroundColor: 'gray.50',
paddingTop: 'calc(80px + 2rem)',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header skeleton */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<div
className={css({
width: '200px',
height: '2rem',
backgroundColor: 'gray.200',
borderRadius: '8px',
margin: '0 auto 0.5rem auto',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div
className={css({
width: '280px',
height: '1rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
margin: '0 auto',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
</header>
{/* Student cards skeleton */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem',
})}
>
{[1, 2, 3].map((i) => (
<div
key={i}
className={css({
backgroundColor: 'white',
borderRadius: '16px',
boxShadow: 'md',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
})}
>
<div
className={css({
width: '60px',
height: '60px',
backgroundColor: 'gray.200',
borderRadius: '50%',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div
className={css({
width: '100px',
height: '1.25rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
</div>
))}
</div>
</div>
</main>
</PageWithNav>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import {
ActiveSession,
type AttemptTimingData,
PracticeErrorBoundary,
PracticeSubNav,
type SessionHudData,
} from '@/components/practice'
import type { Player } from '@/db/schema/players'
import type { SessionHealth, SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import {
useActiveSessionPlan,
useEndSessionEarly,
useRecordSlotResult,
} from '@/hooks/useSessionPlan'
import { css } from '../../../../styled-system/css'
interface PracticeClientProps {
studentId: string
player: Player
initialSession: SessionPlan
}
/**
* Practice Client Component
*
* This component ONLY shows the current problem.
* It assumes the session is in_progress (server guards ensure this).
*
* When the session completes, it redirects to /summary.
*/
export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) {
const router = useRouter()
// Track pause state for HUD display (ActiveSession owns the modal and actual pause logic)
const [isPaused, setIsPaused] = useState(false)
// Track timing data from ActiveSession for the sub-nav HUD
const [timingData, setTimingData] = useState<AttemptTimingData | null>(null)
// Browse mode state - lifted here so PracticeSubNav can trigger it
const [isBrowseMode, setIsBrowseMode] = useState(false)
// Browse index - lifted for navigation from SessionProgressIndicator
const [browseIndex, setBrowseIndex] = useState(0)
// Session plan mutations
const recordResult = useRecordSlotResult()
const endEarly = useEndSessionEarly()
// Fetch active session plan from cache or API with server data as initial
const { data: fetchedPlan } = useActiveSessionPlan(studentId, initialSession)
// Current plan - mutations take priority, then fetched/cached data
const currentPlan = endEarly.data ?? recordResult.data ?? fetchedPlan ?? initialSession
// Compute HUD data from current plan
const currentPart = currentPlan.parts[currentPlan.currentPartIndex] as SessionPart | undefined
const sessionHealth = currentPlan.sessionHealth as SessionHealth | null
// Calculate totals
const { totalProblems, completedProblems } = useMemo(() => {
const total = currentPlan.parts.reduce((sum, part) => sum + part.slots.length, 0)
let completed = 0
for (let i = 0; i < currentPlan.currentPartIndex; i++) {
completed += currentPlan.parts[i].slots.length
}
completed += currentPlan.currentSlotIndex
return { totalProblems: total, completedProblems: completed }
}, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex])
// Pause/resume handlers - just update HUD state (ActiveSession owns the modal)
const handlePause = useCallback(() => {
setIsPaused(true)
}, [])
const handleResume = useCallback(() => {
setIsPaused(false)
}, [])
// Handle recording an answer
const handleAnswer = useCallback(
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
const updatedPlan = await recordResult.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
result,
})
// If session just completed, redirect to summary
if (updatedPlan.completedAt) {
router.push(`/practice/${studentId}/summary`, { scroll: false })
}
},
[studentId, currentPlan.id, recordResult, router]
)
// Handle ending session early
const handleEndEarly = useCallback(
async (reason?: string) => {
await endEarly.mutateAsync({
playerId: studentId,
planId: currentPlan.id,
reason,
})
// Redirect to summary after ending early
router.push(`/practice/${studentId}/summary`, { scroll: false })
},
[studentId, currentPlan.id, endEarly, router]
)
// Handle session completion (called by ActiveSession when all problems done)
const handleSessionComplete = useCallback(() => {
// Redirect to summary
router.push(`/practice/${studentId}/summary`, { scroll: false })
}, [studentId, router])
// Build session HUD data for PracticeSubNav
const sessionHud: SessionHudData | undefined = currentPart
? {
isPaused,
parts: currentPlan.parts,
currentPartIndex: currentPlan.currentPartIndex,
currentPart: {
type: currentPart.type,
partNumber: currentPart.partNumber,
totalSlots: currentPart.slots.length,
},
currentSlotIndex: currentPlan.currentSlotIndex,
results: currentPlan.results,
completedProblems,
totalProblems,
sessionHealth: sessionHealth
? {
overall: sessionHealth.overall,
accuracy: sessionHealth.accuracy,
}
: undefined,
// Pass timing data for the current problem
timing: timingData
? {
startTime: timingData.startTime,
accumulatedPauseMs: timingData.accumulatedPauseMs,
results: currentPlan.results,
parts: currentPlan.parts,
}
: undefined,
onPause: handlePause,
onResume: handleResume,
onEndEarly: () => handleEndEarly('Session ended'),
isBrowseMode,
onToggleBrowse: () => setIsBrowseMode((prev) => !prev),
onBrowseNavigate: setBrowseIndex,
}
: undefined
return (
<PageWithNav>
{/* Practice Sub-Navigation with Session HUD */}
<PracticeSubNav student={player} pageContext="session" sessionHud={sessionHud} />
<main
data-component="practice-page"
className={css({
// Fixed positioning to precisely control bounds
position: 'fixed',
// Top: main nav (80px) + sub-nav height (~52px mobile, ~60px desktop)
top: { base: '132px', md: '140px' },
left: 0,
// Right: 0 by default, landscape mobile handled via media query below
right: 0,
// Bottom: keypad height on mobile portrait (48px), 0 on desktop
// Landscape mobile handled via media query below
bottom: { base: '48px', md: 0 },
overflow: 'hidden', // Prevent scrolling during practice
})}
>
{/* Landscape mobile: keypad is on right (100px) instead of bottom */}
<style
dangerouslySetInnerHTML={{
__html: `
@media (orientation: landscape) and (max-height: 500px) {
[data-component="practice-page"] {
bottom: 0 !important;
right: 100px !important;
}
}
`,
}}
/>
<PracticeErrorBoundary studentName={player.name}>
<ActiveSession
plan={currentPlan}
student={{ name: player.name, emoji: player.emoji, color: player.color }}
onAnswer={handleAnswer}
onEndEarly={handleEndEarly}
onPause={handlePause}
onResume={handleResume}
onComplete={handleSessionComplete}
onTimingUpdate={setTimingData}
isBrowseMode={isBrowseMode}
browseIndex={browseIndex}
onBrowseIndexChange={setBrowseIndex}
/>
</PracticeErrorBoundary>
</main>
</PageWithNav>
)
}

View File

@@ -0,0 +1,165 @@
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
/**
* Skeleton component shown while practice page data is loading
*
* This is used as a fallback for the Suspense boundary, but in practice
* it should rarely be seen since data is prefetched on the server.
* It may appear briefly during client-side navigation.
*/
export function PracticePageSkeleton() {
return (
<PageWithNav>
<main
data-component="practice-page-skeleton"
className={css({
minHeight: '100vh',
backgroundColor: 'gray.50',
paddingTop: 'calc(80px + 2rem)',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header skeleton */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<div
className={css({
width: '200px',
height: '2rem',
backgroundColor: 'gray.200',
borderRadius: '8px',
margin: '0 auto 0.5rem auto',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div
className={css({
width: '280px',
height: '1rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
margin: '0 auto',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
</header>
{/* Dashboard card skeleton */}
<div
className={css({
backgroundColor: 'white',
borderRadius: '16px',
boxShadow: 'md',
padding: '2rem',
})}
>
{/* Student info skeleton */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '2rem',
})}
>
<div
className={css({
width: '60px',
height: '60px',
backgroundColor: 'gray.200',
borderRadius: '50%',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div>
<div
className={css({
width: '150px',
height: '1.5rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
marginBottom: '0.5rem',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div
className={css({
width: '100px',
height: '1rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
</div>
</div>
{/* Phase info skeleton */}
<div
className={css({
backgroundColor: 'gray.50',
borderRadius: '12px',
padding: '1.5rem',
marginBottom: '2rem',
})}
>
<div
className={css({
width: '120px',
height: '1rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
marginBottom: '0.75rem',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div
className={css({
width: '200px',
height: '1.25rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
marginBottom: '0.5rem',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<div
className={css({
width: '100%',
height: '0.875rem',
backgroundColor: 'gray.200',
borderRadius: '4px',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
</div>
{/* Button skeleton */}
<div
className={css({
width: '100%',
height: '56px',
backgroundColor: 'gray.200',
borderRadius: '12px',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
</div>
</div>
</main>
</PageWithNav>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation'
// Disable caching - session data should be fresh
export const dynamic = 'force-dynamic'
interface ConfigurePageProps {
params: Promise<{ studentId: string }>
}
/**
* Configure Practice Session Page - DEPRECATED
*
* This page now redirects to the dashboard. The session configuration
* modal is accessible from the dashboard via the "Start Practice" button.
*
* URL: /practice/[studentId]/configure → redirects to /practice/[studentId]/dashboard
*/
export default async function ConfigurePage({ params }: ConfigurePageProps) {
const { studentId } = await params
// Redirect to dashboard - the StartPracticeModal is now accessible from there
redirect(`/practice/${studentId}/dashboard`)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
import { notFound } from 'next/navigation'
import {
getAllSkillMastery,
getPlayer,
getPlayerCurriculum,
getRecentSessions,
getRecentSessionResults,
} from '@/lib/curriculum/server'
import { getActiveSessionPlan } from '@/lib/curriculum/session-planner'
import { DashboardClient } from './DashboardClient'
// Disable caching for this page - progress data should be fresh
export const dynamic = 'force-dynamic'
interface DashboardPageProps {
params: Promise<{ studentId: string }>
searchParams: Promise<{ tab?: string }>
}
/**
* Dashboard Page - Server Component
*
* Shows the student's tabbed dashboard with:
* - Overview tab: Current level, progress, session controls
* - Skills tab: Detailed skill mastery, BKT analysis, skill management
* - History tab: Past sessions (future)
*
* This page is always accessible regardless of session state.
* Parents/teachers can view stats even while a session is in progress.
*
* URL: /practice/[studentId]/dashboard?tab=overview|skills|history
*/
export default async function DashboardPage({ params, searchParams }: DashboardPageProps) {
const { studentId } = await params
const { tab } = await searchParams
// Fetch player data in parallel
const [player, curriculum, skills, recentSessions, activeSession, problemHistory] =
await Promise.all([
getPlayer(studentId),
getPlayerCurriculum(studentId),
getAllSkillMastery(studentId),
getRecentSessions(studentId, 10),
getActiveSessionPlan(studentId),
getRecentSessionResults(studentId, 50), // For Skills tab BKT analysis
])
// 404 if player doesn't exist
if (!player) {
notFound()
}
// Get skill IDs that are in the student's active practice rotation
// isPracticing=true means the skill is enabled for practice, NOT that it's mastered
const currentPracticingSkillIds = skills.filter((s) => s.isPracticing).map((s) => s.skillId)
return (
<DashboardClient
studentId={studentId}
player={player}
curriculum={curriculum}
skills={skills}
recentSessions={recentSessions}
activeSession={activeSession}
currentPracticingSkillIds={currentPracticingSkillIds}
problemHistory={problemHistory}
initialTab={tab as 'overview' | 'skills' | 'history' | undefined}
/>
)
}

View File

@@ -0,0 +1,75 @@
import Link from 'next/link'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
/**
* Not Found page for invalid student IDs
*
* Shown when navigating to /practice/[studentId] with an ID that doesn't exist
*/
export default function StudentNotFound() {
return (
<PageWithNav>
<main
data-component="practice-not-found"
className={css({
minHeight: '100vh',
backgroundColor: 'gray.50',
paddingTop: 'calc(80px + 2rem)',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '600px',
margin: '0 auto',
textAlign: 'center',
padding: '3rem',
backgroundColor: 'white',
borderRadius: '16px',
boxShadow: 'md',
})}
>
<div className={css({ fontSize: '3rem', marginBottom: '1rem' })}>🔍</div>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: 'gray.800',
marginBottom: '0.5rem',
})}
>
Student Not Found
</h1>
<p
className={css({
color: 'gray.600',
marginBottom: '1.5rem',
})}
>
We couldn't find a student with this ID. They may have been removed.
</p>
<Link
href="/practice"
scroll={false}
className={css({
display: 'inline-block',
padding: '0.75rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'blue.500',
borderRadius: '8px',
textDecoration: 'none',
_hover: { backgroundColor: 'blue.600' },
})}
>
Select a Student
</Link>
</div>
</main>
</PageWithNav>
)
}

View File

@@ -0,0 +1,57 @@
import { notFound, redirect } from 'next/navigation'
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
import { PracticeClient } from './PracticeClient'
// Disable caching for this page - session state must always be fresh
export const dynamic = 'force-dynamic'
interface StudentPracticePageProps {
params: Promise<{ studentId: string }>
}
/**
* Student Practice Page - Server Component
*
* This page ONLY shows the current problem for active practice sessions.
* All other states redirect to appropriate pages.
*
* Guards/Redirects:
* - No active session → /dashboard (show progress, start new session)
* - Draft/approved session (not started) → /configure (approve and start)
* - In_progress session → SHOW PROBLEM (this is the only state we render here)
* - Completed session → /summary (show results)
*
* URL: /practice/[studentId]
*/
export default async function StudentPracticePage({ params }: StudentPracticePageProps) {
const { studentId } = await params
// Fetch player and active session in parallel
const [player, activeSession] = await Promise.all([
getPlayer(studentId),
getActiveSessionPlan(studentId),
])
// 404 if player doesn't exist
if (!player) {
notFound()
}
// No active session → dashboard
if (!activeSession) {
redirect(`/practice/${studentId}/dashboard`)
}
// Draft or approved but not started → configure page
if (!activeSession.startedAt) {
redirect(`/practice/${studentId}/configure`)
}
// Session is completed → summary page
if (activeSession.completedAt) {
redirect(`/practice/${studentId}/summary`)
}
// Only state left: in_progress session → show problem
return <PracticeClient studentId={studentId} player={player} initialSession={activeSession} />
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { PracticeSubNav } from '@/components/practice'
import { PlacementTest } from '@/components/practice/PlacementTest'
import type { Player } from '@/db/schema/players'
interface PlacementTestClientProps {
studentId: string
player: Player
}
/**
* Client component for placement test page
*
* Wraps the PlacementTest component and handles navigation
* on completion or cancellation.
*/
export function PlacementTestClient({ studentId, player }: PlacementTestClientProps) {
const router = useRouter()
const handleComplete = useCallback(
(results: {
masteredSkillIds: string[]
practicingSkillIds: string[]
totalProblems: number
totalCorrect: number
}) => {
// TODO: Save results to curriculum via API
console.log('Placement test complete:', results)
// Return to main practice page
router.push(`/practice/${studentId}`, { scroll: false })
},
[studentId, router]
)
const handleCancel = useCallback(() => {
router.push(`/practice/${studentId}`, { scroll: false })
}, [studentId, router])
return (
<PageWithNav>
{/* Practice Sub-Navigation */}
<PracticeSubNav student={player} pageContext="placement-test" />
<PlacementTest
studentName={player.name}
playerId={studentId}
onComplete={handleComplete}
onCancel={handleCancel}
/>
</PageWithNav>
)
}

View File

@@ -0,0 +1,28 @@
import { notFound } from 'next/navigation'
import { getPlayer } from '@/lib/curriculum/server'
import { PlacementTestClient } from './PlacementTestClient'
interface PlacementTestPageProps {
params: Promise<{ studentId: string }>
}
/**
* Placement Test Page - Server Component
*
* Orthogonal to session state - can be accessed anytime.
* Results are saved and user is redirected to main practice page on completion.
*
* URL: /practice/[studentId]/placement-test
*/
export default async function PlacementTestPage({ params }: PlacementTestPageProps) {
const { studentId } = await params
const player = await getPlayer(studentId)
// 404 if player doesn't exist
if (!player) {
notFound()
}
return <PlacementTestClient studentId={studentId} player={player} />
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { ContinueSessionCard, PracticeSubNav } from '@/components/practice'
import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players'
import type { SessionPlan } from '@/db/schema/session-plans'
import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
import { css } from '../../../../../styled-system/css'
interface ResumeClientProps {
studentId: string
player: Player
initialSession: SessionPlan
}
/**
* Client component for the Resume page
*
* Shows the "Welcome back" card for students returning to an in-progress session.
* Uses React Query to get the most up-to-date session data (from cache if available,
* otherwise uses server-provided initial data).
*/
export function ResumeClient({ studentId, player, initialSession }: ResumeClientProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const abandonSession = useAbandonSession()
// Use React Query to get fresh session data
// If there's cached data from in-progress session, use that; otherwise use server props
const { data: fetchedSession } = useActiveSessionPlan(studentId, initialSession)
const session = fetchedSession ?? initialSession
// Handle continuing the session - navigate to main practice page
const handleContinue = useCallback(() => {
router.push(`/practice/${studentId}`, { scroll: false })
}, [studentId, router])
// Handle starting fresh - abandon current session and go to configure
const handleStartFresh = useCallback(() => {
abandonSession.mutate(
{ playerId: studentId, planId: session.id },
{
onSuccess: () => {
router.push(`/practice/${studentId}/configure`, { scroll: false })
},
}
)
}, [studentId, session.id, abandonSession, router])
return (
<PageWithNav>
{/* Practice Sub-Navigation */}
<PracticeSubNav student={player} pageContext="resume" />
<main
data-component="resume-practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: '2rem',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.25rem',
})}
>
Welcome Back!
</h1>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Continue where you left off
</p>
</header>
{/* Continue Session Card */}
<ContinueSessionCard
studentName={player.name}
studentEmoji={player.emoji}
studentColor={player.color}
session={session}
onContinue={handleContinue}
onStartFresh={handleStartFresh}
/>
</div>
</main>
</PageWithNav>
)
}

View File

@@ -0,0 +1,24 @@
import { redirect } from 'next/navigation'
// Disable caching for this page
export const dynamic = 'force-dynamic'
interface ResumePageProps {
params: Promise<{ studentId: string }>
}
/**
* Resume Session Page - DEPRECATED
*
* This page now redirects to the main practice page. The "welcome back"
* experience is now handled by the SessionPausedModal which shows automatically
* when returning to an in-progress session.
*
* URL: /practice/[studentId]/resume → redirects to /practice/[studentId]
*/
export default async function ResumePage({ params }: ResumePageProps) {
const { studentId } = await params
// The main practice page now handles the "welcome back" modal
redirect(`/practice/${studentId}`)
}

View File

@@ -0,0 +1,52 @@
import { notFound } from 'next/navigation'
import { getPlayer, getRecentSessionResults, getSessionPlan } from '@/lib/curriculum/server'
import { SummaryClient } from '../../summary/SummaryClient'
// Disable caching for this page - session data should be fresh
export const dynamic = 'force-dynamic'
interface SessionPageProps {
params: Promise<{ studentId: string; sessionId: string }>
}
/**
* Session Page - View a specific historical session
*
* URL: /practice/[studentId]/session/[sessionId]
*
* Shows the results of a specific practice session by ID.
* Used when viewing session history from the dashboard.
*/
export default async function SessionPage({ params }: SessionPageProps) {
const { studentId, sessionId } = await params
// Fetch player, session, and problem history in parallel
const [player, session, problemHistory] = await Promise.all([
getPlayer(studentId),
getSessionPlan(sessionId),
getRecentSessionResults(studentId, 100),
])
// 404 if player doesn't exist
if (!player) {
notFound()
}
// 404 if session doesn't exist or belongs to different player
if (!session || session.playerId !== studentId) {
notFound()
}
// Calculate average seconds per problem from the session
const avgSecondsPerProblem = session.avgTimePerProblemSeconds ?? 40
return (
<SummaryClient
studentId={studentId}
player={player}
session={session}
avgSecondsPerProblem={avgSecondsPerProblem}
problemHistory={problemHistory}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import { redirect } from 'next/navigation'
interface SkillsPageProps {
params: Promise<{ studentId: string }>
}
/**
* Skills Page - Redirects to Dashboard Skills Tab
*
* The skills view has been consolidated into the main dashboard
* as a tab. This redirect ensures old URLs continue to work.
*
* URL: /practice/[studentId]/skills -> /practice/[studentId]/dashboard?tab=skills
*/
export default async function SkillsPage({ params }: SkillsPageProps) {
const { studentId } = await params
redirect(`/practice/${studentId}/dashboard?tab=skills`)
}

View File

@@ -0,0 +1,283 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import {
PracticeSubNav,
ProjectingBanner,
SessionOverview,
SessionSummary,
StartPracticeModal,
} from '@/components/practice'
import { useTheme } from '@/contexts/ThemeContext'
import {
ContentBannerSlot,
SessionModeBannerProvider,
useSessionModeBanner,
} from '@/contexts/SessionModeBannerContext'
import type { Player } from '@/db/schema/players'
import type { SessionPlan } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { css } from '../../../../../styled-system/css'
// ============================================================================
// Helper Component for Banner Action Registration
// ============================================================================
/**
* Registers the action callback with the banner context and renders the ProjectingBanner.
* Must be inside SessionModeBannerProvider to access context.
*/
function BannerActionRegistrar({ onAction }: { onAction: () => void }) {
const { setOnAction } = useSessionModeBanner()
useEffect(() => {
setOnAction(onAction)
}, [onAction, setOnAction])
return <ProjectingBanner />
}
interface SummaryClientProps {
studentId: string
player: Player
session: SessionPlan | null
/** Average seconds per problem from recent sessions */
avgSecondsPerProblem?: number
/** Problem history for BKT computation in weak skills targeting */
problemHistory?: ProblemResultWithContext[]
}
/**
* Summary Client Component
*
* Displays the session results and provides navigation options.
* Handles three cases:
* - In-progress session: shows partial results
* - Completed session: shows full results
* - No session: shows empty state
*/
export function SummaryClient({
studentId,
player,
session,
avgSecondsPerProblem = 40,
problemHistory,
}: SummaryClientProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [showStartPracticeModal, setShowStartPracticeModal] = useState(false)
const [viewMode, setViewMode] = useState<'summary' | 'debug'>('summary')
// Session mode - single source of truth for session planning decisions
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
const isInProgress = session?.startedAt && !session?.completedAt
// Handle practice again - show the start practice modal
const handlePracticeAgain = useCallback(() => {
setShowStartPracticeModal(true)
}, [])
// Determine header text based on session state
const headerTitle = isInProgress
? 'Session In Progress'
: session
? 'Session Complete'
: 'No Sessions Yet'
const headerSubtitle = isInProgress
? `${player.name} is currently practicing`
: session
? 'Great work on your practice session!'
: `${player.name} hasn't completed any sessions yet`
return (
<SessionModeBannerProvider sessionMode={sessionMode ?? null} isLoading={isLoadingSessionMode}>
<BannerActionRegistrar onAction={handlePracticeAgain} />
<PageWithNav>
{/* Practice Sub-Navigation */}
<PracticeSubNav student={player} pageContext="summary" />
<main
data-component="practice-summary-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: '2rem',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.5rem',
})}
>
{headerTitle}
</h1>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{headerSubtitle}
</p>
</header>
{/* Content slot for projecting banner - shown after session completion */}
<ContentBannerSlot
className={css({ marginBottom: '1.5rem' })}
minHeight={sessionMode ? 120 : 0}
/>
{/* View Mode Toggle (only show when there's a session) */}
{session && (
<div
data-element="view-mode-toggle"
className={css({
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
marginBottom: '1.5rem',
})}
>
<button
type="button"
data-action="view-summary"
onClick={() => setViewMode('summary')}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: viewMode === 'summary' ? 'bold' : 'normal',
color: viewMode === 'summary' ? 'white' : isDark ? 'gray.300' : 'gray.600',
backgroundColor:
viewMode === 'summary' ? 'blue.500' : isDark ? 'gray.700' : 'gray.200',
borderRadius: '6px 0 0 6px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor:
viewMode === 'summary' ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
},
})}
>
Summary
</button>
<button
type="button"
data-action="view-debug"
onClick={() => setViewMode('debug')}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: viewMode === 'debug' ? 'bold' : 'normal',
color: viewMode === 'debug' ? 'white' : isDark ? 'gray.300' : 'gray.600',
backgroundColor:
viewMode === 'debug' ? 'blue.500' : isDark ? 'gray.700' : 'gray.200',
borderRadius: '0 6px 6px 0',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor:
viewMode === 'debug' ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
},
})}
>
Debug View
</button>
</div>
)}
{/* Session Summary/Overview or Empty State */}
{session ? (
viewMode === 'summary' ? (
<SessionSummary
plan={session}
studentId={studentId}
studentName={player.name}
onPracticeAgain={handlePracticeAgain}
/>
) : (
<SessionOverview plan={session} studentName={player.name} />
)
) : (
<div
className={css({
padding: '3rem',
textAlign: 'center',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<p
className={css({
fontSize: '1.125rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '1.5rem',
})}
>
Start a practice session to see results here.
</p>
<button
type="button"
onClick={handlePracticeAgain}
className={css({
padding: '0.75rem 1.5rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'blue.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: { backgroundColor: 'blue.600' },
})}
>
Start Practice
</button>
</div>
)}
</div>
</main>
{/* Start Practice Modal */}
{showStartPracticeModal && sessionMode && (
<StartPracticeModal
studentId={studentId}
studentName={player.name}
focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={null}
problemHistory={problemHistory}
onClose={() => setShowStartPracticeModal(false)}
onStarted={() => setShowStartPracticeModal(false)}
/>
)}
</PageWithNav>
</SessionModeBannerProvider>
)
}

View File

@@ -0,0 +1,63 @@
import { notFound } from 'next/navigation'
import {
getActiveSessionPlan,
getMostRecentCompletedSession,
getPlayer,
getRecentSessionResults,
} from '@/lib/curriculum/server'
import { SummaryClient } from './SummaryClient'
// Disable caching for this page - session data should be fresh
export const dynamic = 'force-dynamic'
interface SummaryPageProps {
params: Promise<{ studentId: string }>
}
/**
* Summary Page - Server Component
*
* Shows the results of a practice session:
* - If there's an in-progress session → shows partial results so far
* - If there's a completed session → shows the most recent completed session
* - If no sessions exist → shows "no sessions yet" message
*
* This page is always accessible regardless of session state.
* Parents/teachers can view progress even while a session is in progress.
*
* For viewing specific historical sessions, use /practice/[studentId]/session/[sessionId]
*
* URL: /practice/[studentId]/summary
*/
export default async function SummaryPage({ params }: SummaryPageProps) {
const { studentId } = await params
// Fetch player, active session, most recent completed session, and problem history in parallel
const [player, activeSession, completedSession, problemHistory] = await Promise.all([
getPlayer(studentId),
getActiveSessionPlan(studentId),
getMostRecentCompletedSession(studentId),
getRecentSessionResults(studentId, 100),
])
// 404 if player doesn't exist
if (!player) {
notFound()
}
// Priority: show in-progress session (partial results) > completed session > null
const sessionToShow = activeSession?.startedAt ? activeSession : completedSession
// Calculate average seconds per problem from the session
const avgSecondsPerProblem = sessionToShow?.avgTimePerProblemSeconds ?? 40
return (
<SummaryClient
studentId={studentId}
player={player}
session={sessionToShow}
avgSecondsPerProblem={avgSecondsPerProblem}
problemHistory={problemHistory}
/>
)
}

View File

@@ -1,852 +1,17 @@
'use client'
import { useCallback, useState } from 'react'
import {
ActiveSession,
type CurrentPhaseInfo,
PlanReview,
ProgressDashboard,
SessionSummary,
type SkillProgress,
StudentSelector,
type StudentWithProgress,
} from '@/components/practice'
import { ManualSkillSelector } from '@/components/practice/ManualSkillSelector'
import {
type OfflineSessionData,
OfflineSessionForm,
} from '@/components/practice/OfflineSessionForm'
import { PlacementTest } from '@/components/practice/PlacementTest'
import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext'
import type { SlotResult } from '@/db/schema/session-plans'
import { usePlayerCurriculum } from '@/hooks/usePlayerCurriculum'
import {
useApproveSessionPlan,
useEndSessionEarly,
useGenerateSessionPlan,
useRecordSlotResult,
useStartSessionPlan,
} from '@/hooks/useSessionPlan'
import { useUserPlayers } from '@/hooks/useUserPlayers'
import { css } from '../../../styled-system/css'
// Mock curriculum phase data (until we integrate with actual curriculum)
function getPhaseInfo(phaseId: string): CurrentPhaseInfo {
// Parse phase ID format: L{level}.{operation}.{number}.{technique}
const parts = phaseId.split('.')
const level = parts[0]?.replace('L', '') || '1'
const operation = parts[1] || 'add'
const number = parts[2] || '+1'
const technique = parts[3] || 'direct'
const operationName = operation === 'add' ? 'Addition' : 'Subtraction'
const techniqueName =
technique === 'direct'
? 'Direct Method'
: technique === 'five'
? 'Five Complement'
: technique === 'ten'
? 'Ten Complement'
: technique
return {
phaseId,
levelName: `Level ${level}`,
phaseName: `${operationName}: ${number} (${techniqueName})`,
description: `Practice ${operation === 'add' ? 'adding' : 'subtracting'} ${number.replace('+', '').replace('-', '')} using the ${techniqueName.toLowerCase()}.`,
skillsToMaster: [`${operation}.${number}.${technique}`],
masteredSkills: 0,
totalSkills: 1,
}
}
type ViewState =
| 'selecting'
| 'dashboard'
| 'configuring'
| 'reviewing'
| 'practicing'
| 'summary'
| 'creating'
| 'placement-test'
interface SessionConfig {
durationMinutes: number
}
import { getPlayersForViewer } from '@/lib/curriculum/server'
import { PracticeClient } from './PracticeClient'
/**
* Practice page - Entry point for student practice sessions
* Practice page - Server Component
*
* Flow:
* 1. Show StudentSelector to choose which student is practicing
* 2. Show ProgressDashboard with current progress and actions
* 3. Configure session (duration, mode)
* 4. Review generated plan
* 5. Practice!
* 6. View summary
* Fetches player list on the server and passes to client component.
* This provides instant rendering with no loading spinner.
*
* URL: /practice
*/
export default function PracticePage() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
export default async function PracticePage() {
// Fetch players directly on server - no HTTP round-trip
const players = await getPlayersForViewer()
const [viewState, setViewState] = useState<ViewState>('selecting')
const [selectedStudent, setSelectedStudent] = useState<StudentWithProgress | null>(null)
const [sessionConfig, setSessionConfig] = useState<SessionConfig>({
durationMinutes: 10,
})
// Modal states for onboarding features
const [showManualSkillModal, setShowManualSkillModal] = useState(false)
const [showOfflineSessionModal, setShowOfflineSessionModal] = useState(false)
// React Query hooks for players
const { data: players = [], isLoading: isLoadingStudents } = useUserPlayers()
// Get curriculum data for selected student
const curriculum = usePlayerCurriculum(selectedStudent?.id ?? null)
// Session plan mutations
const generatePlan = useGenerateSessionPlan()
const approvePlan = useApproveSessionPlan()
const startPlan = useStartSessionPlan()
const recordResult = useRecordSlotResult()
const endEarly = useEndSessionEarly()
// Current plan from mutations (use the latest successful result)
const currentPlan =
recordResult.data ?? startPlan.data ?? approvePlan.data ?? generatePlan.data ?? null
// Derive error state from mutations
const error = generatePlan.error
? {
context: 'generate' as const,
message: 'Unable to create practice plan',
suggestion:
'This may be a temporary issue. Try selecting a different duration or refresh the page.',
}
: startPlan.error || approvePlan.error
? {
context: 'start' as const,
message: 'Unable to start practice session',
suggestion:
'The plan was created but could not be started. Try clicking "Let\'s Go!" again, or go back and create a new plan.',
}
: null
// Convert players to StudentWithProgress format
// Note: For full curriculum enrichment, we'd need separate queries per player
// For now, use basic player data
const students: StudentWithProgress[] = players.map((player) => ({
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
createdAt: player.createdAt,
}))
// Calculate mastery percentage from skills
function calculateMasteryPercent(skills: Array<{ masteryLevel: string }>): number {
if (skills.length === 0) return 0
const mastered = skills.filter((s) => s.masteryLevel === 'mastered').length
return Math.round((mastered / skills.length) * 100)
}
// Handle student selection
const handleSelectStudent = useCallback((student: StudentWithProgress) => {
setSelectedStudent(student)
setViewState('dashboard')
}, [])
// Handle adding a new student
const handleAddStudent = useCallback(() => {
setViewState('creating')
}, [])
// Handle going back to student selection
const handleChangeStudent = useCallback(() => {
setSelectedStudent(null)
// Reset all mutations to clear plan state
generatePlan.reset()
approvePlan.reset()
startPlan.reset()
recordResult.reset()
endEarly.reset()
setViewState('selecting')
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly])
// Handle continue practice - go to session configuration
const handleContinuePractice = useCallback(() => {
setViewState('configuring')
}, [])
// Handle generating a session plan
const handleGeneratePlan = useCallback(() => {
if (!selectedStudent) return
generatePlan.reset() // Clear any previous errors
generatePlan.mutate(
{
playerId: selectedStudent.id,
durationMinutes: sessionConfig.durationMinutes,
},
{
onSuccess: () => {
setViewState('reviewing')
},
}
)
}, [selectedStudent, sessionConfig, generatePlan])
// Handle approving the plan (approve + start in sequence)
const handleApprovePlan = useCallback(() => {
if (!selectedStudent || !currentPlan) return
approvePlan.reset()
startPlan.reset()
// First approve, then start
approvePlan.mutate(
{ playerId: selectedStudent.id, planId: currentPlan.id },
{
onSuccess: () => {
startPlan.mutate(
{ playerId: selectedStudent.id, planId: currentPlan.id },
{
onSuccess: () => {
setViewState('practicing')
},
}
)
},
}
)
}, [selectedStudent, currentPlan, approvePlan, startPlan])
// Handle canceling the plan review
const handleCancelPlan = useCallback(() => {
generatePlan.reset()
approvePlan.reset()
startPlan.reset()
setViewState('configuring')
}, [generatePlan, approvePlan, startPlan])
// Handle recording an answer
const handleAnswer = useCallback(
async (result: Omit<SlotResult, 'timestamp' | 'partNumber'>): Promise<void> => {
if (!selectedStudent || !currentPlan) return
await recordResult.mutateAsync({
playerId: selectedStudent.id,
planId: currentPlan.id,
result,
})
},
[selectedStudent, currentPlan, recordResult]
)
// Handle ending session early
const handleEndEarly = useCallback(
(reason?: string) => {
if (!selectedStudent || !currentPlan) return
endEarly.mutate(
{
playerId: selectedStudent.id,
planId: currentPlan.id,
reason,
},
{
onSuccess: () => {
setViewState('summary')
},
}
)
},
[selectedStudent, currentPlan, endEarly]
)
// Handle session completion
const handleSessionComplete = useCallback(() => {
setViewState('summary')
}, [])
// Handle practice again
const handlePracticeAgain = useCallback(() => {
// Reset all mutations to clear the plan
generatePlan.reset()
approvePlan.reset()
startPlan.reset()
recordResult.reset()
endEarly.reset()
setViewState('configuring')
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly])
// Handle back to dashboard
const handleBackToDashboard = useCallback(() => {
// Reset all mutations to clear the plan
generatePlan.reset()
approvePlan.reset()
startPlan.reset()
recordResult.reset()
endEarly.reset()
setViewState('dashboard')
}, [generatePlan, approvePlan, startPlan, recordResult, endEarly])
// Handle view full progress (not yet implemented)
const handleViewFullProgress = useCallback(() => {
// TODO: Navigate to detailed progress view when implemented
}, [])
// Handle generate worksheet
const handleGenerateWorksheet = useCallback(() => {
// Navigate to worksheet generator with student's current level
window.location.href = '/create/worksheets/addition'
}, [])
// Handle opening placement test
const handleRunPlacementTest = useCallback(() => {
setViewState('placement-test')
}, [])
// Handle placement test completion
const handlePlacementTestComplete = useCallback(
(results: {
masteredSkillIds: string[]
practicingSkillIds: string[]
totalProblems: number
totalCorrect: number
}) => {
// TODO: Save results to curriculum via API
console.log('Placement test complete:', results)
// Return to dashboard after completion
setViewState('dashboard')
},
[]
)
// Handle placement test cancel
const handlePlacementTestCancel = useCallback(() => {
setViewState('dashboard')
}, [])
// Handle opening manual skill selector
const handleSetSkillsManually = useCallback(() => {
setShowManualSkillModal(true)
}, [])
// Handle saving manual skill selections
const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise<void> => {
// TODO: Save skills to curriculum via API
console.log('Manual skills saved:', masteredSkillIds)
setShowManualSkillModal(false)
}, [])
// Handle opening offline session form
const handleRecordOfflinePractice = useCallback(() => {
setShowOfflineSessionModal(true)
}, [])
// Handle submitting offline session
const handleSubmitOfflineSession = useCallback(
async (data: OfflineSessionData): Promise<void> => {
// TODO: Save offline session to database via API
console.log('Offline session recorded:', data)
setShowOfflineSessionModal(false)
},
[]
)
// Build current phase info from curriculum
const currentPhase = curriculum.curriculum
? getPhaseInfo(curriculum.curriculum.currentPhaseId)
: getPhaseInfo('L1.add.+1.direct')
// Update phase info with actual skill mastery
if (curriculum.skills.length > 0) {
const phaseSkills = curriculum.skills.filter((s) =>
currentPhase.skillsToMaster.includes(s.skillId)
)
currentPhase.masteredSkills = phaseSkills.filter((s) => s.masteryLevel === 'mastered').length
currentPhase.totalSkills = currentPhase.skillsToMaster.length
}
// Map skills to display format
const recentSkills: SkillProgress[] = curriculum.skills.slice(0, 5).map((s) => ({
skillId: s.skillId,
skillName: formatSkillName(s.skillId),
masteryLevel: s.masteryLevel,
attempts: s.attempts,
correct: s.correct,
consecutiveCorrect: s.consecutiveCorrect,
}))
// Format skill ID to human-readable name
function formatSkillName(skillId: string): string {
// Example: "add.+3.direct" -> "+3 Direct"
const parts = skillId.split('.')
if (parts.length >= 2) {
const number = parts[1] || skillId
const technique = parts[2]
const techLabel =
technique === 'direct'
? ''
: technique === 'five'
? ' (5s)'
: technique === 'ten'
? ' (10s)'
: ''
return `${number}${techLabel}`
}
return skillId
}
return (
<PageWithNav>
<main
data-component="practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: viewState === 'practicing' ? '80px' : 'calc(80px + 2rem)',
paddingLeft: viewState === 'practicing' ? '0' : '2rem',
paddingRight: viewState === 'practicing' ? '0' : '2rem',
paddingBottom: viewState === 'practicing' ? '0' : '2rem',
})}
>
<div
className={css({
maxWidth: viewState === 'practicing' ? '100%' : '800px',
margin: '0 auto',
})}
>
{/* Header - hide during practice */}
{viewState !== 'practicing' && (
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.5rem',
})}
>
Daily Practice
</h1>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Build your soroban skills one step at a time
</p>
</header>
)}
{/* Content based on view state */}
{viewState === 'selecting' &&
(isLoadingStudents ? (
<div
className={css({
textAlign: 'center',
padding: '3rem',
color: 'gray.500',
})}
>
Loading students...
</div>
) : (
<StudentSelector
students={students}
selectedStudent={selectedStudent ?? undefined}
onSelectStudent={handleSelectStudent}
onAddStudent={handleAddStudent}
/>
))}
{viewState === 'dashboard' && selectedStudent && (
<ProgressDashboard
student={selectedStudent}
currentPhase={currentPhase}
recentSkills={recentSkills}
onContinuePractice={handleContinuePractice}
onViewFullProgress={handleViewFullProgress}
onGenerateWorksheet={handleGenerateWorksheet}
onChangeStudent={handleChangeStudent}
onRunPlacementTest={handleRunPlacementTest}
onSetSkillsManually={handleSetSkillsManually}
onRecordOfflinePractice={handleRecordOfflinePractice}
/>
)}
{viewState === 'configuring' && selectedStudent && (
<div
data-section="session-config"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
padding: '2rem',
backgroundColor: 'white',
borderRadius: '16px',
boxShadow: 'md',
})}
>
<h2
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: 'gray.800',
textAlign: 'center',
})}
>
Configure Practice Session
</h2>
{/* Duration selector */}
<div>
<label
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'gray.700',
marginBottom: '0.5rem',
})}
>
Session Duration
</label>
<div
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{[5, 10, 15, 20].map((mins) => (
<button
key={mins}
type="button"
onClick={() => setSessionConfig((c) => ({ ...c, durationMinutes: mins }))}
className={css({
flex: 1,
padding: '1rem',
fontSize: '1.25rem',
fontWeight: 'bold',
color: sessionConfig.durationMinutes === mins ? 'white' : 'gray.700',
backgroundColor:
sessionConfig.durationMinutes === mins ? 'blue.500' : 'gray.100',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor:
sessionConfig.durationMinutes === mins ? 'blue.600' : 'gray.200',
},
})}
>
{mins} min
</button>
))}
</div>
</div>
{/* Session structure preview */}
<div
className={css({
padding: '1rem',
backgroundColor: 'gray.50',
borderRadius: '8px',
border: '1px solid',
borderColor: 'gray.200',
})}
>
<div
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'gray.700',
marginBottom: '0.75rem',
})}
>
Today's Practice Structure
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
fontSize: '0.875rem',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
<span>🧮</span>
<span className={css({ color: 'gray.700' })}>
<strong>Part 1:</strong> Use abacus
</span>
</div>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
<span>🧠</span>
<span className={css({ color: 'gray.700' })}>
<strong>Part 2:</strong> Mental math (visualization)
</span>
</div>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
<span>💭</span>
<span className={css({ color: 'gray.700' })}>
<strong>Part 3:</strong> Mental math (linear)
</span>
</div>
</div>
</div>
{/* Error display for plan generation */}
{error?.context === 'generate' && (
<div
data-element="error-banner"
className={css({
padding: '1rem',
backgroundColor: 'red.50',
borderRadius: '8px',
border: '1px solid',
borderColor: 'red.200',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
<div>
<div
className={css({
fontWeight: 'bold',
color: 'red.700',
marginBottom: '0.25rem',
})}
>
{error.message}
</div>
<div className={css({ fontSize: '0.875rem', color: 'red.600' })}>
{error.suggestion}
</div>
</div>
</div>
</div>
)}
{/* Action buttons */}
<div
className={css({
display: 'flex',
gap: '0.75rem',
marginTop: '1rem',
})}
>
<button
type="button"
onClick={() => {
generatePlan.reset()
setViewState('dashboard')
}}
className={css({
flex: 1,
padding: '1rem',
fontSize: '1rem',
color: 'gray.600',
backgroundColor: 'gray.100',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: 'gray.200',
},
})}
>
Cancel
</button>
<button
type="button"
onClick={handleGeneratePlan}
disabled={generatePlan.isPending}
className={css({
flex: 2,
padding: '1rem',
fontSize: '1.125rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: generatePlan.isPending ? 'gray.400' : 'green.500',
borderRadius: '8px',
border: 'none',
cursor: generatePlan.isPending ? 'not-allowed' : 'pointer',
_hover: {
backgroundColor: generatePlan.isPending ? 'gray.400' : 'green.600',
},
})}
>
{generatePlan.isPending ? 'Generating...' : 'Generate Plan'}
</button>
</div>
</div>
)}
{viewState === 'reviewing' && selectedStudent && currentPlan && (
<div data-section="plan-review-wrapper">
{/* Error display for session start */}
{error?.context === 'start' && (
<div
data-element="error-banner"
className={css({
padding: '1rem',
marginBottom: '1rem',
backgroundColor: 'red.50',
borderRadius: '12px',
border: '1px solid',
borderColor: 'red.200',
maxWidth: '600px',
margin: '0 auto 1rem auto',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>⚠️</span>
<div>
<div
className={css({
fontWeight: 'bold',
color: 'red.700',
marginBottom: '0.25rem',
})}
>
{error.message}
</div>
<div className={css({ fontSize: '0.875rem', color: 'red.600' })}>
{error.suggestion}
</div>
</div>
</div>
</div>
)}
<PlanReview
plan={currentPlan}
studentName={selectedStudent.name}
onApprove={handleApprovePlan}
onCancel={handleCancelPlan}
/>
</div>
)}
{viewState === 'practicing' && selectedStudent && currentPlan && (
<ActiveSession
plan={currentPlan}
studentName={selectedStudent.name}
onAnswer={handleAnswer}
onEndEarly={handleEndEarly}
onComplete={handleSessionComplete}
/>
)}
{viewState === 'summary' && selectedStudent && currentPlan && (
<SessionSummary
plan={currentPlan}
studentName={selectedStudent.name}
onPracticeAgain={handlePracticeAgain}
onBackToDashboard={handleBackToDashboard}
/>
)}
{viewState === 'creating' && (
<div
data-section="create-student"
className={css({
textAlign: 'center',
padding: '3rem',
})}
>
<h2
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: 'gray.800',
marginBottom: '1rem',
})}
>
Add New Student
</h2>
<p
className={css({
color: 'gray.600',
marginBottom: '2rem',
})}
>
Student creation form coming soon!
</p>
<button
type="button"
onClick={() => setViewState('selecting')}
className={css({
padding: '0.75rem 2rem',
fontSize: '1rem',
color: 'gray.700',
backgroundColor: 'gray.200',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: 'gray.300',
},
})}
>
← Back to Student Selection
</button>
</div>
)}
{viewState === 'placement-test' && selectedStudent && (
<PlacementTest
studentName={selectedStudent.name}
playerId={selectedStudent.id}
onComplete={handlePlacementTestComplete}
onCancel={handlePlacementTestCancel}
/>
)}
</div>
{/* Manual Skill Selector Modal */}
{selectedStudent && (
<ManualSkillSelector
studentName={selectedStudent.name}
playerId={selectedStudent.id}
open={showManualSkillModal}
onClose={() => setShowManualSkillModal(false)}
onSave={handleSaveManualSkills}
/>
)}
{/* Offline Session Form Modal */}
{selectedStudent && (
<OfflineSessionForm
studentName={selectedStudent.name}
playerId={selectedStudent.id}
open={showOfflineSessionModal}
onClose={() => setShowOfflineSessionModal(false)}
onSubmit={handleSubmitOfflineSession}
/>
)}
</main>
</PageWithNav>
)
return <PracticeClient initialPlayers={players} />
}

View File

@@ -0,0 +1,681 @@
'use client'
import { useCallback, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext'
import type { Player } from '@/db/schema/players'
import {
useCreatePlayer,
useDeletePlayer,
useUpdatePlayer,
useUserPlayers,
} from '@/hooks/useUserPlayers'
import { css } from '../../../styled-system/css'
// Available emojis for student selection
const AVAILABLE_EMOJIS = ['🦊', '🐸', '🐻', '🐼', '🐨', '🦁', '🐯', '🐮', '🐷', '🐵', '🦄', '🐝']
// Available colors for student avatars
const AVAILABLE_COLORS = [
'#FFB3BA', // light pink
'#FFDFBA', // light orange
'#FFFFBA', // light yellow
'#BAFFC9', // light green
'#BAE1FF', // light blue
'#DCC6E0', // light purple
'#F0E68C', // khaki
'#98D8C8', // mint
'#F7DC6F', // gold
'#BB8FCE', // orchid
'#85C1E9', // sky blue
'#F8B500', // amber
]
type ViewMode = 'list' | 'create' | 'edit'
interface EditingStudent {
id: string
name: string
emoji: string
color: string
}
/**
* Students management page
* Allows creating, editing, and deleting students (players)
*/
export default function StudentsPage() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [editingStudent, setEditingStudent] = useState<EditingStudent | null>(null)
// Form state for new/editing student
const [formName, setFormName] = useState('')
const [formEmoji, setFormEmoji] = useState(AVAILABLE_EMOJIS[0])
const [formColor, setFormColor] = useState(AVAILABLE_COLORS[0])
// React Query hooks
const { data: players = [], isLoading } = useUserPlayers()
const createPlayer = useCreatePlayer()
const updatePlayer = useUpdatePlayer()
const deletePlayer = useDeletePlayer()
// Start creating a new student
const handleStartCreate = useCallback(() => {
setFormName('')
setFormEmoji(AVAILABLE_EMOJIS[Math.floor(Math.random() * AVAILABLE_EMOJIS.length)])
setFormColor(AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)])
setEditingStudent(null)
setViewMode('create')
}, [])
// Start editing an existing student
const handleStartEdit = useCallback((player: Player) => {
setFormName(player.name)
setFormEmoji(player.emoji)
setFormColor(player.color)
setEditingStudent({
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
})
setViewMode('edit')
}, [])
// Cancel form and return to list
const handleCancel = useCallback(() => {
setFormName('')
setEditingStudent(null)
setViewMode('list')
}, [])
// Submit form (create or update)
const handleSubmit = useCallback(() => {
if (!formName.trim()) return
if (viewMode === 'create') {
createPlayer.mutate(
{
name: formName.trim(),
emoji: formEmoji,
color: formColor,
},
{
onSuccess: () => {
setViewMode('list')
setFormName('')
},
}
)
} else if (viewMode === 'edit' && editingStudent) {
updatePlayer.mutate(
{
id: editingStudent.id,
updates: {
name: formName.trim(),
emoji: formEmoji,
color: formColor,
},
},
{
onSuccess: () => {
setViewMode('list')
setEditingStudent(null)
setFormName('')
},
}
)
}
}, [viewMode, formName, formEmoji, formColor, editingStudent, createPlayer, updatePlayer])
// Delete a student
const handleDelete = useCallback(
(id: string) => {
if (!window.confirm('Are you sure you want to delete this student? This cannot be undone.')) {
return
}
deletePlayer.mutate(id, {
onSuccess: () => {
if (editingStudent?.id === id) {
setViewMode('list')
setEditingStudent(null)
}
},
})
},
[deletePlayer, editingStudent]
)
// Navigate to practice
const handleNavigateToPractice = useCallback(() => {
window.location.href = '/practice'
}, [])
const isPending = createPlayer.isPending || updatePlayer.isPending || deletePlayer.isPending
return (
<PageWithNav>
<main
data-component="students-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: 'calc(80px + 2rem)',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.5rem',
})}
>
Manage Students
</h1>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Add, edit, or remove students for practice sessions
</p>
</header>
{/* List View */}
{viewMode === 'list' && (
<div data-section="student-list">
{isLoading ? (
<div
className={css({
textAlign: 'center',
padding: '3rem',
color: 'gray.500',
})}
>
Loading students...
</div>
) : players.length === 0 ? (
<div
data-element="empty-state"
className={css({
textAlign: 'center',
padding: '3rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
boxShadow: 'md',
})}
>
<div
className={css({
fontSize: '3rem',
marginBottom: '1rem',
})}
>
👋
</div>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.5rem',
})}
>
No students yet
</h2>
<p
className={css({
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '1.5rem',
})}
>
Add your first student to get started with practice sessions.
</p>
<button
type="button"
data-action="add-first-student"
onClick={handleStartCreate}
className={css({
padding: '0.75rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'green.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: { backgroundColor: 'green.600' },
})}
>
Add Student
</button>
</div>
) : (
<>
{/* Student cards */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem',
marginBottom: '1.5rem',
})}
>
{players.map((player) => (
<div
key={player.id}
data-element="student-card"
data-student-id={player.id}
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
padding: '1.5rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
transition: 'all 0.2s',
_hover: {
boxShadow: 'md',
transform: 'translateY(-2px)',
},
})}
>
{/* Avatar */}
<div
className={css({
width: '64px',
height: '64px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
})}
style={{ backgroundColor: player.color }}
>
{player.emoji}
</div>
{/* Name */}
<h3
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
textAlign: 'center',
})}
>
{player.name}
</h3>
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
<button
type="button"
data-action="edit-student"
onClick={() => handleStartEdit(player)}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
color: isDark ? 'blue.300' : 'blue.600',
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'blue.900/50' : 'blue.100',
},
})}
>
Edit
</button>
<button
type="button"
data-action="delete-student"
onClick={() => handleDelete(player.id)}
disabled={deletePlayer.isPending}
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
color: isDark ? 'red.300' : 'red.600',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'red.900/50' : 'red.100',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
Delete
</button>
</div>
</div>
))}
</div>
{/* Action buttons */}
<div
className={css({
display: 'flex',
gap: '1rem',
justifyContent: 'center',
})}
>
<button
type="button"
data-action="add-student"
onClick={handleStartCreate}
className={css({
padding: '0.75rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'green.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: { backgroundColor: 'green.600' },
})}
>
Add Student
</button>
<button
type="button"
data-action="go-to-practice"
onClick={handleNavigateToPractice}
className={css({
padding: '0.75rem 2rem',
fontSize: '1rem',
color: isDark ? 'gray.300' : 'gray.600',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
},
})}
>
Go to Practice
</button>
</div>
</>
)}
</div>
)}
{/* Create/Edit Form */}
{(viewMode === 'create' || viewMode === 'edit') && (
<div
data-section="student-form"
className={css({
padding: '2rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
boxShadow: 'md',
})}
>
<h2
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '1.5rem',
textAlign: 'center',
})}
>
{viewMode === 'create' ? 'Add New Student' : 'Edit Student'}
</h2>
{/* Preview */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '1.5rem',
})}
>
<div
data-element="avatar-preview"
className={css({
width: '80px',
height: '80px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
boxShadow: 'md',
})}
style={{ backgroundColor: formColor }}
>
{formEmoji}
</div>
</div>
{/* Name input */}
<div className={css({ marginBottom: '1.5rem' })}>
<label
htmlFor="student-name"
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.5rem',
})}
>
Name
</label>
<input
id="student-name"
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Enter student name"
className={css({
width: '100%',
padding: '0.75rem',
fontSize: '1rem',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.800',
_focus: {
outline: 'none',
borderColor: 'blue.500',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.3)',
},
})}
/>
</div>
{/* Emoji selector */}
<div className={css({ marginBottom: '1.5rem' })}>
<label
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.5rem',
})}
>
Avatar
</label>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
{AVAILABLE_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => setFormEmoji(emoji)}
className={css({
width: '48px',
height: '48px',
fontSize: '1.5rem',
borderRadius: '8px',
border: '2px solid',
borderColor: formEmoji === emoji ? 'blue.500' : 'transparent',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.200',
},
})}
>
{emoji}
</button>
))}
</div>
</div>
{/* Color selector */}
<div className={css({ marginBottom: '2rem' })}>
<label
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.5rem',
})}
>
Color
</label>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
{AVAILABLE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormColor(color)}
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
border: '3px solid',
borderColor: formColor === color ? 'blue.500' : 'transparent',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
transform: 'scale(1.1)',
},
})}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Form actions */}
<div
className={css({
display: 'flex',
gap: '0.75rem',
})}
>
<button
type="button"
data-action="cancel"
onClick={handleCancel}
disabled={isPending}
className={css({
flex: 1,
padding: '0.75rem',
fontSize: '1rem',
color: isDark ? 'gray.300' : 'gray.600',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
Cancel
</button>
<button
type="button"
data-action="save"
onClick={handleSubmit}
disabled={isPending || !formName.trim()}
className={css({
flex: 2,
padding: '0.75rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: isPending ? 'gray.400' : 'green.500',
borderRadius: '8px',
border: 'none',
cursor: isPending ? 'not-allowed' : 'pointer',
_hover: {
backgroundColor: isPending ? 'gray.400' : 'green.600',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{isPending ? 'Saving...' : viewMode === 'create' ? 'Add Student' : 'Save Changes'}
</button>
</div>
</div>
)}
</div>
</main>
</PageWithNav>
)
}

View File

@@ -4,23 +4,23 @@ A geography quiz game where players identify countries, states, and territories
## Documentation
| Document | Description |
|----------|-------------|
| **[Architecture](./docs/ARCHITECTURE.md)** | System overview, data flow, component responsibilities |
| **[Features](./docs/FEATURES.md)** | Complete feature inventory with file references |
| **[Patterns](./docs/PATTERNS.md)** | Code conventions, component limits, testing patterns |
| **[Magnifier Architecture](./docs/MAGNIFIER_ARCHITECTURE.md)** | Deep dive on zoom system |
| **[Precision Controls](./docs/PRECISION_CONTROLS.md)** | Cursor dampening and pointer lock |
| Document | Description |
| -------------------------------------------------------------- | ------------------------------------------------------ |
| **[Architecture](./docs/ARCHITECTURE.md)** | System overview, data flow, component responsibilities |
| **[Features](./docs/FEATURES.md)** | Complete feature inventory with file references |
| **[Patterns](./docs/PATTERNS.md)** | Code conventions, component limits, testing patterns |
| **[Magnifier Architecture](./docs/MAGNIFIER_ARCHITECTURE.md)** | Deep dive on zoom system |
| **[Precision Controls](./docs/PRECISION_CONTROLS.md)** | Cursor dampening and pointer lock |
### Implementation Details
| Document | Description |
|----------|-------------|
| Document | Description |
| ------------------------------------------------------------- | ----------------------------------- |
| [Background Music](./docs/implementation/background-music.md) | Music system architecture (Strudel) |
| [Celebration System](./docs/implementation/celebration.md) | Victory animations and types |
| [Give Up Flow](./docs/implementation/give-up.md) | Give up mechanics and re-asking |
| [Map Cropping](./docs/implementation/map-cropping.md) | Viewport fitting algorithm |
| [Strudel Layering](./docs/implementation/strudel-layering.md) | Music layering implementation |
| [Celebration System](./docs/implementation/celebration.md) | Victory animations and types |
| [Give Up Flow](./docs/implementation/give-up.md) | Give up mechanics and re-asking |
| [Map Cropping](./docs/implementation/map-cropping.md) | Viewport fitting algorithm |
| [Strudel Layering](./docs/implementation/strudel-layering.md) | Music layering implementation |
---
@@ -43,13 +43,13 @@ Filter by region size: Huge → Large → Medium → Small → Tiny
### Assistance Levels
| Level | Hot/Cold | Hints | Learning Mode |
|-------|----------|-------|---------------|
| Learning | ✓ | ✓ Auto | ✓ Type name |
| Guided | ✓ | ✓ | |
| Helpful | ✓ | On request | |
| Standard | | On request | |
| None | | | |
| Level | Hot/Cold | Hints | Learning Mode |
| -------- | -------- | ---------- | ------------- |
| Learning | ✓ | ✓ Auto | ✓ Type name |
| Guided | ✓ | ✓ | |
| Helpful | ✓ | On request | |
| Standard | | On request | |
| None | | | |
---
@@ -58,6 +58,7 @@ Filter by region size: Huge → Large → Medium → Small → Tiny
### Precision Controls
Tiny regions (like Gibraltar at 0.08px) are clickable thanks to:
- **Adaptive magnifier**: 8-60x zoom based on region density
- **Cursor dampening**: Slows cursor over tiny regions
- **Pointer lock**: Pixel-precise control mode
@@ -122,6 +123,7 @@ npm run storybook
### Debug Mode
Add `?debug=1` to any URL to enable debug overlays:
- Bounding boxes
- Zoom info
- Hot/cold enable conditions
@@ -132,21 +134,21 @@ Add `?debug=1` to any URL to enable debug overlays:
### Large Files Needing Refactoring
| File | Lines | Notes |
|------|-------|-------|
| `MapRenderer.tsx` | 6,285 | Extract to feature modules |
| `GameInfoPanel.tsx` | 2,090 | Extract UI sections |
| File | Lines | Notes |
| ------------------- | ----- | -------------------------- |
| `MapRenderer.tsx` | 6,285 | Extract to feature modules |
| `GameInfoPanel.tsx` | 2,090 | Extract UI sections |
See [PATTERNS.md](./docs/PATTERNS.md) for refactoring guidelines.
### Test Coverage
| Area | Status |
|------|--------|
| Validator | ✓ Good |
| Utils | ✓ Good |
| Components | Partial |
| Hooks | Needs coverage |
| Area | Status |
| ---------- | -------------- |
| Validator | ✓ Good |
| Utils | ✓ Good |
| Components | Partial |
| Hooks | Needs coverage |
---

Some files were not shown because too many files have changed in this diff Show More