Compare commits

...

236 Commits

Author SHA1 Message Date
semantic-release-bot
937223e318 chore(abacus-react): release v2.18.0 [skip ci]
# [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.17.0...abacus-react-v2.18.0) (2026-01-01)

### Bug Fixes

* allow teacher-parents to enroll their children in other classrooms ([52df7f4](52df7f4697))
* **bkt:** handle missing helpLevelUsed in legacy data causing NaN ([b300ed9](b300ed9f5c))
* **camera:** handle race condition in camera initialization ([2a24700](2a24700e6c))
* **classroom:** auto-transition tutorial→session observation + fix NaN display ([962a52d](962a52d756))
* **classroom:** broadcast digit-by-digit answer and correct phase indicator ([fb73e85](fb73e85f2d))
* **dashboard:** compute skill stats from session results in curriculum API ([11d4846](11d48465d7))
* **db:** add missing is_paused column to session_plans ([9d8b5e1](9d8b5e1148))
* **db:** add missing journal entries for migrations 0041-0042 ([398603c](398603c75a))
* **docker:** add canvas native deps for jsdom/vitest ([5f51bc1](5f51bc1871))
* **docker:** override canvas with mock package for Alpine/musl ([8be1995](8be19958af))
* **docker:** skip canvas native build (optional jsdom dep) ([d717f44](d717f44fcc))
* **observer:** seed results panel with full session history ([aab7469](aab7469d9e))
* only show session stats when there are actual problems ([62aefad](62aefad676))
* **practice:** allow teachers to create student profiles ([5fee129](5fee1297e1))
* **practice:** always show add student FAB button ([a658414](a6584143eb))
* **practice:** real-time progress in observer modal + numeric answer comparison ([c0e63ff](c0e63ff68b))
* **practice:** show active sessions for teacher's own children ([ece3197](ece319738b))
* **practice:** use Next.js Link for student tiles + fix session observer z-index ([6def610](6def610877))
* **seed:** accurate BKT simulation for developing classifications ([d5e4c85](d5e4c858db))
* **share:** use getShareUrl for correct production URLs ([98a69f1](98a69f1f80))
* **vision:** fix manual calibration overlay not showing on remote camera ([44dcb01](44dcb01473))
* **vision:** fix remote camera calibration coordinate system ([e52f94e](e52f94e4b4))
* **vision:** swap corners diagonally for webcam orientation ([dd8efe3](dd8efe379d))

### Features

* API authorization audit + teacher enrollment UI + share codes ([d6e369f](d6e369f9dc))
* **camera:** auto-start camera when opening camera modal ([f3bb0ae](f3bb0aee4f))
* **camera:** fullscreen modal with edge-to-edge preview ([db17c96](db17c96168))
* **chart:** add grouped structure to chart hover tooltip ([594e22c](594e22c428))
* **chart:** improve skill classification visual hierarchy with colors and patterns ([c9518a6](c9518a6b99))
* **classroom:** add active sessions API endpoint ([07f6bb7](07f6bb7f9c))
* **classroom:** add real-time enrollment/unenrollment reactivity ([a0693e9](a0693e9084))
* **classroom:** add session broadcast and active session indicators ([9636f7f](9636f7f44a))
* **classroom:** add unified add-student modal with two-column layout ([dca696a](dca696a29f))
* **classroom:** add unified TeacherClassroomCard with auto-enrollment ([4d6adf3](4d6adf359e))
* **classroom:** complete reactivity fixes (Steps 7-11) ([2015494](2015494c0e))
* **classroom:** consolidate filter pill to single-row design ([78a63e3](78a63e35e3))
* **classroom:** implement enrollment system (Phase 4) ([1952a41](1952a412ed))
* **classroom:** implement entry prompts system ([de39ab5](de39ab52cc))
* **classroom:** implement real-time enrollment updates ([bbe0500](bbe0500fe9))
* **classroom:** implement real-time presence with WebSocket (Phase 6) ([629bfcf](629bfcfc03))
* **classroom:** implement real-time session observation (Step 3) ([2feb684](2feb6844a4))
* **classroom:** implement real-time skill tutorial observation ([4b73879](4b7387905d))
* **classroom:** implement teacher classroom dashboard (Phase 3) ([2202716](2202716f56))
* **classroom:** implement teacher-initiated pause and fix manual pause ([ccea0f8](ccea0f86ac))
* **classroom:** implement two-way abacus sync for session observation (Step 5) ([2f7002e](2f7002e575))
* **classroom:** improve enrollment reactivity and UX ([77336be](77336bea5b))
* **classroom:** integrate create student form into unified add-student modal ([da92289](da92289ed1))
* **classroom:** integrate Enter Classroom into StudentActionMenu ([2f1b9df](2f1b9df9d9))
* **dashboard:** add skill progress chart with trend analysis and timing awareness ([1fc8949](1fc8949b06))
* enable parents to observe children's practice sessions ([7b82995](7b82995664))
* **family:** implement parent-to-parent family code sharing (Phase 2) ([0284227](02842270c9))
* improve session summary header and add practice type badges ([518fe15](518fe153c9))
* **observer:** add live active session item to history list ([91d6d6a](91d6d6a1b6))
* **observer:** add live results panel and session progress indicator ([8527f89](8527f892e2))
* **observer:** implement shareable session observation links ([3ac7b46](3ac7b460ec))
* **practice:** add auto-rotation for captured documents ([ff79a28](ff79a28c65))
* **practice:** add document adjustment UI and auto-capture ([473b7db](473b7dbd7c))
* **practice:** add document scanning with multi-quad tracking ([5f4f1fd](5f4f1fde33))
* **practice:** add fixed filter bar, sticky headers, and shared EmojiPicker ([0e03561](0e0356113d))
* **practice:** add intervention system and improve skill chart hierarchy ([bf5b99a](bf5b99afe9))
* **practice:** add mini start practice banner to QuickLook modal ([d1176da](d1176da9aa))
* **practice:** add Needs Attention to unified compact layout ([8727782](8727782e45))
* **practice:** add photo attachments for practice sessions ([9b85311](9b853116ec))
* **practice:** add photo editing with rotation persistence and auto-detect ([156a0df](156a0dfe96))
* **practice:** add smooth fullscreen transition from QuickLook to dashboard ([cb8b0df](cb8b0dff67))
* **practice:** add student organization with filtering and archiving ([538718a](538718a814))
* **practice:** add StudentActionMenu to dashboard + fix z-index layering ([bf262e7](bf262e7d53))
* **practice:** compact single-student categories and UI improvements ([0e7f326](0e7f3265fe))
* **practice:** implement measurement-based compact layout ([1656b93](1656b9324f))
* **practice:** implement retry wrong problems system ([474c4da](474c4da05a))
* **practice:** parent session observation + relationship UI + error boundaries ([07484fd](07484fdfac))
* **practice:** polish unified student list with keyboard nav and mobile UX ([0ba1551](0ba1551fea))
* **seed:** add category field to all mock student profiles ([f883fbf](f883fbfe23))
* **session-summary:** redesign ProblemToReview with BKT integration and animations ([430c46a](430c46adb9))
* **storybook:** add TeacherClassroomCard stories ([a5e5788](a5e5788fa9))
* **vision:** add AbacusVisionBridge for physical soroban detection ([47088e4](47088e4850))
* **vision:** add ArUco marker auto-calibration for abacus detection ([9e9a06f](9e9a06f2e4))
* **vision:** add remote phone camera support for abacus detection ([8e4975d](8e4975d395))

### Performance Improvements

* reduce practice page dev bundle from 47MB to 115KB ([fd1df93](fd1df93a8f))
2026-01-01 02:42:45 +00:00
Thomas Hallock
e52f94e4b4 fix(vision): fix remote camera calibration coordinate system
- Fix hook dependency issues in AbacusVisionBridge by using destructured
  stable function references instead of remoteCamera object
- Add proper container dimension tracking for remote camera calibration
  overlay to fix coordinate mismatch
- Add rotate180 option to perspectiveTransform to support both Desk View
  (camera pointing down, needs 180° rotation) and phone cameras (no rotation)
- Phone camera cropping now uses direct mapping without rotation

The main issues fixed:
1. Hook dependencies were causing effects to run repeatedly when navigating
   to remote camera URL in a different window
2. CalibrationOverlay was using hardcoded fallback dimensions instead of
   actual container dimensions for remote camera
3. Perspective transform was applying 180° rotation which is wrong for
   phone cameras held at normal angles

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 20:41:25 -06:00
Thomas Hallock
957ff71cf1 chore: polish vision and practice components
- CalibrationOverlay: formatting fixes
- useDeskViewCamera: add SSR guard and focusMode setting
- ActiveSession: integrate vision mode changes
- DocumentAdjuster/PhotoViewerEditor: component updates
- SummaryClient: updates for practice summary
- Drizzle meta: formatting consistency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:07:49 -06:00
Thomas Hallock
44dcb01473 fix(vision): fix manual calibration overlay not showing on remote camera
- Re-run container dimension effect when isCalibrating changes
- Add height checks in addition to width checks for overlay visibility
- Add delayed dimension update to handle layout settling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:01:42 -06:00
Thomas Hallock
8e4975d395 feat(vision): add remote phone camera support for abacus detection
Enable using a phone as a remote camera source for abacus vision detection.
The phone handles calibration and perspective correction locally, then
streams cropped frames to the desktop via Socket.IO.

Features:
- QR code generation for easy phone connection
- Auto-calibration with ArUco markers on phone
- Manual calibration option
- Proper OpenCV perspective transform (not bounding box crop)
- Real-time frame streaming with frame rate display
- LAN address support via NEXT_PUBLIC_LAN_HOST env var

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:59:24 -06:00
Thomas Hallock
9e9a06f2e4 feat(vision): add ArUco marker auto-calibration for abacus detection
- Add js-aruco2 library for pure JavaScript marker detection
- Create /create/vision-markers page for downloading printable marker PDFs
- Add auto/manual calibration mode toggle to AbacusVisionBridge
- Implement automatic quad detection from 4 corner markers (IDs 0-3)
- Fix 180° rotation for Desk View camera orientation
- Add loading state for marker library initialization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 18:16:48 -06:00
Thomas Hallock
dd8efe379d fix(vision): swap corners diagonally for webcam orientation
Webcam images are oriented such that screen positions need to be
swapped diagonally to get the correct physical orientation in the
rectified output.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:25:19 -06:00
Thomas Hallock
47088e4850 feat(vision): add AbacusVisionBridge for physical soroban detection
Implements camera-based detection system for physical abacus:
- VisionCameraFeed: Camera display with Desk View auto-detection
- CalibrationOverlay: Interactive quad corner calibration with proper
  letterbox handling and bounds clamping
- Perspective transform using OpenCV.js for rectification
- Live rectified preview during calibration
- Hooks: useAbacusVision, useDeskViewCamera, useCameraCalibration,
  useFrameStability

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:20:19 -06:00
Thomas Hallock
156a0dfe96 feat(practice): add photo editing with rotation persistence and auto-detect
- Add PhotoViewerEditor component for unified viewing/editing of session photos
- Persist rotation (0/90/180/270) in database when saving edited photos
- Always show cropping UI even when no quad detected (with fallback corners)
- Add "Auto-detect edges" button to re-run document detection
- Button shows detection status: "Edges detected" / "Auto-detect edges" / "No edges found"
- Fix: clicking Done/Back in edit mode closes viewer when opened via pencil icon
- Fix: pressing Escape in edit mode closes viewer when opened via pencil icon
- Show loading screen instead of view mode flash when opening editor
- Add original file preservation for re-editing photos
- Add corners column to practice_attachments for crop coordinates
- Add rotation column to practice_attachments schema

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:34:19 -06:00
Thomas Hallock
8be19958af fix(docker): override canvas with mock package for Alpine/musl
canvas is an optional peer dep of jsdom that fails to compile on Alpine
due to missing <cstdint> includes in canvas 3.x. Override it with
canvas-mock which provides a stub implementation with no native deps.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:39:30 -06:00
Thomas Hallock
d717f44fcc fix(docker): skip canvas native build (optional jsdom dep)
canvas is an optional dependency of jsdom used for testing. It fails to
compile on Alpine/musl due to missing <cstdint> includes in canvas 3.x.
Since canvas is not required for production or even for tests, configure
pnpm to skip its native build.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:28:01 -06:00
Thomas Hallock
5f51bc1871 fix(docker): add canvas native deps for jsdom/vitest
canvas is an optional dependency of jsdom (used by vitest) and requires
native libraries (cairo, pango, pixman) to build on Alpine. Add these
dependencies to the base stage.

Also fix legacy ENV format warnings.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:23:32 -06:00
Thomas Hallock
473b7dbd7c feat(practice): add document adjustment UI and auto-capture
Adds an interstitial adjustment UI between camera capture and session
report, allowing users to fine-tune document cropping and rotation.

New features:
- DocumentAdjuster component with draggable corner handles
- Live preview of cropped/rotated result
- 90° rotation controls (clockwise/counter-clockwise)
- Auto-capture: automatically enters adjustment mode when detection
  is locked (stable for 5+ frames with >50% stability)

New hook exports:
- cv: OpenCV reference for external use
- getBestQuadCorners(): get current detected quad corners
- captureSourceFrame(): capture video frame as canvas

Flow: Camera → Auto-capture on lock → Adjust corners/rotation → Confirm → Upload

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 12:09:35 -06:00
Thomas Hallock
ff79a28c65 feat(practice): add auto-rotation for captured documents
Analyzes cropped document orientation and auto-rotates:
- Detects text line direction via horizontal vs vertical edge analysis
- Rotates 90° if text appears sideways (vertical edges dominate)
- Checks content density top vs bottom to detect upside-down
- Rotates 180° if content is heavier at bottom (likely inverted)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 12:01:41 -06:00
Thomas Hallock
5f4f1fde33 feat(practice): add document scanning with multi-quad tracking
Adds real-time document detection to the camera capture on session
summary pages. Uses OpenCV.js for edge detection and perspective
correction.

Key features:
- Multi-quad tracking: detects ALL quadrilaterals, not just largest
- Scores quads by stability over time (filters transient detections)
- Visual feedback: yellow (detecting) → green (stable) → bright green (locked)
- Auto-crops and deskews captured documents
- Falls back to raw photo if no document detected

Technical details:
- OpenCV.js (~8MB) lazy-loaded only when camera opens
- Tracks quads across frames by matching corner positions
- Filters by area (15-95% of frame) and document aspect ratios
- Locks on after 5 frames with 50%+ stability

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 11:57:50 -06:00
Thomas Hallock
9124a3182e refactor(summary): restructure session report with two-column desktop layout
Major refactoring of the session summary page to match the redesign plan:

Layout changes:
- Desktop (≥1200px): Two-column layout with Hero+Evidence left, Skills+Review right
- Extract individual components from monolithic SessionSummary
- Add ScrollspyNav fixed at bottom for mobile/tablet navigation
- Add scrollspy section attributes for navigation tracking

New components extracted:
- SessionHero: Celebration header, stats, practice type badges, trend indicator
- SkillsPanel: Skills breakdown by category with human-readable names
- ProblemsToReviewPanel: Auto-pause timing + problems needing attention
- TrendIndicator: Comparison to previous session accuracy
- ScrollspyNav: Fixed bottom navigation with dot indicator
- OfflineWorkSection: Photo upload zone with lightbox
- PhotoLightbox: Full-screen photo viewer with navigation

Utilities added:
- skillDisplay.ts: Human-readable skill name resolution
- trends.ts: Session trend calculations

Known issues documented for follow-up:
- Mobile section order needs adjustment (Evidence before Skills)
- Tablet layout not yet implemented (Skills+Review side-by-side)
- ScrollspyNav visibility breakpoint needs tuning

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:52:29 -06:00
Thomas Hallock
2d3b319e57 refactor: remove redundant Practice Again buttons from summary page
The sticky practice banner in the sub-nav already provides the "Start Practice"
action, making the bottom buttons redundant:
- Remove Practice Again button from SessionSummary
- Remove Start Practice button for photo-only sessions
- Remove Start Practice button for no-session empty state
- Remove unused onPracticeAgain prop from SessionSummary interface

Also simplifies header logic since SessionSummary handles its own header
for sessions with problems.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:32:10 -06:00
Thomas Hallock
518fe153c9 feat: improve session summary header and add practice type badges
- Show celebration header only when just completed a session (?completed=1)
- Show session date as header when viewing historical sessions
- Add practice type badges (e.g., "Use Abacus", "Visualize") to main stats
- Pass justCompleted flag through page -> client -> SessionSummary

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:13:49 -06:00
Thomas Hallock
6d36da47b3 refactor: centralize practice types in constants file
Create single source of truth for practice types in src/constants/practiceTypes.ts
with IDs, labels, descriptions, and icons. This makes it easy to add new
practice types app-wide.

Updated:
- SessionPartType now aliases PracticeTypeId from constants
- OfflineSessionModal uses PRACTICE_TYPES from constants
- offline-sessions API uses isValidPracticeTypeId for validation
- practiceTheme uses PracticeTypeId with default fallback for new types

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 04:21:34 -06:00
Thomas Hallock
62aefad676 fix: only show session stats when there are actual problems
Photo-only sessions (offline practice with just photo uploads) now show
only the photos section with upload controls, not misleading 0% accuracy
and empty stats. Sessions with problems still show full stats.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:33:22 -06:00
Thomas Hallock
ce2cb331e8 refactor: inline photo upload on session summary, remove modal
- Remove SessionPhotoGallery modal in favor of inline upload
- Add drag-and-drop support to Practice Photos section
- Upload photos immediately on file selection or camera capture
- Fullscreen camera modal using Radix Dialog
- Photos appear in grid directly on session summary page

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:30:59 -06:00
Thomas Hallock
2a24700e6c fix(camera): handle race condition in camera initialization
Add cancelled flag to prevent setting state after unmount and properly
cleanup streams that were acquired after component unmounted (e.g. in
React Strict Mode double-mounting).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:20:39 -06:00
Thomas Hallock
db17c96168 feat(camera): fullscreen modal with edge-to-edge preview
- Use Radix Dialog for proper modal management
- Camera preview fills entire viewport
- Floating close button (top-right) and capture button (bottom-center)
- iOS-style capture button design
- Works fullscreen on both mobile and desktop

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:17:06 -06:00
Thomas Hallock
310672ceb9 refactor(camera): simplify - camera always starts on mount
Removed the concept of an "unstarted" camera state. When the camera
modal opens, the camera starts immediately. Just show "Starting..."
while initializing, then the capture button once ready.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:13:12 -06:00
Thomas Hallock
f3bb0aee4f feat(camera): auto-start camera when opening camera modal
Camera now starts immediately when clicking the Camera button,
eliminating the extra "Start Camera" click.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:10:29 -06:00
Thomas Hallock
9b853116ec feat(practice): add photo attachments for practice sessions
Adds ability to upload, view, and manage photos for practice sessions:

- New database schema for practice attachments with file metadata
- Photo upload zone component with drag & drop and camera capture
- Offline session modal for logging practice done away from the app
- Session photo gallery with lightbox viewer and upload capability
- Photo indicators on session list cards
- Inline photo thumbnails on session summary page
- APIs for creating offline sessions, uploading photos, and serving files

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:33:18 -06:00
Thomas Hallock
1656b9324f feat(practice): implement measurement-based compact layout
Replace count-based heuristic with actual width measurements to determine
which single-student sections flow together on the same row.

Key changes:
- Add useMeasuredCompactLayout hook that uses useLayoutEffect to measure
  before paint (no flash of wrong layout)
- Hidden measurement container measures actual item widths
- ResizeObserver triggers re-measurement on container resize
- Group items by measured fit into rows
- Storybook stories demonstrate all layout scenarios including
  interactive resize, various widths, and mixed compact/full sections

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 12:39:37 -06:00
Thomas Hallock
8727782e45 feat(practice): add Needs Attention to unified compact layout
- Integrate "Needs Attention" section into unified compacting system
- Single attention student flows with other compact sections
- Multiple attention students get full sticky header
- Add comprehensive Storybook stories for Needs Attention scenarios:
  - NeedsAttentionSingle, NeedsAttentionMultiple
  - NeedsAttentionWithCompactBuckets, NeedsAttentionFullThenCompact
  - NeedsAttentionRealistic, NeedsAttentionDarkMode
- Update GroupedStudentsDemo to use unified renderItems array

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 12:11:44 -06:00
Thomas Hallock
0e7f3265fe feat(practice): compact single-student categories and UI improvements
- Add compact display for single-student categories so they flow
  together on the same row instead of each taking a full row
- Hide "Present" (in-classroom) filter segment when no students present
- Update in-classroom empty state with bulk-prompt instructions
- Fix classroomId ReferenceError in ViewEmptyState component
- Add Storybook stories for GroupedCategories demonstrating
  compact/full rendering patterns
- Add compact mode to StudentSelector component

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 11:59:32 -06:00
Thomas Hallock
78a63e35e3 feat(classroom): consolidate filter pill to single-row design
- Move classroom name to first segment of compound chip (replaces "Enrolled" label)
- Move add student "+" button to prefix position on the chip
- Integrate settings gear into classroom name segment
- Remove two-row card layout, now matches other filter pill styling
- Add settingsTrigger prop to TeacherCompoundChip for popover integration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 10:04:51 -06:00
Thomas Hallock
da92289ed1 feat(classroom): integrate create student form into unified add-student modal
- Integrate create student form directly into AddStudentToClassroomModal
  instead of opening a separate modal
- Left column shows choose mode (create button + family code) or create form
- Clicking "Cancel" in create mode returns to choose view
- User stays in one modal throughout the entire flow
- Hide "Link existing child" option in AddStudentModal when in classroom mode
  (this functionality is now in the unified modal)
- Remove unused onCreateStudent prop from PracticeClient

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:42:45 -06:00
Thomas Hallock
dca696a29f feat(classroom): add unified add-student modal with two-column layout
Consolidates three add-student flows into a single discoverable modal:
- Left column "Add Now": Create student button + family code input
- Right column "Invite Parents": QR code + copy code/link buttons

Also refactors TeacherClassroomCard:
- Moves classroom name from header to first filter segment
- Removes ShareCodePanel from header (now in unified modal)
- Adds classroomName prop to TeacherCompoundChip for label override

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:03:26 -06:00
Thomas Hallock
a5e5788fa9 feat(storybook): add TeacherClassroomCard stories
Add Storybook stories for the TeacherClassroomCard component with
14 variants covering:
- Basic usage (enrolled, in-classroom, active views)
- View count variations (empty, large, none present)
- Custom settings (expiry time, long names)
- Dark theme variants
- Responsive width testing

Also exports TeacherClassroomCard and TeacherClassroomCardProps
from StudentFilterBar.tsx for use in stories.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 08:05:45 -06:00
Thomas Hallock
a8f55c9f4f docs: document migration file modification failure pattern
Add CRITICAL section documenting the failure pattern where migration
files are modified after deployment, causing Drizzle to skip them
(it tracks by name, not content). This pattern has caused three
production outages in December 2025.

Includes:
- Explanation of why this happens
- The correct pattern (complete SQL before committing)
- How to fix with new migrations
- Emergency fix procedure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:56:09 -06:00
Thomas Hallock
474c4da05a feat(practice): implement retry wrong problems system
When students get problems wrong, they are now re-queued to retry:
- Problems retry in epochs (max 3 attempts per problem)
- Mastery weight decays: 100% → 50% → 25% → 0%
- Transition screen shows encouraging message between epochs
- Progress indicator shows retry attempt badges on slots
- BKT calculations respect mastery weight from retries

Also fixes entry prompts UNIQUE constraint error by marking
expired prompts before inserting new ones.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:56:48 -06:00
Thomas Hallock
a6584143eb fix(practice): always show add student FAB button
The FAB button should always be available for creating students
without auto-enrollment. Teachers now have both:
- FAB button: creates student only
- Classroom card button: creates student and auto-enrolls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:31:09 -06:00
Thomas Hallock
4d6adf359e feat(classroom): add unified TeacherClassroomCard with auto-enrollment
- Create TeacherClassroomCard component that combines:
  - Editable classroom name
  - Share code chip
  - Add student button (auto-enrolls)
  - Settings popover
  - Embedded filter tabs (Enrolled/Present/Active)

- Add auto-enrollment when teachers create students from classroom:
  - AddStudentModal accepts classroomId/classroomName props
  - Shows notice: "This student will be auto-enrolled in..."
  - Button text changes to "Add & Enroll"
  - Calls directEnrollStudent API after creation

- Add directEnrollStudent functionality:
  - New function in enrollment-manager.ts
  - POST /api/classrooms/[id]/enrollments endpoint
  - useDirectEnrollStudent hook in useClassroom.ts

- Refactor filter bar layout:
  - Move TeacherClassroomCard inside ViewSelector
  - Align filter chips to bottom
  - Match border radius and padding consistency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:26:47 -06:00
Thomas Hallock
5fee1297e1 fix(practice): allow teachers to create student profiles
Teachers were only able to add students via family code (EnrollChildModal).
This change shows the full AddStudentModal which allows both creating new
student profiles and adding existing students via code.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:44:53 -06:00
Thomas Hallock
de39ab52cc feat(classroom): implement entry prompts system
Teachers can now send entry prompts to parents requesting their child
enter the classroom. Features include:

- Entry prompts API with create, list, and respond endpoints
- Real-time notifications via WebSocket to parents
- Parent can accept (enters child) or decline prompts
- Configurable expiry time per classroom (default 30 min)
- Classroom name editing in settings popover
- Active sessions API returns sessions for all enrolled students
- E2E and unit tests for the complete feature

Also fixes bug where expired prompts could block creating new prompts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:41:47 -06:00
Thomas Hallock
ece319738b fix(practice): show active sessions for teacher's own children
The useUnifiedStudents hook was only subscribing to child session
updates for non-teachers. When a user is both a teacher AND a parent,
their child's active session wouldn't show on /practice unless the
child was also present in the classroom.

Changed from `!isTeacher` to `hasChildren` so the child session socket
is enabled for ALL users who have children.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:36:06 -06:00
Thomas Hallock
91d6d6a1b6 feat(observer): add live active session item to history list
- Add active session item at top of history tab that opens observation modal
- Create useLiveSessionTimeEstimate hook for real-time WebSocket updates
- Extract shared time estimation logic to useSessionTimeEstimate hook
- Add subscribe-session-stats socket event for lightweight session updates
- Display live progress, accuracy, idle time, and estimated time remaining
- Add corner ribbon "In Progress" indicator with two-line layout
- Use inset box-shadow for border to avoid overlapping ribbon

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 10:05:57 -06:00
Thomas Hallock
98a69f1f80 fix(share): use getShareUrl for correct production URLs
The share link API was using request.nextUrl.origin which returns
localhost when behind a reverse proxy. Now uses getShareUrl helper
which properly reads NEXT_PUBLIC_APP_URL on server-side.

Also adds comprehensive unit tests for:
- session-share.ts utilities (12 tests)
- autoPauseCalculator.ts functions (20 tests)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 18:05:51 -06:00
Thomas Hallock
aab7469d9e fix(observer): seed results panel with full session history
Previously the LiveResultsPanel only showed problems completed after the
observer connected. Now it seeds the results array from slotResults on
the first practice-state event, converting historical SlotResult data to
ObservedResult format.

This ensures observers see all completed problems, not just those
completed while actively observing.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 17:51:36 -06:00
Thomas Hallock
3ac7b460ec feat(observer): implement shareable session observation links
Add time-limited shareable links that allow anyone to observe a student's
practice session without logging in. Links expire after 1 hour or 24 hours.

Key features:
- Parents can generate share links from the observation modal/page
- Teachers cannot create share links (API enforces parent-only)
- Shared observers are view-only (no pause/resume, no abacus control)
- Links are automatically invalidated when sessions complete
- QR code and copy buttons for easy sharing
- View count and expiration tracking for active shares

New files:
- Session observation shares DB schema and migration
- Token generation utilities (10-char base62, ~59 bits entropy)
- Share CRUD API routes
- Public observation page for token-based access
- SessionShareButton component with popover UI

Modified:
- Socket server to accept token-based observer auth
- useSessionObserver hook to support shareToken param
- Session planner to revoke shares on session end
- Observer modal/page to show share button (parents only)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 17:46:34 -06:00
Thomas Hallock
8527f892e2 feat(observer): add live results panel and session progress indicator
- Add LiveResultsPanel component showing real-time problem results
- Add LiveSessionReportModal for detailed session analytics view
- Broadcast session structure (parts, slots, results) to observers
- Display SessionProgressIndicator in observer modal (same as student view)
- Show inline full report view instead of separate modal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:49:00 -06:00
Thomas Hallock
d06048bc2c Merge pull request #14 from antialias/codex/determine-auto-pause-timer-reset-criteria 2025-12-28 15:12:10 -06:00
Thomas Hallock
01a606af4e Reset auto-pause timer on digit input 2025-12-28 15:11:48 -06:00
Thomas Hallock
ca1ed1980a Merge pull request #13 from antialias/codex/add-full-screen-toggle-for-observation-modal 2025-12-28 14:59:06 -06:00
Thomas Hallock
cd259c9c58 Add full-screen practice observation view 2025-12-28 14:58:56 -06:00
Thomas Hallock
1708899183 Merge pull request #12 from antialias/codex/define-input-conditions-for-submission 2025-12-28 14:32:11 -06:00
Thomas Hallock
9f86077bef Allow manual submit input after correction threshold 2025-12-28 14:31:54 -06:00
Thomas Hallock
c0e63ff68b fix(practice): real-time progress in observer modal + numeric answer comparison
- Add currentProblemNumber and totalProblems to broadcast state
- Observer modal now shows live "Problem X of Y" updates as student progresses
- Fix answer validation to use numeric comparison (parseInt) instead of string
  comparison, so "09" correctly equals 9 (fixes red background when correct)
- Simplify MiniStartPracticeBanner to always show "Resume" for active sessions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 12:18:09 -06:00
Thomas Hallock
b31aba7aa3 refactor(family): migrate FamilyCodeDisplay to Radix Dialog
- Replace custom modal with @radix-ui/react-dialog
- Adds proper focus trapping, escape key handling, ARIA attributes
- Improves accessibility for screen readers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:26:21 -06:00
Thomas Hallock
c8f2984d7b refactor(practice): improve relationship display and dashboard components
- DashboardClient: refactor session observation and error boundary handling
- PracticeSubNav: streamline relationship indicator integration
- RelationshipCard: improve layout and styling
- RelationshipIndicator: simplify component structure
- useStudentRelationship: cleanup hook implementation
- Export relationship components from practice index

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:20:01 -06:00
Thomas Hallock
6def610877 fix(practice): use Next.js Link for student tiles + fix session observer z-index
- Replace <button> with <Link> in StudentCard for proper routing
  - Enables Cmd/Ctrl+Click to open in new tab
  - Shows URL on hover in browser status bar
  - Enables Next.js prefetching
- Close NotesModal before opening SessionObserverModal
  - Fixes z-index stacking issue where observer appeared behind quicklook

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:18:25 -06:00
Thomas Hallock
07484fdfac feat(practice): parent session observation + relationship UI + error boundaries
- Fix parent authorization for session observation (use userId not viewerId)
- Move SessionObserverModal from NotesModal to parent components to fix z-index
- Add session observation support to student dashboard
- Add PracticeErrorBoundary to dashboard for tighter error handling
- Add RelationshipBadge, RelationshipCard, RelationshipIndicator components
- Add stakeholders API endpoint and useStudentStakeholders hook
- Integrate relationship info into PracticeSubNav and StudentSelector
- Add hover cards for relationship details on student tiles

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 10:29:17 -06:00
github-actions[bot]
c631e10728 🎨 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-28 02:47:00 +00:00
Thomas Hallock
7b82995664 feat: enable parents to observe children's practice sessions
- Add GET /api/players/[playerId]/active-session endpoint
- Add useChildActiveSession and useChildrenActiveSessions hooks
- Update useUnifiedStudents to fetch child session status
- Parents now see "Watch Session" button when child is practicing

Polls every 10 seconds to detect session start/end.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:52:37 -06:00
Thomas Hallock
d6e369f9dc feat: API authorization audit + teacher enrollment UI + share codes
Security:
- Add authorization checks to curriculum API endpoints (session plans, skills, record-game)
- Add e2e tests for API authorization (positive and negative cases)
- Fix missing player_stats table migration

Classroom:
- Add TeacherEnrollmentSection for teachers to approve parent enrollment requests
- Add share code system with ShareCodePanel component and useShareCode hook
- Add /join/classroom/[code] and /join/family/[code] pages
- Remove dead code: ClassroomDashboard, ClassroomTab, StudentManagerTab

UI:
- Update StudentFilterBar and StudentSelector styling
- Fix PageTransitionOverlay z-index
- Minor chart and banner improvements

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:27:09 -06:00
Thomas Hallock
52df7f4697 fix: allow teacher-parents to enroll their children in other classrooms
Remove `!isTeacher` check from enterClassroom, leaveClassroom, and
enrollInClassroom actions. The `isMyChild` check is sufficient to
ensure these parent-specific actions only appear for the user's own
children, not for other students in their classroom roster.

This fixes the case where a parent who is also a teacher couldn't
enroll their own child in another teacher's classroom.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:29:46 -06:00
Thomas Hallock
2f1b9df9d9 feat(classroom): integrate Enter Classroom into StudentActionMenu
- Add classroom data (enrolled, current presence) to useStudentActions hook
- Add enterSpecificClassroom handler for entering a specific classroom
- Replace simple enter/leave actions with smart classroom section:
  - In classroom: shows presence indicator with leave option
  - 1 enrollment: direct enter action
  - Multiple enrollments: Radix submenu with classroom list
  - Always shows enroll option
- Remove EnterClassroomButton from dashboard (now handled by StudentActionMenu)
- Fix EnrollChildModal z-index to use same value as overlay

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:14:30 -06:00
Thomas Hallock
bf262e7d53 feat(practice): add StudentActionMenu to dashboard + fix z-index layering
- Add StudentActionMenu to student dashboard with inline variant
- Build proper StudentActionData with relationship/activity info
- Use useEnrolledClassrooms and useMyClassroom hooks for context
- Fix z-index layering for sub-modals (FamilyCodeDisplay, EnrollChildModal, SessionObserverModal)
- Use Z_INDEX.TOOLTIP (15000) for nested modals to appear above parent modals
- Remove unnecessary portal from FamilyCodeDisplay
- Add variant prop to StudentActionMenu: 'card' (absolute) vs 'inline' (normal flow)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:02:29 -06:00
Thomas Hallock
fd1df93a8f perf: reduce practice page dev bundle from 47MB to 115KB
- Dynamic import echarts-for-react in SkillProgressChart, ValidationCharts,
  and SkillDifficultyCharts to avoid bundling 58MB echarts library
- Lazy load game-registry in CreateRoomModal to prevent all arcade games
  from being bundled into every page using PageWithNav

The practice page was bundling echarts (58MB) and all arcade game code
because of static imports in shared components. These changes ensure:
- echarts only loads when charts are actually rendered
- game-registry only loads when CreateRoomModal is opened

Bundle size: 47MB → 115KB (99.8% reduction)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 08:02:08 -06:00
Thomas Hallock
d1176da9aa feat(practice): add mini start practice banner to QuickLook modal
- Create MiniStartPracticeBanner component showing session mode or active session status
- Shows "Start" for idle students (navigates to dashboard with startPractice=true)
- Shows "Resume" for practicing students (parent view)
- Shows "Watch" for practicing students (teacher view, opens SessionObserverModal)
- Add session observer integration for teachers to watch active sessions
- Mode-specific styling: amber for remediation, green for progression, blue for maintenance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:58:07 -06:00
Thomas Hallock
cb8b0dff67 feat(practice): add smooth fullscreen transition from QuickLook to dashboard
- Add PageTransitionContext to manage transition state across navigation
- Add PageTransitionOverlay component with react-spring animations
- Add fullscreen ⛶ button to NotesModal header
- Implement cross-fade transitions:
  - Modal fades out while overlay fades in at modal position
  - Overlay expands to fullscreen during navigation
  - Overlay fades out while dashboard content fades in
- Use sessionStorage to persist transition state across page navigation
- Add pulse keyframe animation for loading indicator

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:29:36 -06:00
Thomas Hallock
0ba1551fea feat(practice): polish unified student list with keyboard nav and mobile UX
Completes Steps 14-18 of the unified student list plan:

Step 14: Move edit mode to secondary menu
- Replace prominent Edit button with ⋮ dropdown menu
- Add "Select Multiple" option, "Done" button in edit mode

Step 15-16: Clean up deprecated components
- Remove ClassroomDashboard, ClassroomTab, StudentManagerTab exports
- Add StudentQuickLook alias for NotesModal

Step 17: Real-time updates
- Add useClassroomSocket to PracticeClient for live updates
- Teachers now see presence/session changes in real-time

Step 18: Mobile responsive polish
- ViewSelector: horizontal scroll on mobile, wrap on desktop
- NotesModal: responsive height for virtual keyboard

Bonus: Arrow key navigation in QuickLook modal
- Left/Right/Up/Down arrows navigate between students
- Uses bounding rects to find nearest card in direction
- Disabled when editing notes (textarea focused)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:31:52 -06:00
Thomas Hallock
576abcb89e refactor(practice): unify student actions with shared useStudentActions hook
Extract all student action logic into a single useStudentActions hook
used by both StudentActionMenu (on tiles) and NotesModal (in quicklook).
This ensures consistent action availability across the UI.

Key changes:
- Create useStudentActions hook with actions, handlers, and modal state
- Create StudentActionMenu as thin component using the hook
- Refactor NotesModal to use same hook (removes duplicated logic)
- Add studentActions.ts for shared types and getAvailableActions
- Clean up actionContext prop drilling from StudentSelector/PracticeClient
- Add useUnifiedStudents hook for consistent student data across views
- Add ViewSelector component for grid/list toggle

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:00:36 -06:00
Thomas Hallock
1b3dcbe14f docs: add rule about never directly modifying database schema
This rule documents the failure pattern from December 2025 where
columns were added directly to the local dev database, causing
migration 0043 to be committed as a no-op. Production never got
the columns and crashed.

The rule requires all schema changes to go through the Drizzle
migration system.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:41:39 -06:00
Thomas Hallock
9d8b5e1148 fix(db): add missing is_paused column to session_plans
The is_paused column was missing from migration 0043, causing a production
error: "SqliteError: no such column 'is_paused'"

The other pause columns (paused_at, paused_by, paused_reason) were added
manually during development but is_paused was overlooked.

This commit:
- Reverts 0043 to a no-op (those columns already exist)
- Adds 0044 to add only the missing is_paused column

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:28:08 -06:00
Thomas Hallock
ccea0f86ac feat(classroom): implement teacher-initiated pause and fix manual pause
- Add teacher pause/resume functionality to SessionObserverModal
- Extend PauseInfo interface with 'teacher' reason and teacherMessage
- Handle session-paused/session-resumed WebSocket events in student client
- Update SessionPausedModal UI for teacher-initiated pauses:
  - Show teacher emoji (👩‍🏫) and custom message
  - Disable resume button (only teacher can resume)
- Fix manual pause not showing modal when clicking HUD dropdown
- Add pause columns to session_plans schema (isPaused, pausedAt, pausedBy, pauseReason)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:43:15 -06:00
Thomas Hallock
2f7002e575 feat(classroom): implement two-way abacus sync for session observation (Step 5)
Teacher observing a student's practice session can now:
- Dock their MyAbacus alongside the observed problem (AbacusDock pattern)
- Manipulate their abacus to sync the student's docked abacus
- See their abacus sync when the student manipulates theirs

Key changes:
- SessionObserverModal: Uses same AbacusDock pattern as ActiveSession
  with ResizeObserver for height matching
- MyAbacus: Fixed docked mode to use setDockedValue instead of
  setAbacusValue, ensuring read/write use same state source
- MyAbacusContext: Added contextAbacusValue for external abacus control
- useSessionObserver: Sends abacus-control events to student
- useSessionBroadcast: Receives abacus-control events from teacher

The two-way sync flow:
1. Teacher→Student: Teacher moves beads → sendControl(set-abacus-value)
   → student's abacus syncs via onAbacusControl callback
2. Student→Teacher: Student moves beads → broadcasts studentAnswer
   → observer receives → setDockedValue syncs teacher's abacus

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:03:04 -06:00
Thomas Hallock
962a52d756 fix(classroom): auto-transition tutorial→session observation + fix NaN display
- Auto-transition: When teacher observes a tutorial that completes,
  automatically switch to observing the student's practice session
  - Added polling mechanism while waiting for session to start
  - Shows "Waiting for practice to start" overlay with cancel button
  - 30-second timeout to prevent indefinite waiting

- Fixed NaN display in complexity tooltip "Per term (avg)":
  - Added !Number.isNaN() checks in PurposeBadge and ActiveSession
  - Fixed problemGenerator reduce to explicitly filter NaN values
    (the ?? operator only catches null/undefined, not NaN)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:42:25 -06:00
Thomas Hallock
4b7387905d feat(classroom): implement real-time skill tutorial observation
Teachers can now observe students' skill tutorials in real-time:

- Added TutorialObserverModal that reuses SkillTutorialLauncher component
- Student broadcasts tutorial state via WebSocket (useSkillTutorialBroadcast)
- Teacher receives live updates via useClassroomTutorialStates
- Teacher controls work: Start, Skip, Prev, Next, Complete buttons
- Socket server forwards skill-tutorial-state and skill-tutorial-control events
- Added onControl prop to SkillTutorialLauncher and TutorialPlayer
- In observation mode, buttons send control events instead of changing local state

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:55:35 -06:00
Thomas Hallock
fb73e85f2d fix(classroom): broadcast digit-by-digit answer and correct phase indicator
- Add onBroadcastStateChange callback to ActiveSession for real-time state
- Change studentAnswer from number to string for digit-by-digit display
- Map internal phases correctly: inputting→problem, showingFeedback→feedback, helpMode→tutorial
- Update SessionObserverModal to display string answers directly
- Teachers can now see each digit as students type their answers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:41:10 -06:00
Thomas Hallock
2feb6844a4 feat(classroom): implement real-time session observation (Step 3)
Teachers can now observe students' practice sessions in real-time:
- Added SessionObserverModal with live problem display
- Created useSessionObserver hook for receiving broadcasts
- Refactored useSessionBroadcast to re-broadcast on observer join
- Added join-session socket event for session channel subscription
- Created shared PurposeBadge component with tooltip
- Created shared PracticeFeedback component
- Fixed tooltip z-index to work inside modals (15000)
- Used Z_INDEX constants throughout modal components

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:29:59 -06:00
Thomas Hallock
9636f7f44a feat(classroom): add session broadcast and active session indicators
Step 1: Student broadcasts practice state via WebSocket
- Create useSessionBroadcast hook that emits practice-state events
- Only broadcasts when student is present in a classroom
- Wired into PracticeClient to broadcast during active sessions

Step 2: Teacher sees active sessions indicator
- Add useActiveSessionsInClassroom to ClassroomTab
- Show "Practicing" badge with problem progress for active students
- Blue styling for practicing students vs green for idle

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:24:15 -06:00
Thomas Hallock
07f6bb7f9c feat(classroom): add active sessions API endpoint
Provides API for useActiveSessionsInClassroom hook to fetch
which students in a classroom are currently practicing.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:57:43 -06:00
Thomas Hallock
2015494c0e feat(classroom): complete reactivity fixes (Steps 7-11)
- Fix parent enrollment creation cache invalidation (Step 7)
- Add usePlayerEnrollmentSocket for student-side enrollment updates (Step 8)
- Update parent approval route to use socket-emitter helper (Step 8)
- Fix enter/leave classroom to use centralized playerKeys (Step 9)
- Add session started/ended socket events for active sessions (Step 11)
- Teacher sees real-time session updates when students in classroom

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:54:57 -06:00
Thomas Hallock
a0693e9084 feat(classroom): add real-time enrollment/unenrollment reactivity
- Add studentUnenrolled event to socket system for real-time updates
- Emit socket events when teacher or parent unenrolls a student
- Handle enrollment-approved events in usePlayerPresenceSocket
- Add EnrollChildModal with reactive success state and auto-close
  - Detects when teacher approves while modal is open
  - Shows countdown progress bar in Done button
  - Smooth fade-out transition on close

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:30:11 -06:00
Thomas Hallock
77336bea5b feat(classroom): improve enrollment reactivity and UX
Contextual Enrollment Flow:
- Add "Enroll in Classroom" option to EnterClassroomButton dropdown
- Add "Enroll" button to NotesModal for child-specific enrollment
- Remove global enrollment button from PracticeClient
- Show EnterClassroomButton even without enrollments

Reactivity Improvements (Steps 0-6):
- Create query invalidation map for consistent cache updates
- Create type-safe socket emission helper
- Add EnrollmentRequestApprovedEvent and EnrollmentRequestDeniedEvent types
- Teacher approval/denial now emits socket events to parents
- Parent denial now emits socket events to classroom (teacher)
- Refactor useParentSocket and useClassroomSocket to use invalidateForEvent
- Add enrollment-request-approved and enrollment-request-denied listeners

Both teacher and parent dashboards now update in real-time without reloading.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:14:56 -06:00
Thomas Hallock
bbe0500fe9 feat(classroom): implement real-time enrollment updates
- Add socket events when parent approves enrollment request
  - Emits enrollment-request-approved and enrollment-approved to classroom channel
  - Teacher's view updates immediately without page reload

- Add socket events to notify parents of new enrollment requests
  - Teacher adding student by family code now emits to parent's user channel
  - Parent sees new pending approvals in real-time

- Create useParentSocket hook for parent real-time notifications
  - Connects to user:${userId} channel
  - Listens for enrollment-request-created events
  - Invalidates pendingParentApprovals query

- Add "Awaiting Parent Approval" section in StudentManagerTab
  - Shows teacher-initiated requests waiting for parent response
  - Styled in blue to differentiate from pending requests

- Add useAwaitingParentApproval hook and query key
- Add AddStudentByFamilyCodeModal for teachers to add students
- Auto-approve requester's side when creating enrollment requests

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:26:54 -06:00
Thomas Hallock
629bfcfc03 feat(classroom): implement real-time presence with WebSocket (Phase 6)
- Add classroom presence management (enter/leave classroom)
- Implement WebSocket events for real-time presence updates
- Add useClassroomSocket hook for teachers to receive student enter/leave events
- Add usePlayerPresenceSocket hook for students to receive removal notifications
- Create EnterClassroomButton component for students to join classroom
- Add PendingApprovalsSection for parents to approve enrollment requests
- Fix React Query cache invalidation for enter/leave mutations
- Move socket subscription to ClassroomDashboard for cross-tab persistence
- Add getDbUserId() helper to fix viewerId vs user.id bug pattern
- Delete legacy configure/resume pages (consolidated into dashboard)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 22:14:24 -06:00
Thomas Hallock
1952a412ed feat(classroom): implement enrollment system (Phase 4)
- Add EnrollChildFlow component for parents to enroll children
- Add enrollment hooks (useEnrolledStudents, useCreateEnrollmentRequest, etc.)
- Update StudentManagerTab with enrolled students and pending requests
- Add "Enroll in another classroom" option for teachers who are parents
- Fix critical auth bug: convert guestId to user.id in all classroom API routes

The API routes were incorrectly using viewerId (guestId from cookie) when
they should have been using users.id. This caused 403 errors for all
classroom operations. Fixed by adding getOrCreateUser() to convert
guestId to proper user.id before calling business logic functions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 13:31:04 -06:00
Thomas Hallock
398603c75a fix(db): add missing journal entries for migrations 0041-0042
The classroom system migration files (0041_classroom-system.sql and
0042_classroom-system-indexes.sql) were created manually without using
drizzle-kit generate, so they weren't registered in _journal.json.

This caused drizzle to skip these migrations, resulting in:
- No parent_child table
- No family_code column on players
- No classrooms/enrollments tables

Fixes production error on /practice page.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 13:19:05 -06:00
Thomas Hallock
2202716f56 feat(classroom): implement teacher classroom dashboard (Phase 3)
Add teacher classroom functionality allowing users to create a classroom
and manage students:

- Add classroom query keys for React Query cache management
- Create useClassroom hooks (useMyClassroom, useClassroomByCode,
  useCreateClassroom, useIsTeacher)
- Add classroom UI components:
  - ClassroomCodeShare: Display join code with copy button
  - CreateClassroomForm: Form to create a classroom
  - ClassroomTab: Live classroom view (empty state for Phase 6)
  - StudentManagerTab: Enrolled students list (empty state for Phase 4)
  - ClassroomDashboard: Main teacher dashboard with tabs
- Integrate into PracticeClient with conditional routing:
  - Teachers see ClassroomDashboard with own children shown separately
  - Parents see normal student list with "Become a Teacher" option
- Fix API route to remove non-existent 'image' field from User type

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 13:07:40 -06:00
Thomas Hallock
02842270c9 feat(family): implement parent-to-parent family code sharing (Phase 2)
Add family code system allowing multiple parents to access the same child's
practice data. This is the foundation for the Suzuki Triangle model where
both parents can supervise daily practice.

Key features:
- Family codes (FAM-XXXXXX) per student for sharing access
- FamilyCodeDisplay modal for viewing/copying/regenerating codes
- LinkChildForm modal for linking to existing child via code
- parent_child junction table for many-to-many relationships
- React Query mutations with proper cache invalidation

API routes:
- GET/POST /api/family/children/[playerId]/code - manage family codes
- POST /api/family/link - link to child via family code
- GET /api/family/children - list linked children

Also includes classroom system foundation (Phase 1):
- Classroom, enrollment, and presence schemas
- Teacher classroom management APIs
- Student enrollment request workflow
- Real-time presence tracking infrastructure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 12:41:20 -06:00
Thomas Hallock
dfc2627ccb refactor(bkt): unify skill classification logic into shared BktContext
- Add ExtendedSkillClassification type with 5 categories (strong, stale, developing, weak, unassessed)
- Add getExtendedClassification() as single source of truth for classification
- Add SkillDistribution interface for consistent counts across components
- Update ProgressDashboard to display grouped skill categories with visual hierarchy
- Update DashboardClient to compute full 5-category distribution
- Update SkillProgressChart to use shared types from BktContext

This ensures Overview and Skills tabs use identical classification logic.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:30:34 -06:00
Thomas Hallock
594e22c428 feat(chart): add grouped structure to chart hover tooltip
- Tooltip now shows MASTERED, IN PROGRESS, NOT STARTED group headers
- Categories organized under their respective groups (Strong/Stale, Developing/Weak, Unassessed)
- Styled tooltip background for light/dark mode
- Skip empty categories (0 count) from tooltip display
- Consistent visual hierarchy with legend cards

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:10:38 -06:00
Thomas Hallock
c9518a6b99 feat(chart): improve skill classification visual hierarchy with colors and patterns
- Add diagonal stripe pattern to Stale and Weak categories to indicate "needs attention"
- Update color scheme: Strong/Stale share green family, Developing stays blue, Weak is coral
- Make Unassessed nearly transparent with dashed border in legend
- Legend cards now have colored backgrounds matching chart colors
- Add group headers (Mastered, In Progress, Not Started) with vertical dividers
- Ensure text contrast works in both light and dark modes
- Add "(7+ days ago)" descriptor to Stale card for clarity

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:08:18 -06:00
Thomas Hallock
bf5b99afe9 feat(practice): add intervention system and improve skill chart hierarchy
- Add "Needs Attention" section to /practice page surfacing struggling students
- Implement intervention detection (struggling, declining, stale, absent, plateau)
- Group skill chart legend into Mastered/In Progress/Not Started hierarchy
- Use color families: green for mastered (strong+stale), patterns for attention states
- Add diagonal stripe pattern to Stale and Weak categories in chart and legend
- Update tooltip to show same grouped structure with tree branches
- Fix encouragement message to not celebrate when weak skills are growing
- Make Unassessed transparent/dashed to fade into background

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:00:39 -06:00
Thomas Hallock
1fc8949b06 feat(dashboard): add skill progress chart with trend analysis and timing awareness
Major changes:

1. Sentinel approach for skill recency refresh
   - Added 'recency-refresh' session status and SlotResult source field
   - BKT skips pKnown updates for sentinel records (zero-weight)
   - refreshSkillRecency() now inserts sentinel records instead of updating DB field
   - Single source of truth: all lastPracticedAt comes from problem history

2. Skill progress chart (new component)
   - 100% stacked area chart showing skill distribution over time
   - Clickable legend cards for filtering by classification
   - Adaptive time window presets (Recent, Last N, All)
   - Synthetic "current" snapshot ensures chart matches legend staleness

3. Trend-aware encouragement messages
   - Linear regression to detect improving/declining/stable trends
   - Session timing analysis (gap detection, frequency patterns)
   - Window-aware scope text ("over the past 2 weeks", "since Nov 15")
   - Priority-based message selection (urgent issues first)
   - Consistent "stale" terminology (not "rusty")

4. Dashboard improvements
   - Virtualized session history list for performance
   - Paginated session history API with cursor-based pagination
   - BKT-computed lastPracticedAt used throughout (single source of truth)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 14:37:15 -06:00
Thomas Hallock
c5a0586094 refactor(practice): remove per-skill accuracy in favor of BKT classification
Complete removal of per-skill accuracy tracking from the practice system.
BKT-based mastery classification (weak/developing/strong) now serves as
the sole measure of skill proficiency.

Changes:
- Remove accuracy field from skill stats and related components
- Remove accuracyMultiplier from tuning adjustments
- Simplify session summary to use BKT classification only
- Update DashboardClient to remove accuracy-based displays
- Clean up unused accuracy utility functions and theme colors
- Update journey simulator to remove accuracy references

This aligns with the design principle that BKT pKnown is a more reliable
indicator of true mastery than raw accuracy percentages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:28:50 -06:00
Thomas Hallock
d5e4c858db fix(seed): accurate BKT simulation for developing classifications
Fixed the seed script to reliably produce "developing" skill classifications
(pKnown 0.5-0.8) by correcting several issues:

- Fix BKT simulation to only apply learning transition after CORRECT answers
  (matching actual updateOnCorrect vs updateOnIncorrect behavior)
- Remove result shuffling to preserve designed correct/incorrect sequence order
- Force single-skill problem annotations to avoid multi-skill blame distribution
- Add multiple pattern generators for finding developing-range sequences

The simulation now accurately predicts actual BKT outcomes, enabling 10
developing classifications across 6 test profiles.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:27:59 -06:00
Thomas Hallock
c522620e46 refactor(help): rename helpLevel terminology to hadHelp boolean
The codebase previously used "help level" terminology (HelpLevel type,
helpLevelUsed field, helpLevelWeight function) which implied a graduated
scale. Since the system now only tracks whether help was used or not,
this renames everything to use proper boolean terminology.

Changes:
- Delete HelpLevel type, use boolean directly
- Rename helpLevelUsed → hadHelp in SlotResult
- Rename lastHelpLevel → lastHadHelp in PlayerSkillMastery schema
- Rename helpLevelWeight() → helpWeight() with boolean parameter
- Update all components, tests, stories, and documentation
- Add database migration for column rename

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:04:52 -06:00
Thomas Hallock
446678799c refactor(help): simplify to binary help system + add seed script CLI
Help System Cleanup:
- Simplify HelpLevel from 0|1|2|3 to binary 0|1 (matching actual usage)
- Change BKT help weight from 0.8 to 0.5 for helped answers
- Delete unused PracticeHelpPanel.tsx (~540 lines)
- Delete unused usePracticeHelp.ts (~400 lines)
- Delete unused reinforcement-config.ts
- Remove reinforcement tracking (BKT handles skill weakness detection)
- Update all journey simulator profiles to use binary help arrays
- Update documentation (BKT_DESIGN_SPEC.md, decomposition README)

Seed Script Improvements:
- Add CLI argument parsing with node:util parseArgs
- Add --help, --list, --name, --category, --dry-run flags
- Add new "Forgotten Weaknesses" test profile (weak + stale skill mix)
- Enable seeding individual students or categories

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 06:28:27 -06:00
github-actions[bot]
b9d4bc552a 🎨 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-21 02:41:19 +00:00
Thomas Hallock
7d03d8c69b test(seed): update NaN stress test to cover missing helpLevelUsed root cause
- Add simulateLegacyData option to SkillConfig interface
- When set, generateSlotResults omits helpLevelUsed field to simulate legacy data
- Update NaN Stress Test profile to test 3 skills with legacy data format
- This tests the actual production issue where old sessions lack helpLevelUsed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:44:15 -06:00
Thomas Hallock
f883fbfe23 feat(seed): add category field to all mock student profiles
All 15 profiles now have correct category assignments:
- 5 BKT profiles (category: 'bkt')
- 4 Session profiles (category: 'session')
- 6 Edge case profiles (category: 'edge')

This enables CLI filtering when seeding specific profile types.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:36:56 -06:00
Thomas Hallock
b300ed9f5c fix(bkt): handle missing helpLevelUsed in legacy data causing NaN
Root cause: Old production data (before Dec 2024) is missing the
`helpLevelUsed` field. The `helpLevelWeight` function had no default
case in its switch statement, returning `undefined` when called with
undefined, which caused `undefined * rtWeight = NaN` to propagate
through BKT calculations.

Changes:
- evidence-quality.ts: Add default case returning 1.0 for undefined/null
  and add NaN guard to responseTimeWeight
- bkt-core.ts: Add NaN guards that surface invalid data with console.warn
- conjunctive-bkt.ts: Add NaN guards for multi-skill BKT updates
- compute-bkt.ts: Skip problem results that would produce NaN, preserving
  prior state rather than corrupting skill estimates
- bkt-integration.ts: Add NaN guard to calculateBktMultiplier with
  conservative fallback
- DashboardClient.tsx: Add UI error state for NaN pKnown values showing
  "⚠️ Data Error" instead of displaying "~NaN%"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:31:47 -06:00
Thomas Hallock
f760ec130e refactor(summary): replace compact/detailed toggle with tap-for-details popover
- Remove view mode toggle (Compact/Detailed) - confusing UX
- Add problem numbers (#1, #2...) to compact view for easy reference
- Make each problem tappable to open detail popover
- Popover shows: equation, student answer if wrong, response time,
  pause threshold, help level, abacus usage, skills exercised
- Close popover with tap outside, X button, or Escape key

Users can now see all problems at a glance with numbers, and tap any
specific problem to get the full details without losing their place.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:55:57 -06:00
Thomas Hallock
d6c8e582a7 refactor(skills): unify skill presentation with SkillCard grid
- Replace horizontal rows in stale skills section with SkillCard grid
- Move "Mark Current" action into SkillDetailDrawer for stale skills
- Add staleness alert section in drawer with explanation and action
- Consistent visual presentation between practicing and stale skills
- User can now tap any stale skill to see details and mark as current

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:16:35 -06:00
Thomas Hallock
0e0356113d feat(practice): add fixed filter bar, sticky headers, and shared EmojiPicker
- Make StudentFilterBar fixed below nav with proper z-index layering
- Add sticky bucket headers (Today, This Week, etc.) and category headers
- Move bulk actions into filter bar (shown in edit mode in place of search)
- Create shared EmojiPicker component with emojibase-data integration
- Simplify AddStudentModal to use shared EmojiPicker (single way to pick emoji)
- Add z-index constants for filter bar and sticky headers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:17:12 -06:00
Thomas Hallock
538718a814 feat(practice): add student organization with filtering and archiving
Implements comprehensive student organization system for /practice page:

Database:
- Add isArchived column to players table (migration 0039)
- Drop unused skill stat columns from playerSkillMastery (migration 0038)

New features:
- Search bar for filtering by student name or skill
- Skill filter pills with AND logic
- Hierarchical grouping: recency buckets → skill categories
- Archive/unarchive from notes modal
- Edit mode with bulk checkbox selection
- Bulk archive for selected students
- Show/hide archived students toggle

New files:
- src/constants/skillCategories.ts - shared skill category definitions
- src/utils/studentGrouping.ts - grouping/filtering logic
- src/utils/skillSearch.ts - skill search utilities
- src/components/practice/StudentFilterBar.tsx - filter bar component

Updated:
- PracticeClient.tsx - filter state management, grouped display
- StudentSelector.tsx - edit mode, archived badges
- NotesModal.tsx - archive button in footer
- ManualSkillSelector.tsx - uses shared skill categories
- server.ts - getPlayersWithSkillData() for enhanced queries

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 10:37:34 -06:00
Thomas Hallock
11d48465d7 fix(dashboard): compute skill stats from session results in curriculum API
The dashboard fetches skills from /api/curriculum/[playerId] which was
returning raw playerSkillMastery records with persisted attempts/correct
values (always 0 for seeded data).

Updated the API to compute skill stats from session results, consistent
with the recent single-source-of-truth refactor.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:39:38 -06:00
Thomas Hallock
f804d24a29 refactor(skills): compute skill stats from session results (single source of truth)
Previously, skill stats (attempts, correct, accuracy) were stored as
persisted aggregates in playerSkillMastery and updated incrementally.
This caused issues with seeded test data showing "0 correct".

Now:
- analyzeSkillPerformance() computes stats from session results on-the-fly
- findStrugglingSkills() computes accuracy from session results
- Seeder no longer needs to update aggregate columns

Benefits:
- Single source of truth (session results)
- No drift between aggregates and actual data
- Seeded data automatically works correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:31:16 -06:00
Thomas Hallock
35720820f3 refactor(seeder): reframe problematic profiles for realistic data generation
- Rename "⚖️ Balanced Mix" → "⚖️ Multi-Weak Remediation" to match actual output
- Update intention notes to reflect BKT's natural tendency to push skills to extremes
- Remove tuning criteria that fought against realistic behavior
- Focus profiles on app feature coverage rather than perfect BKT states

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:10:34 -06:00
Thomas Hallock
430c46adb9 feat(session-summary): redesign ProblemToReview with BKT integration and animations
ProblemToReview component improvements:
- Use AnnotatedProblem for unified collapsed/expanded problem display
- Integrate BKT mastery data for weak skill detection and ordering
- Add smooth CSS Grid animations for expand/collapse transitions
- Make header row clickable (toggle moved from footer to header)
- Add purpose explanations and timing info in expanded mode
- Show up to 3 weak skills in collapsed mode with "+N more" indicator

React Query cache fix:
- Fix "Mark Current" button not updating UI (stale cache issue)
- Replace plain fetch + router.refresh() with useRefreshSkillRecency mutation
- Mutation properly invalidates curriculumKeys.detail(playerId)

Documentation:
- Add CLAUDE.md section on React Query mutation patterns
- Document the relationship between mutations and query cache invalidation
- Include checklist to prevent future cache invalidation bugs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 07:31:43 -06:00
semantic-release-bot
05aa87ffd2 chore(abacus-react): release v2.17.0 [skip ci]
# [2.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.16.0...abacus-react-v2.17.0) (2025-12-20)

### Bug Fixes

* **modal:** expand settings by default on tall phones to fill space ([2ffb71a](2ffb71ab28))
* **modal:** make StartPracticeModal full-screen on mobile phones ([1383db8](1383db8185))

### Features

* **banner:** add scroll-based projection with FLIP animation ([d405038](d405038711))
* **modal:** redesign StartPracticeModal layout for tall phones ([0e2fcee](0e2fcee0ae))
* **practice:** unify session resume/start into projecting banner ([d128e80](d128e808db))
* **session-summary:** unify summary and debug views with progressive disclosure ([2977bd5](2977bd57df))
2025-12-20 03:16:36 +00:00
Thomas Hallock
2977bd57df feat(session-summary): unify summary and debug views with progressive disclosure
Major changes:
- Merge SessionSummary and SessionOverview into unified experience
- Add "Problems Worth Attention" section with expandable problem details
- Add "All Problems" collapsible section with compact/detailed toggle
- Keep auto-pause timing info in unified view
- Remove debug view toggle from SummaryClient
- Delete SessionOverview.tsx (replaced by new components)

New components:
- AllProblemsSection.tsx - collapsible all-problems view
- ProblemToReview.tsx - expandable problem row with reason badges
- sessionSummaryUtils.ts - filtering utilities for attention-worthy problems

Bug fix:
- Fix ROTATION_MULTIPLIERS import in DashboardClient (was undefined due to re-export chain)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 21:15:14 -06:00
Thomas Hallock
4b291b304b refactor(practice): remove stub features and add stale skills refresh
Remove unimplemented features that didn't actually save data:
- Placement test: UI worked but results were never persisted
- Offline session recording: form collected data but just logged it
- View Progress button: was an empty callback
- Worksheet button: redirected but didn't integrate with practice

Add functional stale skills management:
- Add "Mark Current" refresh buttons to Skills tab stale skills section
- Show BKT status badges and staleness warnings
- Wire up refresh API to update skill recency

Clean up dead code:
- Delete SkillsClient.tsx (was unused, /skills redirects to dashboard)
- Remove onRefreshSkill from ManualSkillSelector modal
- Add BKT badges to ManualSkillSelector for skill status visibility

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:30:02 -06:00
Thomas Hallock
aef5fadf86 refactor: remove fluency system terminology from comments
Replace outdated "fluency-based" and "fluency multipliers" terminology
with "discrete multipliers (practicing/not_practicing)" throughout
the codebase. BKT now handles fine-grained mastery estimation.

Changes:
- Update bkt-integration.ts mode descriptions
- Update complexity-budgets.ts mode comparison comments
- Update skillComplexity.ts calculator documentation
- Update useSessionPlan.ts hook parameter descriptions
- Update progress-manager.ts and API route comments
- Update comprehensive A/B test documentation
- Update skillComplexity.test.ts to test 2-state model

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 13:28:49 -06:00
Thomas Hallock
d405038711 feat(banner): add scroll-based projection with FLIP animation
- Banner projects from content area to nav when scrolled under sticky header
- Uses IntersectionObserver to detect when content banner scrolls out of view
- FLIP animation smoothly transitions between slots with spring physics
- Preserves content slot space when projecting to prevent layout jump
- Uses react-use-measure for accurate dimension tracking
- Animates both position and size (width/height) between variants
- Nav slot collapses to height:0 when content slot is active
- Animation overlay uses higher z-index to appear above sticky nav

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 10:17:47 -06:00
Thomas Hallock
0c40c28a9a refactor(banner): implement FLIP animation for document-flow banners
Replace fixed-position ProjectingBanner with FLIP-based BannerSlots:

- Banners now render in document flow (relative positioning)
- Content flows naturally around banners
- FLIP animation only uses fixed positioning during ~400ms transition
- After animation, banner returns to document flow at target slot

Architecture:
- ContentBannerSlot/NavBannerSlot: Render content in-flow when active
- ProjectingBanner: Orchestrates FLIP animation during slot transitions
- Uses framer-motion springs for smooth position/size interpolation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 09:13:53 -06:00
Thomas Hallock
d128e808db feat(practice): unify session resume/start into projecting banner
Consolidates two competing CTAs (resume button in dashboard vs practice
button in banner) into a single unified experience in the session banner.

When an active session exists, the projecting banner now shows:
- Session progress (X/Y problems completed)
- When started / last activity timestamps
- Focus description and skill changes since session start
- "Resume Session" button to continue where left off
- "Start Fresh" link to open modal (session only abandoned when new one starts)

Changes:
- Add ActiveSessionBanner component with dashboard and nav variants
- Add computeSkillChanges utility for tracking skill state changes
- Extend SessionModeBannerContext with activeSession state and handlers
- Modify ProjectingBanner to conditionally render ActiveSessionBanner
- Remove resume button and activeSession prop from ProgressDashboard
- Wire up active session data in DashboardClient

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 08:08:36 -06:00
Thomas Hallock
9abf29d9fc refactor(modal): simplify StartPracticeModal layout to single responsive design
- Remove all "tall phone" media queries (@media max-width: 480px and min-height: 700px)
- Remove useEffect that auto-expanded settings on tall phones
- Use CSS order: 99 on session-config to always show CTAs first, settings last
- Remove redundant header section (🎯 Ready to practice?) - CTAs provide context
- Keep Dialog.Title/Description as sr-only for accessibility
- Simplify to just 2 layouts: default and landscape mode
- Format SessionProgressIndicator.stories.tsx

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 07:06:45 -06:00
Thomas Hallock
2ffb71ab28 fix(modal): expand settings by default on tall phones to fill space
- Settings now expand automatically on tall phones (height >= 700px)
- Increased gap between sections (1rem -> 1.5rem)
- Restored normal padding and gaps inside expanded settings
- Larger labels and button gaps in settings on tall phones

This fills the empty space by showing all settings expanded,
making the layout feel intentional rather than sparse.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 06:44:29 -06:00
Thomas Hallock
0e2fcee0ae feat(modal): redesign StartPracticeModal layout for tall phones
On tall phones (width ≤ 480px AND height ≥ 700px):
- Reorder content: hero CTA banner first, settings second (CSS order)
- Hero CTA (tutorial/remediation/generic) is larger, centered, more prominent
  - 2.5rem emoji, 1.125rem title, generous padding
  - Centered vertical layout with skill badges
- Session settings appear below as secondary option
- Title and icon restored to full size
- Generous spacing creates intentional, designed appearance
- Content vertically centered with balanced whitespace

Fixes the "empty middle gap" issue where settings were at top
and CTA was pushed to bottom with marginTop: auto.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 06:41:38 -06:00
Thomas Hallock
1383db8185 fix(modal): make StartPracticeModal full-screen on mobile phones
The modal was only going full-screen based on max-height: 700px,
which didn't trigger on iPhone 14 (844px height). Now also triggers
on max-width: 480px to cover all phone-sized screens.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 20:09:49 -06:00
Thomas Hallock
6a98f6af95 chore: remove unused SkillUnlockBanner component
Superseded by CelebrationProgressionBanner which is integrated
into SessionModeBanner and handles skill unlock celebrations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 20:07:59 -06:00
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
556 changed files with 186174 additions and 27273 deletions

View File

@@ -181,9 +181,254 @@
"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:*)",
"Bash(apps/web/package.json )",
"Bash(pnpm-lock.yaml )",
"Bash(apps/web/src/components/practice/BannerSlots.tsx )",
"Bash(apps/web/src/components/practice/BannerSlots.stories.tsx )",
"Bash(apps/web/src/components/practice/ProjectingBanner.tsx )",
"Bash(apps/web/src/components/practice/ProjectingBanner.stories.tsx )",
"Bash(apps/web/src/components/practice/PracticeSubNav.tsx )",
"Bash(apps/web/src/contexts/SessionModeBannerContext.tsx )",
"Bash(apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx)",
"Bash(\"apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx\" )",
"Bash(apps/web/src/utils/__tests__/problemGenerator.budget.test.ts )",
"Bash(apps/web/src/utils/__tests__/problemGenerator.stateAware.test.ts )",
"Bash(apps/web/src/utils/__tests__/skillComplexity.test.ts )",
"Bash(apps/web/src/lib/curriculum/progress-manager.ts )",
"Bash(apps/web/src/lib/curriculum/config/complexity-budgets.ts )",
"Bash(apps/web/src/lib/curriculum/config/skill-costs.ts )",
"Bash(apps/web/src/lib/curriculum/config/bkt-integration.ts )",
"Bash(apps/web/src/app/api/curriculum/[playerId]/skills/route.ts )",
"Bash(apps/web/src/utils/skillComplexity.ts )",
"Bash(apps/web/src/test/journey-simulator/comprehensive-ab-test.test.ts )",
"Bash(apps/web/src/components/practice/TermSkillAnnotation.tsx )",
"Bash(apps/web/src/components/practice/DetailedProblemCard.tsx )",
"Bash(apps/web/src/db/schema/session-plans.ts)",
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/skills/route.ts\" )",
"Bash(npm run test:*)",
"Bash(\"apps/web/src/app/practice/[studentId]/placement-test/\" )",
"Bash(\"apps/web/src/app/practice/[studentId]/skills/SkillsClient.tsx\" )",
"Bash(\"apps/web/src/components/practice/ManualSkillSelector.tsx\" )",
"Bash(\"apps/web/src/components/practice/OfflineSessionForm.tsx\" )",
"Bash(\"apps/web/src/components/practice/OfflineSessionForm.stories.tsx\" )",
"Bash(\"apps/web/src/components/practice/PlacementTest.tsx\" )",
"Bash(\"apps/web/src/components/practice/PlacementTest.stories.tsx\" )",
"Bash(\"apps/web/src/components/practice/PracticeSubNav.tsx\" )",
"Bash(\"apps/web/src/components/practice/ProgressDashboard.tsx\" )",
"Bash(\"apps/web/src/components/practice/ProgressDashboard.stories.tsx\" )",
"Bash(\"apps/web/src/lib/curriculum/placement-test.ts\" )",
"Bash(\"apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts\")",
"Bash(mcp__sqlite__read_query:*)",
"Bash(mcp__sqlite__describe_table:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(npx tsx:*)",
"Bash(xargs ls:*)",
"Bash(mcp__sqlite__list_tables)",
"WebFetch(domain:developer.chrome.com)",
"Bash(claude mcp add:*)",
"Bash(claude mcp:*)",
"Bash(git rev-parse:*)",
"Bash(wc:*)",
"Bash(src/lib/classroom/query-invalidations.ts )",
"Bash(src/lib/classroom/socket-emitter.ts )",
"Bash(src/lib/classroom/socket-events.ts )",
"Bash(src/lib/queryKeys.ts )",
"Bash(src/hooks/useClassroomSocket.ts )",
"Bash(src/hooks/useParentSocket.ts )",
"Bash(\"src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve/route.ts\" )",
"Bash(\"src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny/route.ts\" )",
"Bash(\"src/app/api/enrollment-requests/[requestId]/deny/route.ts\" )",
"Bash(src/components/practice/NotesModal.tsx )",
"Bash(src/components/classroom/EnterClassroomButton.tsx )",
"Bash(src/components/classroom/index.ts )",
"Bash(src/app/practice/PracticeClient.tsx)",
"Bash(apps/web/src/app/api/curriculum/[playerId]/sessions/plans/[planId]/route.ts )",
"Bash(apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts )",
"Bash(apps/web/src/components/classroom/EnterClassroomButton.tsx )",
"Bash(apps/web/src/hooks/useClassroom.ts )",
"Bash(apps/web/src/hooks/useClassroomSocket.ts )",
"Bash(apps/web/src/hooks/usePlayerEnrollmentSocket.ts )",
"Bash(apps/web/src/lib/classroom/query-invalidations.ts )",
"Bash(apps/web/src/lib/classroom/socket-emitter.ts )",
"Bash(apps/web/src/lib/classroom/socket-events.ts)",
"Bash(apps/web/src/components/practice/SessionObserver.tsx )",
"Bash(apps/web/src/components/practice/TeacherPausedOverlay.tsx )",
"Bash(apps/web/drizzle/0043_add_session_pause_columns.sql )",
"Bash(apps/web/drizzle/meta/0043_snapshot.json)",
"Bash(apps/web/src/hooks/useSessionBroadcast.ts )",
"Bash(apps/web/src/app/practice/[studentId]/PracticeClient.tsx )",
"Bash(apps/web/src/components/classroom/ClassroomTab.tsx)",
"Bash(src/app/practice/[studentId]/PracticeClient.tsx )",
"Bash(src/components/classroom/ClassroomDashboard.tsx )",
"Bash(src/components/classroom/ClassroomTab.tsx )",
"Bash(src/components/classroom/SessionObserverModal.tsx )",
"Bash(src/components/practice/ActiveSession.tsx )",
"Bash(src/components/practice/index.ts )",
"Bash(src/components/practice/PracticeFeedback.tsx )",
"Bash(src/components/practice/PurposeBadge.tsx )",
"Bash(src/components/ui/Tooltip.tsx )",
"Bash(src/constants/zIndex.ts )",
"Bash(src/hooks/useSessionBroadcast.ts )",
"Bash(src/hooks/useSessionObserver.ts )",
"Bash(src/socket-server.ts)",
"Bash(src/components/MyAbacus.tsx )",
"Bash(src/contexts/MyAbacusContext.tsx )",
"Bash(src/components/practice/StartPracticeModal.tsx )",
"Bash(src/components/tutorial/SkillTutorialLauncher.tsx )",
"Bash(src/hooks/useSkillTutorialBroadcast.ts)",
"Bash(\"src/app/practice/[studentId]/PracticeClient.tsx\" )",
"Bash(apps/web/drizzle/meta/0044_snapshot.json )",
"Bash(apps/web/drizzle/meta/_journal.json )",
"Bash(apps/web/src/app/practice/PracticeClient.tsx )",
"Bash(apps/web/src/components/classroom/EnrollChildModal.tsx )",
"Bash(apps/web/src/components/classroom/index.ts )",
"Bash(apps/web/src/components/family/FamilyCodeDisplay.tsx )",
"Bash(apps/web/src/components/practice/NotesModal.tsx )",
"Bash(apps/web/src/components/practice/StudentFilterBar.tsx )",
"Bash(apps/web/src/components/practice/StudentSelector.tsx )",
"Bash(apps/web/src/components/practice/StudentActionMenu.tsx )",
"Bash(apps/web/src/components/practice/ViewSelector.tsx )",
"Bash(apps/web/src/components/practice/studentActions.ts )",
"Bash(apps/web/src/hooks/useStudentActions.ts )",
"Bash(apps/web/src/hooks/useUnifiedStudents.ts )",
"Bash(apps/web/src/types/student.ts)",
"Bash(drizzle/meta/0044_snapshot.json )",
"Bash(drizzle/meta/_journal.json )",
"Bash(\"src/app/practice/[studentId]/dashboard/DashboardClient.tsx\" )",
"Bash(src/components/classroom/EnrollChildModal.tsx )",
"Bash(src/components/family/FamilyCodeDisplay.tsx )",
"Bash(src/components/practice/StudentFilterBar.tsx )",
"Bash(src/components/practice/StudentSelector.tsx )",
"Bash(src/components/practice/StudentActionMenu.tsx )",
"Bash(src/components/practice/ViewSelector.tsx )",
"Bash(src/components/practice/studentActions.ts )",
"Bash(src/hooks/useStudentActions.ts )",
"Bash(src/hooks/useUnifiedStudents.ts )",
"Bash(src/types/student.ts)",
"Bash(ANALYZE=true pnpm next build:*)",
"Bash(du:*)",
"Bash(gzip:*)",
"Bash({}/1024/1024\" | bc\\)MB\")",
"Bash(114595/1024\" | bc\\) KB\" curl -s 'http://localhost:3000/_next/static/chunks/app/practice/page.js')",
"Bash(done)",
"Bash(PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright test:*)",
"Bash(BASE_URL=http://localhost:3000 npx playwright test:*)",
"Bash(BASE_URL=http://localhost:3000 pnpm --filter @soroban/web exec playwright test:*)",
"Bash(BASE_URL=http://localhost:3000 pnpm exec playwright test:*)",
"Bash(git rebase:*)",
"Bash(GIT_EDITOR=true git rebase:*)",
"Bash(npm run test:run:*)",
"Bash(git mv:*)",
"Bash(drizzle/0050_abandoned_salo.sql )",
"Bash(drizzle/meta/0050_snapshot.json )",
"Bash(src/db/schema/practice-attachments.ts )",
"Bash(src/db/schema/index.ts )",
"Bash(\"src/app/api/curriculum/[playerId]/attachments/\" )",
"Bash(\"src/app/api/curriculum/[playerId]/offline-sessions/\" )",
"Bash(\"src/app/api/curriculum/[playerId]/sessions/[sessionId]/\" )",
"Bash(src/components/practice/PhotoUploadZone.tsx )",
"Bash(src/components/practice/SessionPhotoGallery.tsx )",
"Bash(src/components/practice/OfflineSessionModal.tsx )",
"Bash(src/components/practice/VirtualizedSessionList.tsx )",
"Bash(\"src/app/practice/[studentId]/summary/SummaryClient.tsx\" )",
"Bash(git ls-tree:*)",
"Bash(apps/web/drizzle/0051_luxuriant_selene.sql )",
"Bash(apps/web/drizzle/0052_remarkable_karnak.sql )",
"Bash(apps/web/drizzle/0053_premium_expediter.sql )",
"Bash(apps/web/drizzle/meta/0051_snapshot.json )",
"Bash(apps/web/drizzle/meta/0052_snapshot.json )",
"Bash(apps/web/drizzle/meta/0053_snapshot.json )",
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/file/route.ts\" )",
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/route.ts\" )",
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/attachments/[attachmentId]/original/\" )",
"Bash(\"apps/web/src/app/api/curriculum/[playerId]/sessions/[sessionId]/attachments/route.ts\" )",
"Bash(apps/web/src/components/practice/DocumentAdjuster.tsx )",
"Bash(apps/web/src/components/practice/OfflineWorkSection.tsx )",
"Bash(apps/web/src/components/practice/PhotoViewerEditor.tsx )",
"Bash(apps/web/src/components/practice/ScrollspyNav.tsx )",
"Bash(apps/web/src/components/practice/useDocumentDetection.ts )",
"Bash(apps/web/src/db/schema/practice-attachments.ts)",
"Bash(drizzle/0051_luxuriant_selene.sql )",
"Bash(drizzle/0052_remarkable_karnak.sql )",
"Bash(drizzle/0053_premium_expediter.sql )",
"Bash(drizzle/meta/0051_snapshot.json )",
"Bash(drizzle/meta/0052_snapshot.json )",
"Bash(drizzle/meta/0053_snapshot.json )",
"Bash(\"src/app/api/curriculum/[playerId]/attachments/[attachmentId]/file/route.ts\" )",
"Bash(\"src/app/api/curriculum/[playerId]/attachments/[attachmentId]/route.ts\" )",
"Bash(\"src/app/api/curriculum/[playerId]/attachments/[attachmentId]/original/\" )",
"Bash(\"src/app/api/curriculum/[playerId]/sessions/[sessionId]/attachments/route.ts\" )",
"Bash(src/components/practice/DocumentAdjuster.tsx )",
"Bash(src/components/practice/OfflineWorkSection.tsx )",
"Bash(src/components/practice/PhotoViewerEditor.tsx )",
"Bash(src/components/practice/ScrollspyNav.tsx )",
"Bash(src/components/practice/useDocumentDetection.ts )",
"Bash(src/db/schema/practice-attachments.ts)",
"Bash(apps/web/src/components/vision/ )",
"Bash(apps/web/src/hooks/useAbacusVision.ts )",
"Bash(apps/web/src/hooks/useCameraCalibration.ts )",
"Bash(apps/web/src/hooks/useDeskViewCamera.ts )",
"Bash(apps/web/src/hooks/useFrameStability.ts )",
"Bash(apps/web/src/lib/vision/ )",
"Bash(apps/web/src/types/vision.ts)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(head:*)",
"Bash(apps/web/public/js-aruco2/ )",
"Bash(apps/web/src/app/create/vision-markers/ )",
"Bash(apps/web/src/lib/vision/arucoDetection.ts )",
"Bash(apps/web/src/components/vision/AbacusVisionBridge.tsx )",
"Bash(pnpm-lock.yaml)",
"Bash(apps/web/src/app/api/remote-camera/ )",
"Bash(apps/web/src/app/remote-camera/ )",
"Bash(apps/web/src/components/vision/RemoteCameraQRCode.tsx )",
"Bash(apps/web/src/components/vision/RemoteCameraReceiver.tsx )",
"Bash(apps/web/src/hooks/useRemoteCameraDesktop.ts )",
"Bash(apps/web/src/hooks/useRemoteCameraPhone.ts )",
"Bash(apps/web/src/hooks/useRemoteCameraSession.ts )",
"Bash(apps/web/src/lib/remote-camera/ )",
"Bash(apps/web/src/lib/vision/perspectiveTransform.ts )",
"Bash(apps/web/src/socket-server.ts)",
"Bash(apps/web/src/components/vision/CalibrationOverlay.tsx )",
"Bash(apps/web/src/components/practice/ActiveSession.tsx )"
],
"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.*

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
# Prevent native addon builds for packages that have prebuilds or are optional
# canvas is an optional dep of jsdom (for testing) - doesn't compile on Alpine/musl
neverBuiltDependencies[]=canvas

View File

@@ -1,8 +1,20 @@
# Multi-stage build for Soroban Abacus Flashcards
FROM node:20-alpine AS base
# Install Python and build tools for better-sqlite3
RUN apk add --no-cache python3 py3-setuptools make g++
# Install Python, build tools for better-sqlite3, and canvas native dependencies
# canvas is an optional dep of jsdom (used by vitest) and requires cairo/pango
RUN apk add --no-cache \
python3 \
py3-setuptools \
make \
g++ \
pkgconfig \
cairo-dev \
pango-dev \
libjpeg-turbo-dev \
giflib-dev \
librsvg-dev \
pixman-dev
# Install pnpm and turbo
RUN npm install -g pnpm@9.15.4 turbo@1.10.0
@@ -155,9 +167,9 @@ RUN mkdir -p data/uploads && chown -R nextjs:nodejs data
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV=production
# Start the application
CMD ["node", "server.js"]

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,796 @@
# 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;
hadHelp: boolean; // Whether student used help during this problem
}
```
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 whether help was used.
* Using help = less confident the student really knows it.
*
* Note: Help is a boolean (hadHelp: true = used help, false = no help).
* We can't determine which skill needed help for multi-skill problems,
* so we apply the discount uniformly and let conjunctive BKT identify
* weak skills from aggregated evidence.
*/
export function helpWeight(hadHelp: boolean): number {
return hadHelp ? 0.5 : 1.0; // 50% weight for helped answers
}
/**
* 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 { helpWeight, 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 helpW = helpWeight(result.hadHelp);
const rtWeight = responseTimeWeight(
result.responseTimeMs,
result.isCorrect,
);
const evidenceWeight = helpW * 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.
---
## 8. Recency Refresh (Sentinel Records)
**Implemented in December 2024**
### 8.1 The Problem: Abstraction Gap
Teachers need to mark skills as "recently practiced" when students do offline work
(e.g., workbooks, tutoring sessions). This resets the staleness indicator without
changing the BKT mastery estimate.
**Original (broken) approach:**
- Database field `player_skill_mastery.lastPracticedAt` for manual override
- BKT computed `lastPracticedAt` from problem history
- **Two separate sources** created an abstraction gap:
- UI sometimes used database field (stale)
- Chart used BKT computed value (correct)
- Inconsistency caused confusion and bugs
### 8.2 The Sentinel Approach
**Single source of truth:** All `lastPracticedAt` values come from problem history.
When a teacher clicks "Mark Current" for a skill:
1. A **sentinel record** is inserted into session history
2. The sentinel has `source: 'recency-refresh'`
3. BKT naturally processes it and updates `lastPracticedAt`
4. BKT skips the sentinel for P(known) calculation (zero-weight)
**Benefits:**
- No abstraction gap - BKT is the single source of truth
- No MAX logic to combine two data sources
- Clear semantics - sentinels are explicitly marked
- Natural integration - flows through existing query paths
### 8.3 Implementation Details
**SlotResult schema** (`session-plans.ts`):
```typescript
export type SlotResultSource = "practice" | "recency-refresh";
export interface SlotResult {
// ... other fields ...
/**
* Source of this record. Defaults to 'practice' when undefined.
*
* 'recency-refresh' records are sentinels inserted when a teacher clicks
* "Mark Current" to indicate offline practice. BKT uses these for
* lastPracticedAt but skips them for pKnown calculation (zero-weight).
*/
source?: SlotResultSource;
}
```
**Session status** (`session-plans.ts`):
```typescript
export type SessionStatus =
| "draft"
| "approved"
| "in_progress"
| "completed"
| "abandoned"
| "recency-refresh"; // Sessions containing only sentinel records
```
**BKT handling** (`compute-bkt.ts`):
```typescript
// Check if this is a recency-refresh sentinel record
const isRecencyRefresh = result.source === "recency-refresh";
if (isRecencyRefresh) {
// Only update lastPracticedAt - skip pKnown calculation
for (const skillId of skillIds) {
const state = skillStates.get(skillId)!;
if (!state.lastPracticedAt || timestamp > state.lastPracticedAt) {
state.lastPracticedAt = timestamp;
}
}
continue; // Skip BKT updates for sentinel records
}
```
**Query inclusion** (`session-planner.ts`):
```typescript
const sessions = await db.query.sessionPlans.findMany({
where: and(
eq(schema.sessionPlans.playerId, playerId),
inArray(schema.sessionPlans.status, ["completed", "recency-refresh"]),
),
// ...
});
```
### 8.4 API Usage
**Mark skill as recently practiced:**
```
PATCH /api/curriculum/[playerId]/skills
Body: { skillId: string }
Returns: { sessionId: string, timestamp: Date }
```
The endpoint inserts a recency-refresh sentinel session. The next time BKT is
computed, the skill's `lastPracticedAt` will reflect the refresh timestamp,
removing the staleness warning.
---
## 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,608 @@
# 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
@@ -88,6 +91,161 @@ README.md (root)
**Invalid:** Creating `/docs/some-feature.md` without linking from anywhere ❌
**Valid:** Creating `/docs/some-feature.md` AND linking from root README ✅
## CRITICAL: Never Directly Modify Database Schema
**NEVER modify the database schema directly (e.g., via sqlite3, manual SQL, or direct ALTER TABLE commands).**
All database schema changes MUST go through the Drizzle migration system:
1. **Modify the schema file** in `src/db/schema/`
2. **Generate a migration** using `npx drizzle-kit generate --custom`
3. **Edit the generated SQL file** with the actual migration statements
4. **Run the migration** using `npm run db:migrate`
5. **Commit both** the schema change AND the migration file
**Why this matters:**
- The migration system tracks which changes have been applied
- Production runs migrations on startup to sync schema with code
- If you modify the DB directly, the migration system doesn't know about it
- When you later create a migration for the "same" change, it becomes a no-op locally but fails on production
**The failure pattern (December 2025):**
1. During development, columns were added directly to local DB (bypassing migrations)
2. Migration 0043 was created but as `SELECT 1;` (no-op) because "columns already exist"
3. Production ran 0043 (no-op), so it never got the columns
4. Production crashed with "no such column: is_paused"
5. Required emergency migration 0044 to fix
**The correct pattern:**
```bash
# 1. Modify schema file
# 2. Generate migration
npx drizzle-kit generate --custom
# 3. Edit the generated SQL file with actual ALTER TABLE statements
# 4. Test locally
npm run db:migrate
# 5. Commit both schema and migration
git add src/db/schema/ drizzle/
git commit -m "feat: add new column"
```
**Never do this:**
```bash
# ❌ WRONG - Direct database modification
sqlite3 data/sqlite.db "ALTER TABLE session_plans ADD COLUMN is_paused INTEGER;"
# ❌ WRONG - Then creating a no-op migration because "column exists"
# drizzle/0043.sql:
# SELECT 1; -- This does nothing on production!
```
## CRITICAL: Never Modify Migration Files After Deployment
**NEVER modify a migration file after it has been deployed to production.**
Drizzle tracks migrations by name/tag, not by content. Once a migration is recorded in `__drizzle_migrations`, it will NEVER be re-run, even if the file content changes.
### The Failure Pattern (December 2025)
1. Migration 0047 was created and deployed (possibly as a stub or incomplete)
2. Production recorded it in `__drizzle_migrations` as "applied"
3. Developer modified 0047.sql locally with the actual CREATE TABLE statement
4. New deployment saw 0047 was already "applied" → skipped it
5. Production crashed: `SqliteError: no such column: "entry_prompt_expiry_minutes"`
6. Required emergency migration to fix
**This exact pattern has caused THREE production outages:**
- Migration 0043 (December 2025): `is_paused` column missing
- Migration 0047 (December 2025): `entry_prompts` table missing
- Migration 0048 (December 2025): `entry_prompt_expiry_minutes` column missing
### Why This Happens
```
Timeline:
1. Create migration file (empty or stub) → deployed → recorded as "applied"
2. Modify migration file with real SQL → deployed → SKIPPED (already "applied")
3. Production crashes → missing tables/columns
```
Drizzle's migrator checks: "Is migration 0047 in `__drizzle_migrations`?" → Yes → Skip it.
It does NOT check: "Has the content of 0047.sql changed?"
### The Correct Pattern
**Before committing a migration, ensure it contains the FINAL SQL:**
```bash
# 1. Generate migration
npx drizzle-kit generate --custom
# 2. IMMEDIATELY edit the generated SQL with the actual statements
# DO NOT commit an empty/stub migration!
# 3. Run locally to verify
npm run db:migrate
# 4. Only THEN commit
git add drizzle/
git commit -m "feat: add entry_prompts table"
```
### If You Need to Fix a Deployed Migration
**DO NOT modify the existing migration file.** Instead:
```bash
# Create a NEW migration with the fix
npx drizzle-kit generate --custom
# Name it something like: 0050_fix_missing_entry_prompts.sql
# Add the missing SQL (with IF NOT EXISTS for safety)
CREATE TABLE IF NOT EXISTS `entry_prompts` (...);
# OR for SQLite (which doesn't support IF NOT EXISTS for columns):
# Check if column exists first, or just let it fail silently
```
### Red Flags
If you find yourself:
- Editing a migration file that's already been committed
- Thinking "I'll just update this migration with the correct SQL"
- Seeing "migration already applied" but schema is wrong
**STOP.** Create a NEW migration instead.
### Emergency Fix for Production
If production is down due to missing schema, create a new migration immediately:
```bash
# 1. Generate emergency migration
npx drizzle-kit generate --custom
# Creates: drizzle/0050_emergency_fix.sql
# 2. Add the missing SQL with safety checks
# For tables:
CREATE TABLE IF NOT EXISTS `entry_prompts` (...);
# For columns (SQLite workaround - will error if exists, but migration still records):
ALTER TABLE `classrooms` ADD COLUMN `entry_prompt_expiry_minutes` integer;
# 3. Commit and deploy
git add drizzle/
git commit -m "fix: emergency migration for missing schema"
git push
```
The new migration will run on production startup and fix the schema.
## CRITICAL: @svg-maps ES Module Imports Work Correctly
**The @svg-maps packages (world, usa) USE ES module syntax and this WORKS correctly in production.**
@@ -821,6 +979,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.**
@@ -872,6 +1073,138 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
**Common mistake:** Seeing https://abaci.one is online and assuming the new code is deployed. Always verify the commit SHA.
## CRITICAL: React Query - Mutations Must Invalidate Related Queries
**This is a documented failure pattern. Do not repeat it.**
When modifying data that's fetched via React Query, you MUST use React Query mutations that properly invalidate the affected queries. Using plain `fetch()` + `router.refresh()` will NOT update the React Query cache.
### The Failure Pattern
```typescript
// ❌ WRONG - This causes stale UI!
const handleRefreshSkill = useCallback(
async (skillId: string) => {
const response = await fetch(`/api/curriculum/${studentId}/skills`, {
method: "PATCH",
body: JSON.stringify({ skillId }),
});
router.refresh(); // Does NOT invalidate React Query cache!
},
[studentId, router],
);
```
**Why this fails:**
- `router.refresh()` triggers a Next.js server re-render
- But React Query maintains its own cache on the client
- The cached data stays stale until the query naturally refetches
- User sees outdated UI until they reload the page
### The Correct Pattern
**Step 1: Check if a mutation hook already exists**
Look in `src/hooks/` for existing mutation hooks:
- `usePlayerCurriculum.ts` - curriculum mutations (`useRefreshSkillRecency`, `useSetMasteredSkills`)
- `useSessionPlan.ts` - session plan mutations
- `useRoomData.ts` - arcade room mutations
**Step 2: Use the existing hook**
```typescript
// ✅ CORRECT - Use the mutation hook
import { useRefreshSkillRecency } from "@/hooks/usePlayerCurriculum";
function MyComponent({ studentId }) {
const refreshSkillRecency = useRefreshSkillRecency();
const handleRefreshSkill = useCallback(
async (skillId: string) => {
await refreshSkillRecency.mutateAsync({ playerId: studentId, skillId });
},
[studentId, refreshSkillRecency],
);
// Use mutation state for loading indicators
const isRefreshing = refreshSkillRecency.isPending
? refreshSkillRecency.variables?.skillId
: null;
}
```
**Step 3: If no hook exists, create one with proper invalidation**
```typescript
// In src/hooks/usePlayerCurriculum.ts
export function useRefreshSkillRecency() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
playerId,
skillId,
}: {
playerId: string;
skillId: string;
}) => {
const response = await api(`curriculum/${playerId}/skills`, {
method: "PATCH",
body: JSON.stringify({ skillId }),
});
if (!response.ok) throw new Error("Failed to refresh skill");
return response.json();
},
// THIS IS THE CRITICAL PART - invalidate affected queries!
onSuccess: (_, { playerId }) => {
queryClient.invalidateQueries({
queryKey: curriculumKeys.detail(playerId),
});
},
});
}
```
### Query Key Relationships
**Always ensure mutations invalidate the right query keys:**
| Mutation | Must Invalidate |
| --------------------- | ---------------------------------- |
| Skill mastery changes | `curriculumKeys.detail(playerId)` |
| Session plan updates | `sessionPlanKeys.active(playerId)` |
| Player settings | `curriculumKeys.detail(playerId)` |
| Room settings | `roomKeys.detail(roomId)` |
**Query keys are defined in:** `src/lib/queryKeys.ts`
### Red Flags
If you find yourself:
- Using `fetch()` directly in a component for mutations
- Calling `router.refresh()` after a data mutation
- Creating `useState` for loading states instead of using `mutation.isPending`
**STOP.** Look for an existing mutation hook or create one with proper cache invalidation.
### Historical Context
**This exact bug has occurred multiple times:**
1. **SkillsTab "Mark Current" button (2025-12-20)**: Used `fetch` + `router.refresh()` instead of `useRefreshSkillRecency`. Skills list showed stale data until page reload.
2. **Similar patterns exist elsewhere** - always check before adding new mutation logic.
### Checklist Before Writing Mutation Code
1. [ ] Is there already a mutation hook in `src/hooks/`?
2. [ ] Does the mutation invalidate the correct query key?
3. [ ] Am I using `mutation.isPending` instead of manual loading state?
4. [ ] Am I NOT using `router.refresh()` for cache updates?
## Daily Practice System
When working on the curriculum-based daily practice system, refer to:
@@ -884,6 +1217,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,158 @@
# Plan: ProblemToReview Redesign
## Problem Statement
The current ProblemToReview component has redundant information - the collapsed view shows the problem, and expanding reveals DetailedProblemCard which shows the same problem again. Users need to quickly identify problems and understand why they went wrong.
## Design Goals
1. **Single problem representation** - never duplicate the problem display
2. **BKT-driven weak skill detection** - use mastery data to identify likely causes
3. **Progressive disclosure** - collapsed shows identification, expanded shows annotations
4. **Actionable insights** - surface what the student needs to work on
---
## Collapsed View
```
┌─────────────────────────────────────────────────────────────────┐
│ #5 🧮 Abacus ❌ Incorrect │
│ ┌─────┐ │
│ │ 5 │ │
│ │ + 4 │ = 9 [said 8] │
│ └─────┘ │
│ │
│ ⚠️ 5's: 4=5-1, 10's: 8=10-2 (+1 more) [▼] │
└─────────────────────────────────────────────────────────────────┘
```
**Elements:**
- Problem number + part type (🧮 Abacus, 🧠 Visualize, 💭 Mental)
- Problem in vertical format (even for linear problems)
- Wrong answer indicator: `[said 8]`
- Reason badges (❌ Incorrect, ⏱️ Slow, 💡 Help used)
- Weak skills summary: up to 3, ordered by BKT severity, "+N more" if truncated
- Expand button
---
## Expanded View
```
┌─────────────────────────────────────────────────────────────────┐
│ #5 🧮 Abacus ❌ Incorrect [▲] │
│ │
│ ┌─────┬───────────────────────────────────────────────────┐│
│ │ 5 │ direct addition ││
│ │ + 4 │ ⚠️ 5's: 4=5-1 ← likely cause ││
│ ├─────┼───────────────────────────────────────────────────┤│
│ │ = 9 │ [said 8] ││
│ └─────┴───────────────────────────────────────────────────┘│
│ │
│ ⚠️ Weak: 5's: 4=5-1 (23%), 10's: 8=10-2 (41%), +1 more │
│ │
│ ⏱️ 12.3s response (threshold: 8.5s) │
│ │
│ 🎯 Focus: Practicing a skill you're still learning, with │
│ scaffolding to build confidence. │
└─────────────────────────────────────────────────────────────────┘
```
**Elements:**
- Same header as collapsed
- Problem with skill annotations next to each term
- Weak skills marked with ⚠️ and "← likely cause" indicator
- Weak skills summary with BKT mastery percentages
- Timing info (response time vs threshold)
- Purpose with full explanation text
---
## Implementation Steps
### Step 1: Add BKT data to ProblemToReview props
- Add `skillMasteries: Record<string, number>` prop (skillId → mastery 0-1)
- Pass from SessionSummary which can compute from session or fetch from API
### Step 2: Create weak skill detection utility
- `getWeakSkillsForProblem(skillsExercised: string[], masteries: Record<string, number>)`
- Returns skills sorted by mastery (lowest first)
- Include mastery percentage for display
### Step 3: Create AnnotatedProblem component
- Single component that handles both collapsed and expanded states
- Vertical format for all problems (linear and vertical parts)
- In expanded mode: shows skill annotation next to each term
- Highlights weak skills with ⚠️ indicator
### Step 4: Create WeakSkillsSummary component
- Shows up to 3 weak skills, ordered by severity
- "+N more" indicator if truncated
- In expanded mode: includes mastery percentages
### Step 5: Create PurposeExplanation component
- Maps purpose (focus/reinforce/review/challenge) to explanation text
- Reuse or extract from existing purpose tooltip logic
### Step 6: Refactor ProblemToReview
- Remove DetailedProblemCard usage
- Use new AnnotatedProblem component
- Add WeakSkillsSummary to both views
- Add timing and purpose sections to expanded view
### Step 7: Update SessionSummary to pass BKT data
- Compute skill masteries from session results OR
- Fetch from /api/curriculum/[playerId]/skills endpoint
- Pass to ProblemToReview components
### Step 8: Hide costs in session summary context
- Add `showCosts` prop to any shared components
- Default false for session summary, true for plan review
---
## Files to Modify/Create
| File | Action |
| ------------------------------------------------ | ----------------------- |
| `src/components/practice/ProblemToReview.tsx` | Major refactor |
| `src/components/practice/AnnotatedProblem.tsx` | New component |
| `src/components/practice/WeakSkillsSummary.tsx` | New component |
| `src/components/practice/weakSkillUtils.ts` | New utility |
| `src/components/practice/SessionSummary.tsx` | Pass BKT data |
| `src/components/practice/purposeExplanations.ts` | New or extract existing |
---
## Data Flow
```
SessionSummary
├── Fetches/computes skill masteries
└── Passes to ProblemToReview
├── weakSkillUtils.getWeakSkillsForProblem()
├── AnnotatedProblem (collapsed or expanded)
├── WeakSkillsSummary
└── Timing + Purpose sections (expanded only)
```
---
## Open Questions (Resolved)
- ✅ Use BKT data for weak skill detection
- ✅ Show up to 3 weak skills, "+N more" if truncated
- ✅ Order by BKT severity (lowest mastery first)
- ✅ Show weak skills on correct problems too (if they're in review list for timing/help reasons)
- ✅ Single problem representation, annotated in expanded view

View File

@@ -0,0 +1,154 @@
# 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,171 @@
# 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

File diff suppressed because it is too large Load Diff

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,76 @@
{
"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:*)",
"Bash(pnpm why:*)",
"Bash(npm view:*)",
"Bash(pnpm install:*)"
],
"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,329 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { db, schema } from '../src/db'
import type { SessionPart, SessionSummary } from '../src/db/schema/session-plans'
import {
createSessionShare,
getSessionShare,
validateSessionShare,
incrementShareViewCount,
revokeSessionShare,
revokeSharesForSession,
getActiveSharesForSession,
isValidShareToken,
generateShareToken,
} from '../src/lib/session-share'
/**
* Session Share E2E Tests
*
* Tests the session share database operations and validation logic.
*/
// Minimal valid session parts and summary for FK constraint satisfaction
const TEST_SESSION_PARTS: SessionPart[] = [
{
partNumber: 1,
type: 'abacus',
format: 'vertical',
useAbacus: true,
slots: [],
estimatedMinutes: 5,
},
]
const TEST_SESSION_SUMMARY: SessionSummary = {
focusDescription: 'Test session',
totalProblemCount: 0,
estimatedMinutes: 5,
parts: [
{
partNumber: 1,
type: 'abacus',
description: 'Test part',
problemCount: 0,
estimatedMinutes: 5,
},
],
}
describe('Session Share API', () => {
let testUserId: string
let testPlayerId: string
let testSessionId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
// Create a test player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Student',
emoji: '🧪',
color: '#FF5733',
})
.returning()
testPlayerId = player.id
// Create a real session plan (required due to FK constraint on sessionObservationShares)
const [session] = await db
.insert(schema.sessionPlans)
.values({
playerId: testPlayerId,
targetDurationMinutes: 15,
estimatedProblemCount: 10,
avgTimePerProblemSeconds: 30,
parts: TEST_SESSION_PARTS,
summary: TEST_SESSION_SUMMARY,
status: 'in_progress',
})
.returning()
testSessionId = session.id
})
afterEach(async () => {
// Clean up all test shares first
await db
.delete(schema.sessionObservationShares)
.where(eq(schema.sessionObservationShares.createdBy, testUserId))
// Clean up session plans (before player due to FK)
await db.delete(schema.sessionPlans).where(eq(schema.sessionPlans.playerId, testPlayerId))
// Then clean up user (cascades to player)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('createSessionShare', () => {
it('creates a share with 1h expiration', async () => {
const before = Date.now()
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const after = Date.now()
expect(share.id).toHaveLength(10)
expect(isValidShareToken(share.id)).toBe(true)
expect(share.sessionId).toBe(testSessionId)
expect(share.playerId).toBe(testPlayerId)
expect(share.createdBy).toBe(testUserId)
expect(share.status).toBe('active')
expect(share.viewCount).toBe(0)
// Expiration should be ~1 hour from now
const expectedExpiry = before + 60 * 60 * 1000
const actualExpiry = share.expiresAt.getTime()
expect(actualExpiry).toBeGreaterThanOrEqual(expectedExpiry - 1000)
expect(actualExpiry).toBeLessThanOrEqual(after + 60 * 60 * 1000 + 1000)
})
it('creates a share with 24h expiration', async () => {
const before = Date.now()
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '24h')
const after = Date.now()
// Expiration should be ~24 hours from now
const expectedExpiry = before + 24 * 60 * 60 * 1000
const actualExpiry = share.expiresAt.getTime()
expect(actualExpiry).toBeGreaterThanOrEqual(expectedExpiry - 1000)
expect(actualExpiry).toBeLessThanOrEqual(after + 24 * 60 * 60 * 1000 + 1000)
})
it('generates unique tokens for each share', async () => {
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share3 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
expect(share1.id).not.toBe(share2.id)
expect(share2.id).not.toBe(share3.id)
expect(share1.id).not.toBe(share3.id)
})
})
describe('getSessionShare', () => {
it('returns share for valid token', async () => {
const created = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const retrieved = await getSessionShare(created.id)
expect(retrieved).not.toBeNull()
expect(retrieved!.id).toBe(created.id)
expect(retrieved!.sessionId).toBe(testSessionId)
})
it('returns null for invalid token format', async () => {
const result = await getSessionShare('invalid!')
expect(result).toBeNull()
})
it('returns null for non-existent token', async () => {
const result = await getSessionShare('abcdef1234') // Valid format but doesn't exist
expect(result).toBeNull()
})
})
describe('validateSessionShare', () => {
it('returns valid for active non-expired share', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const result = await validateSessionShare(share.id)
expect(result.valid).toBe(true)
expect(result.share).toBeDefined()
expect(result.share!.id).toBe(share.id)
expect(result.error).toBeUndefined()
})
it('returns invalid for non-existent token', async () => {
const result = await validateSessionShare('abcdef1234')
expect(result.valid).toBe(false)
expect(result.error).toBe('Share link not found')
expect(result.share).toBeUndefined()
})
it('returns invalid for revoked share', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await revokeSessionShare(share.id)
const result = await validateSessionShare(share.id)
expect(result.valid).toBe(false)
expect(result.error).toBe('Share link has been revoked')
})
it('returns invalid and marks as expired for time-expired share', async () => {
// Create share and manually set expired time in past
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await db
.update(schema.sessionObservationShares)
.set({ expiresAt: new Date(Date.now() - 1000) }) // 1 second in past
.where(eq(schema.sessionObservationShares.id, share.id))
const result = await validateSessionShare(share.id)
expect(result.valid).toBe(false)
expect(result.error).toBe('Share link has expired')
// Verify it was marked as expired in DB
const updated = await getSessionShare(share.id)
expect(updated!.status).toBe('expired')
})
})
describe('incrementShareViewCount', () => {
it('increments view count and updates lastViewedAt', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
expect(share.viewCount).toBe(0)
await incrementShareViewCount(share.id)
const updated = await getSessionShare(share.id)
expect(updated!.viewCount).toBe(1)
expect(updated!.lastViewedAt).not.toBeNull()
await incrementShareViewCount(share.id)
await incrementShareViewCount(share.id)
const final = await getSessionShare(share.id)
expect(final!.viewCount).toBe(3)
})
it('does nothing for non-existent token', async () => {
// Should not throw
await incrementShareViewCount('abcdef1234')
})
})
describe('revokeSessionShare', () => {
it('marks share as revoked', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
expect(share.status).toBe('active')
await revokeSessionShare(share.id)
const updated = await getSessionShare(share.id)
expect(updated!.status).toBe('revoked')
})
})
describe('revokeSharesForSession', () => {
it('marks all active shares for session as expired', async () => {
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(testSessionId, testPlayerId, testUserId, '24h')
await revokeSharesForSession(testSessionId)
const updated1 = await getSessionShare(share1.id)
const updated2 = await getSessionShare(share2.id)
expect(updated1!.status).toBe('expired')
expect(updated2!.status).toBe('expired')
})
it('does not affect already revoked shares', async () => {
const share = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await revokeSessionShare(share.id)
await revokeSharesForSession(testSessionId)
const updated = await getSessionShare(share.id)
expect(updated!.status).toBe('revoked') // Still revoked, not expired
})
it('does not affect shares for other sessions', async () => {
// Create a second session for isolation test
const [otherSession] = await db
.insert(schema.sessionPlans)
.values({
playerId: testPlayerId,
targetDurationMinutes: 15,
estimatedProblemCount: 10,
avgTimePerProblemSeconds: 30,
parts: TEST_SESSION_PARTS,
summary: TEST_SESSION_SUMMARY,
status: 'in_progress',
})
.returning()
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(otherSession.id, testPlayerId, testUserId, '1h')
await revokeSharesForSession(testSessionId)
const updated1 = await getSessionShare(share1.id)
const updated2 = await getSessionShare(share2.id)
expect(updated1!.status).toBe('expired')
expect(updated2!.status).toBe('active') // Unaffected
})
})
describe('getActiveSharesForSession', () => {
it('returns only active shares for the session', async () => {
const share1 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
const share2 = await createSessionShare(testSessionId, testPlayerId, testUserId, '24h')
const share3 = await createSessionShare(testSessionId, testPlayerId, testUserId, '1h')
await revokeSessionShare(share3.id) // Revoke one
const active = await getActiveSharesForSession(testSessionId)
expect(active).toHaveLength(2)
const ids = active.map((s) => s.id)
expect(ids).toContain(share1.id)
expect(ids).toContain(share2.id)
expect(ids).not.toContain(share3.id)
})
it('returns empty array for session with no shares', async () => {
const active = await getActiveSharesForSession('non-existent-session')
expect(active).toEqual([])
})
})
})

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`;

View File

@@ -0,0 +1,18 @@
-- Custom SQL migration file, put your code below! --
-- Drop deprecated skill stat columns from player_skill_mastery table
-- These stats are now computed on-the-fly from session results (single source of truth)
-- Requires SQLite 3.35.0+ (2021-03-12) for ALTER TABLE DROP COLUMN support
ALTER TABLE `player_skill_mastery` DROP COLUMN `attempts`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `correct`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `consecutive_correct`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `total_response_time_ms`;
--> statement-breakpoint
ALTER TABLE `player_skill_mastery` DROP COLUMN `response_time_count`;

View File

@@ -0,0 +1,4 @@
-- Custom SQL migration file, put your code below! --
-- Add isArchived column to players table for filtering inactive students
ALTER TABLE `players` ADD `is_archived` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Rename last_help_level to last_had_help (terminology change: "help level" is no longer accurate since it's a boolean)
ALTER TABLE `player_skill_mastery` RENAME COLUMN `last_help_level` TO `last_had_help`;

View File

@@ -0,0 +1,130 @@
-- Custom SQL migration file, put your code below! --
-- Classroom system: parent-child relationships, classrooms, enrollments, presence
-- ============================================================================
-- 1. Add family_code to players table
-- ============================================================================
ALTER TABLE `players` ADD `family_code` text;
--> statement-breakpoint
CREATE UNIQUE INDEX `players_family_code_unique` ON `players` (`family_code`);
--> statement-breakpoint
-- ============================================================================
-- 2. Add pause fields to session_plans table
-- ============================================================================
ALTER TABLE `session_plans` ADD `paused_at` integer;
--> statement-breakpoint
ALTER TABLE `session_plans` ADD `paused_by` text;
--> statement-breakpoint
ALTER TABLE `session_plans` ADD `paused_reason` text;
--> statement-breakpoint
-- ============================================================================
-- 3. Create parent_child table (many-to-many family relationships)
-- ============================================================================
CREATE TABLE `parent_child` (
`parent_user_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`child_player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`linked_at` integer NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (`parent_user_id`, `child_player_id`)
);
--> statement-breakpoint
-- ============================================================================
-- 4. Create classrooms table (one per teacher)
-- ============================================================================
CREATE TABLE `classrooms` (
`id` text PRIMARY KEY NOT NULL,
`teacher_id` text NOT NULL UNIQUE REFERENCES `users`(`id`) ON DELETE CASCADE,
`name` text NOT NULL,
`code` text NOT NULL UNIQUE,
`created_at` integer NOT NULL DEFAULT (unixepoch())
);
--> statement-breakpoint
CREATE INDEX `classrooms_code_idx` ON `classrooms` (`code`);
--> statement-breakpoint
-- ============================================================================
-- 5. Create classroom_enrollments table (persistent student roster)
-- ============================================================================
CREATE TABLE `classroom_enrollments` (
`id` text PRIMARY KEY NOT NULL,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`enrolled_at` integer NOT NULL DEFAULT (unixepoch())
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_enrollments_classroom_player` ON `classroom_enrollments` (`classroom_id`, `player_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollments_classroom` ON `classroom_enrollments` (`classroom_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollments_player` ON `classroom_enrollments` (`player_id`);
--> statement-breakpoint
-- ============================================================================
-- 6. Create enrollment_requests table (consent workflow)
-- ============================================================================
CREATE TABLE `enrollment_requests` (
`id` text PRIMARY KEY NOT NULL,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`requested_by` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`requested_by_role` text NOT NULL,
`requested_at` integer NOT NULL DEFAULT (unixepoch()),
`status` text NOT NULL DEFAULT 'pending',
`teacher_approval` text,
`teacher_approved_at` integer,
`parent_approval` text,
`parent_approved_by` text REFERENCES `users`(`id`),
`parent_approved_at` integer,
`resolved_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_enrollment_requests_classroom_player` ON `enrollment_requests` (`classroom_id`, `player_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollment_requests_classroom` ON `enrollment_requests` (`classroom_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollment_requests_player` ON `enrollment_requests` (`player_id`);
--> statement-breakpoint
CREATE INDEX `idx_enrollment_requests_status` ON `enrollment_requests` (`status`);
--> statement-breakpoint
-- ============================================================================
-- 7. Create classroom_presence table (ephemeral "in classroom" state)
-- ============================================================================
CREATE TABLE `classroom_presence` (
`player_id` text PRIMARY KEY NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`entered_at` integer NOT NULL DEFAULT (unixepoch()),
`entered_by` text NOT NULL REFERENCES `users`(`id`)
);
--> statement-breakpoint
CREATE INDEX `idx_presence_classroom` ON `classroom_presence` (`classroom_id`);
--> statement-breakpoint
-- ============================================================================
-- 8. Data migration: Create parent_child entries from existing players
-- ============================================================================
-- For each existing player, create a parent_child relationship with the creator
INSERT INTO `parent_child` (`parent_user_id`, `child_player_id`, `linked_at`)
SELECT `user_id`, `id`, `created_at` FROM `players`;

View File

@@ -0,0 +1,25 @@
-- Custom SQL migration file, put your code below! --
-- Add missing indexes and generate family codes
-- ============================================================================
-- 1. Add missing indexes to parent_child table
-- ============================================================================
CREATE INDEX `idx_parent_child_parent` ON `parent_child` (`parent_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_parent_child_child` ON `parent_child` (`child_player_id`);
--> statement-breakpoint
-- ============================================================================
-- 2. Generate family codes for existing players
-- ============================================================================
-- SQLite doesn't have built-in random string generation, so we use a combination
-- of hex(randomblob()) to create unique codes, then format them.
-- Format: FAM-XXXXXX where X is alphanumeric
-- The uniqueness constraint on family_code will ensure no collisions.
UPDATE `players`
SET `family_code` = 'FAM-' || UPPER(SUBSTR(HEX(RANDOMBLOB(3)), 1, 6))
WHERE `family_code` IS NULL;

View File

@@ -0,0 +1,6 @@
-- Custom SQL migration file, put your code below! --
-- The columns paused_at, paused_by, paused_reason were added manually during development.
-- This migration was kept for consistency but those columns already exist.
-- Note: is_paused was missing and is added in migration 0044.
SELECT 1;

View File

@@ -0,0 +1,4 @@
-- Custom SQL migration file, put your code below! --
-- Add is_paused column that was missing from migration 0043
ALTER TABLE `session_plans` ADD `is_paused` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,21 @@
-- Session observation shares table
-- Allows time-limited shareable links for observing practice sessions
CREATE TABLE `session_observation_shares` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL REFERENCES `session_plans`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`created_by` text NOT NULL,
`created_at` integer NOT NULL DEFAULT (unixepoch()),
`expires_at` integer NOT NULL,
`status` text NOT NULL DEFAULT 'active',
`view_count` integer NOT NULL DEFAULT 0,
`last_viewed_at` integer
);
--> statement-breakpoint
-- Index for cleanup when session ends
CREATE INDEX `idx_session_observation_shares_session` ON `session_observation_shares`(`session_id`);
--> statement-breakpoint
-- Index for listing active shares
CREATE INDEX `idx_session_observation_shares_status` ON `session_observation_shares`(`status`);

View File

@@ -0,0 +1,22 @@
-- Custom SQL migration file, put your code below! --
CREATE TABLE `entry_prompts` (
`id` text PRIMARY KEY NOT NULL,
`teacher_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`classroom_id` text NOT NULL REFERENCES `classrooms`(`id`) ON DELETE CASCADE,
`expires_at` integer NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`responded_by` text REFERENCES `users`(`id`),
`responded_at` integer,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_teacher` ON `entry_prompts` (`teacher_id`);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_player` ON `entry_prompts` (`player_id`);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_classroom` ON `entry_prompts` (`classroom_id`);
--> statement-breakpoint
CREATE INDEX `idx_entry_prompts_status` ON `entry_prompts` (`status`);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_entry_prompts_unique_pending` ON `entry_prompts` (`player_id`, `classroom_id`) WHERE `status` = 'pending';

View File

@@ -0,0 +1,5 @@
-- Custom SQL migration file, put your code below! --
-- Add entry_prompt_expiry_minutes column to classrooms table
-- Allows teachers to configure their default entry prompt expiry time
-- NULL means use system default (30 minutes)
ALTER TABLE `classrooms` ADD `entry_prompt_expiry_minutes` integer;

View File

@@ -0,0 +1,2 @@
-- Add retry_state column to session_plans for tracking retry epochs
ALTER TABLE `session_plans` ADD `retry_state` text;

View File

@@ -0,0 +1,21 @@
-- Custom SQL migration file, put your code below! --
-- Practice attachments table for storing photos of student work
CREATE TABLE `practice_attachments` (
`id` text PRIMARY KEY NOT NULL,
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
`session_id` text NOT NULL REFERENCES `session_plans`(`id`) ON DELETE CASCADE,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`file_size` integer NOT NULL,
`uploaded_by` text NOT NULL REFERENCES `users`(`id`),
`uploaded_at` text NOT NULL
);
--> statement-breakpoint
-- Index for fast lookups by player
CREATE INDEX `practice_attachments_player_idx` ON `practice_attachments` (`player_id`);
--> statement-breakpoint
-- Index for fast lookups by session
CREATE INDEX `practice_attachments_session_idx` ON `practice_attachments` (`session_id`);

View File

@@ -0,0 +1,4 @@
-- Add original_filename column for preserving uncropped originals
-- When a photo is cropped/adjusted, the original is kept so re-edits
-- can start from the full original image instead of the cropped version.
ALTER TABLE `practice_attachments` ADD `original_filename` text;

View File

@@ -0,0 +1,4 @@
-- Add corners column for preserving crop coordinates
-- Stores JSON array of 4 {x, y} points in original image coordinates
-- Used to restore crop position when re-editing photos
ALTER TABLE `practice_attachments` ADD `corners` text;

View File

@@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Add rotation column to practice_attachments for persisting image rotation
ALTER TABLE `practice_attachments` ADD COLUMN `rotation` integer DEFAULT 0;

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

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

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,188 @@
"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
},
{
"idx": 38,
"version": "6",
"when": 1766246063026,
"tag": "0038_drop_skill_stat_columns",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1766275200000,
"tag": "0039_add_player_archived",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1766320890578,
"tag": "0040_rename_last_help_level_to_last_had_help",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1766404380000,
"tag": "0041_classroom-system",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1766406120000,
"tag": "0042_classroom-system-indexes",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1766706763639,
"tag": "0043_add_session_pause_columns",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1766773151809,
"tag": "0044_add_is_paused_column",
"breakpoints": true
},
{
"idx": 45,
"version": "6",
"when": 1766885087540,
"tag": "0045_add_player_stats_table",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1766980800000,
"tag": "0046_session_observation_shares",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1767037546552,
"tag": "0047_add_entry_prompts",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1767044481301,
"tag": "0048_ambitious_firedrake",
"breakpoints": true
},
{
"idx": 49,
"version": "6",
"when": 1767060697736,
"tag": "0049_flowery_jean_grey",
"breakpoints": true
},
{
"idx": 50,
"version": "6",
"when": 1767144779337,
"tag": "0050_abandoned_salo",
"breakpoints": true
},
{
"idx": 51,
"version": "6",
"when": 1767205428875,
"tag": "0051_luxuriant_selene",
"breakpoints": true
},
{
"idx": 52,
"version": "6",
"when": 1767206527582,
"tag": "0052_remarkable_karnak",
"breakpoints": true
},
{
"idx": 53,
"version": "6",
"when": 1767208127241,
"tag": "0053_premium_expediter",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,384 @@
/**
* E2E tests for API authorization
*
* Tests that curriculum and player-stats endpoints properly enforce
* authorization based on parent/teacher relationships.
*
* Test scenarios:
* - Parent can modify their own child's data (positive)
* - Unrelated user cannot modify another's child data (negative)
*/
import { expect, test } from '@playwright/test'
test.describe('API Authorization', () => {
test.describe('Session Plan Authorization', () => {
test('parent can create and modify session plan for own child', async ({ page }) => {
// Visit page to establish session cookies
await page.goto('/')
await page.waitForLoadState('networkidle')
const request = page.request
// Step 1: Create a player (this establishes parent relationship)
const createPlayerRes = await request.post('/api/players', {
data: { name: 'Test Child', emoji: '🧒', color: '#4CAF50' },
})
expect(
createPlayerRes.ok(),
`Create player failed: ${await createPlayerRes.text()}`
).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// Step 1.5: Enable skills for this player (required before creating session plan)
const enableSkillsRes = await request.put(`/api/curriculum/${playerId}/skills`, {
data: {
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead', '1c-simple-combinations'],
},
})
expect(
enableSkillsRes.ok(),
`Enable skills failed: ${await enableSkillsRes.text()}`
).toBeTruthy()
// Step 2: Create a session plan
const createPlanRes = await request.post(`/api/curriculum/${playerId}/sessions/plans`, {
data: { durationMinutes: 5 },
})
expect(createPlanRes.ok(), `Create plan failed: ${await createPlanRes.text()}`).toBeTruthy()
const { plan } = await createPlanRes.json()
const planId = plan.id
// Step 3: Approve the plan (PATCH - was vulnerable)
const approveRes = await request.patch(
`/api/curriculum/${playerId}/sessions/plans/${planId}`,
{
data: { action: 'approve' },
}
)
expect(approveRes.ok(), `Approve failed: ${await approveRes.text()}`).toBeTruthy()
// Step 4: Start the plan
const startRes = await request.patch(`/api/curriculum/${playerId}/sessions/plans/${planId}`, {
data: { action: 'start' },
})
expect(startRes.ok(), `Start failed: ${await startRes.text()}`).toBeTruthy()
// Step 5: Abandon the plan (cleanup)
const abandonRes = await request.patch(
`/api/curriculum/${playerId}/sessions/plans/${planId}`,
{
data: { action: 'abandon' },
}
)
expect(abandonRes.ok(), `Abandon failed: ${await abandonRes.text()}`).toBeTruthy()
// Cleanup: Delete the player
await request.delete(`/api/players/${playerId}`)
})
test("unrelated user cannot modify another user's session plan", async ({ browser }) => {
// Create two isolated browser contexts (simulating two different users)
const userAContext = await browser.newContext()
const userBContext = await browser.newContext()
try {
// User A: Create page and establish session
const userAPage = await userAContext.newPage()
await userAPage.goto('/')
await userAPage.waitForLoadState('networkidle')
const userARequest = userAPage.request
// User B: Create page and establish session
const userBPage = await userBContext.newPage()
await userBPage.goto('/')
await userBPage.waitForLoadState('networkidle')
const userBRequest = userBPage.request
// User A: Create a player and session plan
const createPlayerRes = await userARequest.post('/api/players', {
data: { name: 'User A Child', emoji: '👧', color: '#2196F3' },
})
expect(createPlayerRes.ok()).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// Enable skills (required before creating session plan)
const enableSkillsRes = await userARequest.put(`/api/curriculum/${playerId}/skills`, {
data: {
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead'],
},
})
expect(enableSkillsRes.ok()).toBeTruthy()
const createPlanRes = await userARequest.post(
`/api/curriculum/${playerId}/sessions/plans`,
{
data: { durationMinutes: 5 },
}
)
expect(createPlanRes.ok()).toBeTruthy()
const { plan } = await createPlanRes.json()
const planId = plan.id
// User B: Try to modify User A's session plan (should fail with 403)
const attackRes = await userBRequest.patch(
`/api/curriculum/${playerId}/sessions/plans/${planId}`,
{
data: { action: 'abandon' },
}
)
expect(attackRes.status()).toBe(403)
const errorBody = await attackRes.json()
expect(errorBody.error).toBe('Not authorized')
// Cleanup: User A deletes their player
await userARequest.delete(`/api/players/${playerId}`)
} finally {
await userAContext.close()
await userBContext.close()
}
})
})
test.describe('Skills Endpoint Authorization', () => {
test('parent can record skill attempts for own child', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const request = page.request
// Create a player
const createPlayerRes = await request.post('/api/players', {
data: { name: 'Skill Test Child', emoji: '📚', color: '#9C27B0' },
})
expect(createPlayerRes.ok()).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// POST: Record a skill attempt
const recordRes = await request.post(`/api/curriculum/${playerId}/skills`, {
data: { skillId: '1a-direct-addition', isCorrect: true },
})
expect(recordRes.ok(), `Record skill failed: ${await recordRes.text()}`).toBeTruthy()
// PUT: Set mastered skills
const setMasteredRes = await request.put(`/api/curriculum/${playerId}/skills`, {
data: { masteredSkillIds: ['1a-direct-addition'] },
})
expect(
setMasteredRes.ok(),
`Set mastered failed: ${await setMasteredRes.text()}`
).toBeTruthy()
// PATCH: Refresh skill recency
const refreshRes = await request.patch(`/api/curriculum/${playerId}/skills`, {
data: { skillId: '1a-direct-addition' },
})
expect(refreshRes.ok(), `Refresh skill failed: ${await refreshRes.text()}`).toBeTruthy()
// Cleanup
await request.delete(`/api/players/${playerId}`)
})
test("unrelated user cannot record skill attempts for another's child", async ({ browser }) => {
const userAContext = await browser.newContext()
const userBContext = await browser.newContext()
try {
const userAPage = await userAContext.newPage()
await userAPage.goto('/')
await userAPage.waitForLoadState('networkidle')
const userARequest = userAPage.request
const userBPage = await userBContext.newPage()
await userBPage.goto('/')
await userBPage.waitForLoadState('networkidle')
const userBRequest = userBPage.request
// User A: Create a player
const createPlayerRes = await userARequest.post('/api/players', {
data: { name: 'Protected Child', emoji: '🔒', color: '#F44336' },
})
expect(createPlayerRes.ok()).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// User B: Try POST (record skill attempt) - should fail
const postAttackRes = await userBRequest.post(`/api/curriculum/${playerId}/skills`, {
data: { skillId: '1a-direct-addition', isCorrect: true },
})
expect(postAttackRes.status()).toBe(403)
expect((await postAttackRes.json()).error).toBe('Not authorized')
// User B: Try PUT (set mastered skills) - should fail
const putAttackRes = await userBRequest.put(`/api/curriculum/${playerId}/skills`, {
data: {
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead'],
},
})
expect(putAttackRes.status()).toBe(403)
expect((await putAttackRes.json()).error).toBe('Not authorized')
// User B: Try PATCH (refresh recency) - should fail
const patchAttackRes = await userBRequest.patch(`/api/curriculum/${playerId}/skills`, {
data: { skillId: '1a-direct-addition' },
})
expect(patchAttackRes.status()).toBe(403)
expect((await patchAttackRes.json()).error).toBe('Not authorized')
// Cleanup
await userARequest.delete(`/api/players/${playerId}`)
} finally {
await userAContext.close()
await userBContext.close()
}
})
})
test.describe('Record Game Authorization', () => {
// Skip these tests if player_stats table doesn't exist (run migrations first)
test('parent can record game stats for own child', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const request = page.request
// Create a player
const createPlayerRes = await request.post('/api/players', {
data: { name: 'Gamer Child', emoji: '🎮', color: '#FF9800' },
})
expect(createPlayerRes.ok()).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// Record a game result
const recordGameRes = await request.post('/api/player-stats/record-game', {
data: {
gameResult: {
gameType: 'matching',
completedAt: Date.now(),
playerResults: [{ playerId, won: true, score: 100, accuracy: 0.95 }],
},
},
})
expect(recordGameRes.ok(), `Record game failed: ${await recordGameRes.text()}`).toBeTruthy()
const { success, updates } = await recordGameRes.json()
expect(success).toBe(true)
expect(updates).toHaveLength(1)
expect(updates[0].playerId).toBe(playerId)
// Cleanup
await request.delete(`/api/players/${playerId}`)
})
test("unrelated user cannot record game stats for another's child", async ({ browser }) => {
const userAContext = await browser.newContext()
const userBContext = await browser.newContext()
try {
const userAPage = await userAContext.newPage()
await userAPage.goto('/')
await userAPage.waitForLoadState('networkidle')
const userARequest = userAPage.request
const userBPage = await userBContext.newPage()
await userBPage.goto('/')
await userBPage.waitForLoadState('networkidle')
const userBRequest = userBPage.request
// User A: Create a player
const createPlayerRes = await userARequest.post('/api/players', {
data: { name: 'User A Gamer', emoji: '🕹️', color: '#00BCD4' },
})
expect(createPlayerRes.ok()).toBeTruthy()
const { player } = await createPlayerRes.json()
const playerId = player.id
// User B: Try to record game stats for User A's child (should fail)
const attackRes = await userBRequest.post('/api/player-stats/record-game', {
data: {
gameResult: {
gameType: 'matching',
completedAt: Date.now(),
playerResults: [{ playerId, won: true, score: 99999 }],
},
},
})
expect(attackRes.status()).toBe(403)
const errorBody = await attackRes.json()
expect(errorBody.error).toContain('Not authorized')
// Cleanup
await userARequest.delete(`/api/players/${playerId}`)
} finally {
await userAContext.close()
await userBContext.close()
}
})
test('cannot record game stats for mixed authorized/unauthorized players', async ({
browser,
}) => {
const userAContext = await browser.newContext()
const userBContext = await browser.newContext()
try {
const userAPage = await userAContext.newPage()
await userAPage.goto('/')
await userAPage.waitForLoadState('networkidle')
const userARequest = userAPage.request
const userBPage = await userBContext.newPage()
await userBPage.goto('/')
await userBPage.waitForLoadState('networkidle')
const userBRequest = userBPage.request
// User A: Create their player
const createPlayerARes = await userARequest.post('/api/players', {
data: { name: 'Player A', emoji: '🅰️', color: '#E91E63' },
})
const { player: playerA } = await createPlayerARes.json()
// User B: Create their player
const createPlayerBRes = await userBRequest.post('/api/players', {
data: { name: 'Player B', emoji: '🅱️', color: '#3F51B5' },
})
const { player: playerB } = await createPlayerBRes.json()
// User A: Try to record game with BOTH players (should fail - can't record for Player B)
const mixedRes = await userARequest.post('/api/player-stats/record-game', {
data: {
gameResult: {
gameType: 'matching',
completedAt: Date.now(),
playerResults: [
{ playerId: playerA.id, won: true, score: 100 },
{ playerId: playerB.id, won: false, score: 50 },
],
},
},
})
expect(mixedRes.status()).toBe(403)
const errorBody = await mixedRes.json()
expect(errorBody.error).toContain(playerB.id)
// Cleanup
await userARequest.delete(`/api/players/${playerA.id}`)
await userBRequest.delete(`/api/players/${playerB.id}`)
} finally {
await userAContext.close()
await userBContext.close()
}
})
})
test.describe('Teacher Authorization', () => {
test.skip('teacher-present can modify student curriculum', async () => {
// TODO: Implement when classroom e2e helpers are available
})
test.skip('teacher-enrolled (not present) cannot modify student curriculum', async () => {
// TODO: Implement when classroom e2e helpers are available
})
})
})

View File

@@ -0,0 +1,567 @@
/**
* E2E tests for Entry Prompts feature
*
* Tests the complete flow of teachers sending entry prompts to parents
* to have their children enter the classroom.
*
* Test scenarios:
* - Teacher creates classroom and enrolls student
* - Teacher sends entry prompt to parent
* - Parent accepts/declines prompt
* - Teacher configures entry prompt expiry time
* - Watch Session visible for practicing enrolled students
*/
import { expect, test, type APIRequestContext } from '@playwright/test'
/**
* Helper to get or create a classroom for the teacher
* Teachers can only have one classroom, so this handles both cases
*/
async function getOrCreateClassroom(
request: APIRequestContext,
name: string
): Promise<{
id: string
code: string
entryPromptExpiryMinutes: number | null
}> {
// First try to get existing classroom
const getRes = await request.get('/api/classrooms/mine')
if (getRes.ok()) {
const data = await getRes.json()
if (data.classroom) {
return {
id: data.classroom.id,
code: data.classroom.code,
entryPromptExpiryMinutes: data.classroom.entryPromptExpiryMinutes,
}
}
}
// No existing classroom, create one
const createRes = await request.post('/api/classrooms', {
data: { name },
})
if (!createRes.ok()) {
throw new Error(`Failed to create classroom: ${await createRes.text()}`)
}
const { classroom } = await createRes.json()
return {
id: classroom.id,
code: classroom.code,
entryPromptExpiryMinutes: classroom.entryPromptExpiryMinutes,
}
}
test.describe('Entry Prompts', () => {
test.describe('API Endpoints', () => {
test('teacher can create entry prompt for enrolled student', async ({ browser }) => {
// Create two isolated browser contexts (teacher and parent)
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
// Teacher: Set up classroom
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
// Parent: Create player (child)
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Step 1: Parent creates a child
const createPlayerRes = await parentRequest.post('/api/players', {
data: { name: 'Entry Test Child', emoji: '🧒', color: '#4CAF50' },
})
expect(
createPlayerRes.ok(),
`Create player failed: ${await createPlayerRes.text()}`
).toBeTruthy()
const { player } = await createPlayerRes.json()
const childId = player.id
// Step 2: Teacher gets or creates classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Entry Prompt Test Class')
const classroomId = classroom.id
const classroomCode = classroom.code
// Step 3: Parent enrolls child using classroom code
const lookupRes = await parentRequest.get(`/api/classrooms/code/${classroomCode}`)
expect(lookupRes.ok(), `Lookup classroom failed: ${await lookupRes.text()}`).toBeTruthy()
const enrollRes = await parentRequest.post(
`/api/classrooms/${classroomId}/enrollment-requests`,
{
data: { playerId: childId },
}
)
expect(enrollRes.ok(), `Enroll failed: ${await enrollRes.text()}`).toBeTruthy()
const { request: enrollmentRequest } = await enrollRes.json()
// Step 4: Teacher approves enrollment
const approveRes = await teacherRequest.post(
`/api/classrooms/${classroomId}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
expect(
approveRes.ok(),
`Approve enrollment failed: ${await approveRes.text()}`
).toBeTruthy()
// Step 5: Teacher sends entry prompt
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroomId}/entry-prompts`,
{
data: { playerIds: [childId] },
}
)
expect(promptRes.ok(), `Create prompt failed: ${await promptRes.text()}`).toBeTruthy()
const promptData = await promptRes.json()
expect(promptData.created).toBe(1)
expect(promptData.prompts).toHaveLength(1)
expect(promptData.prompts[0].playerId).toBe(childId)
// Cleanup - just delete the player, keep the classroom
await parentRequest.delete(`/api/players/${childId}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
test('cannot send prompt to student already present', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Present Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Presence Test Class')
// Enroll child
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
// Get enrollment request ID and approve
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Parent enters child into classroom
const enterRes = await parentRequest.post(`/api/classrooms/${classroom.id}/presence`, {
data: { playerId: player.id },
})
expect(enterRes.ok(), `Enter classroom failed: ${await enterRes.text()}`).toBeTruthy()
// Teacher tries to send prompt - should be skipped
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
expect(promptRes.ok()).toBeTruthy()
const promptData = await promptRes.json()
expect(promptData.created).toBe(0)
expect(promptData.skipped).toHaveLength(1)
expect(promptData.skipped[0].reason).toBe('already_present')
// Cleanup
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
test('parent can accept entry prompt', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Accept Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Accept Test Class')
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Teacher sends entry prompt
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
const { prompts } = await promptRes.json()
const promptId = prompts[0].id
// Parent accepts prompt
const acceptRes = await parentRequest.post(`/api/entry-prompts/${promptId}/respond`, {
data: { action: 'accept' },
})
expect(acceptRes.ok(), `Accept prompt failed: ${await acceptRes.text()}`).toBeTruthy()
const acceptData = await acceptRes.json()
expect(acceptData.action).toBe('accepted')
// Verify child is now present
const presenceRes = await teacherRequest.get(`/api/classrooms/${classroom.id}/presence`)
expect(presenceRes.ok()).toBeTruthy()
const presenceData = await presenceRes.json()
const childPresent = presenceData.students.some((s: { id: string }) => s.id === player.id)
expect(childPresent).toBe(true)
// Cleanup
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
test('parent can decline entry prompt', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Decline Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Decline Test Class')
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Teacher sends entry prompt
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
const { prompts } = await promptRes.json()
const promptId = prompts[0].id
// Parent declines prompt
const declineRes = await parentRequest.post(`/api/entry-prompts/${promptId}/respond`, {
data: { action: 'decline' },
})
expect(declineRes.ok(), `Decline prompt failed: ${await declineRes.text()}`).toBeTruthy()
const declineData = await declineRes.json()
expect(declineData.action).toBe('declined')
// Verify child is NOT present
const presenceRes = await teacherRequest.get(`/api/classrooms/${classroom.id}/presence`)
expect(presenceRes.ok()).toBeTruthy()
const presenceData = await presenceRes.json()
const childPresent = presenceData.students.some((s: { id: string }) => s.id === player.id)
expect(childPresent).toBe(false)
// Cleanup
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
})
test.describe('Classroom Settings', () => {
test('teacher can configure entry prompt expiry time', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const request = page.request
// Get or create classroom
const classroom = await getOrCreateClassroom(request, 'Settings Test Class')
// Update expiry setting to 60 minutes
const updateRes = await request.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: 60 },
})
expect(updateRes.ok(), `Update failed: ${await updateRes.text()}`).toBeTruthy()
const { classroom: updated } = await updateRes.json()
expect(updated.entryPromptExpiryMinutes).toBe(60)
// Update to a different value
const update2Res = await request.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: 15 },
})
expect(update2Res.ok()).toBeTruthy()
const { classroom: updated2 } = await update2Res.json()
expect(updated2.entryPromptExpiryMinutes).toBe(15)
// Reset to default (null)
const resetRes = await request.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: null },
})
expect(resetRes.ok()).toBeTruthy()
const { classroom: reset } = await resetRes.json()
expect(reset.entryPromptExpiryMinutes).toBeNull()
})
test('entry prompt uses classroom expiry setting', async ({ browser }) => {
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Expiry Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Expiry Test Class')
// Set classroom expiry to 90 minutes
await teacherRequest.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: 90 },
})
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Send entry prompt - should use 90 minute expiry
const promptRes = await teacherRequest.post(
`/api/classrooms/${classroom.id}/entry-prompts`,
{
data: { playerIds: [player.id] },
}
)
expect(promptRes.ok()).toBeTruthy()
const { prompts } = await promptRes.json()
// Verify expiry is approximately 90 minutes from now
const expiresAt = new Date(prompts[0].expiresAt)
const now = new Date()
const diffMinutes = (expiresAt.getTime() - now.getTime()) / (60 * 1000)
// Allow some tolerance for test execution time
expect(diffMinutes).toBeGreaterThan(88)
expect(diffMinutes).toBeLessThan(92)
// Reset classroom setting and cleanup
await teacherRequest.patch(`/api/classrooms/${classroom.id}`, {
data: { entryPromptExpiryMinutes: null },
})
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
})
test.describe('Active Sessions for Enrolled Students', () => {
test('active sessions returned for enrolled students not present', async ({ browser }) => {
test.setTimeout(60000) // Increase timeout for this complex test
const teacherContext = await browser.newContext()
const parentContext = await browser.newContext()
try {
const teacherPage = await teacherContext.newPage()
await teacherPage.goto('/')
await teacherPage.waitForLoadState('networkidle')
const teacherRequest = teacherPage.request
const parentPage = await parentContext.newPage()
await parentPage.goto('/')
await parentPage.waitForLoadState('networkidle')
const parentRequest = parentPage.request
// Setup: Create child with skills
const { player } = await (
await parentRequest.post('/api/players', {
data: { name: 'Session Test Child', emoji: '🧒', color: '#4CAF50' },
})
).json()
// Enable skills for the player
await parentRequest.put(`/api/curriculum/${player.id}/skills`, {
data: {
masteredSkillIds: ['1a-direct-addition', '1b-heaven-bead', '1c-simple-combinations'],
},
})
// Get or create classroom
const classroom = await getOrCreateClassroom(teacherRequest, 'Session Test Class')
// Enroll and approve
await parentRequest.post(`/api/classrooms/${classroom.id}/enrollment-requests`, {
data: { playerId: player.id },
})
const requestsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/enrollment-requests`
)
const { requests } = await requestsRes.json()
const enrollmentRequest = requests.find(
(r: { playerId: string }) => r.playerId === player.id
)
await teacherRequest.post(
`/api/classrooms/${classroom.id}/enrollment-requests/${enrollmentRequest.id}/approve`,
{ data: {} }
)
// Parent starts a practice session for their child (without entering classroom)
const createPlanRes = await parentRequest.post(
`/api/curriculum/${player.id}/sessions/plans`,
{
data: { durationMinutes: 5 },
}
)
expect(createPlanRes.ok(), `Create plan failed: ${await createPlanRes.text()}`).toBeTruthy()
const { plan } = await createPlanRes.json()
// Approve and start the plan
await parentRequest.patch(`/api/curriculum/${player.id}/sessions/plans/${plan.id}`, {
data: { action: 'approve' },
})
await parentRequest.patch(`/api/curriculum/${player.id}/sessions/plans/${plan.id}`, {
data: { action: 'start' },
})
// Teacher checks active sessions - should include this student even though not present
const sessionsRes = await teacherRequest.get(
`/api/classrooms/${classroom.id}/presence/active-sessions`
)
expect(sessionsRes.ok(), `Get sessions failed: ${await sessionsRes.text()}`).toBeTruthy()
const { sessions } = await sessionsRes.json()
// Find the session for our test player
const playerSession = sessions.find((s: { playerId: string }) => s.playerId === player.id)
expect(playerSession).toBeDefined()
expect(playerSession.isPresent).toBe(false) // Not present but session is visible
// Cleanup - abandon session first
await parentRequest.patch(`/api/curriculum/${player.id}/sessions/plans/${plan.id}`, {
data: { action: 'abandon' },
})
await parentRequest.delete(`/api/players/${player.id}`)
} finally {
await teacherContext.close()
await parentContext.close()
}
})
})
})

View File

@@ -25,6 +25,12 @@ const nextConfig = {
layers: true,
}
// Exclude native Node.js modules from client bundle
// canvas is a jscanify dependency only needed for Node.js, not browser
if (!isServer) {
config.externals = [...(config.externals || []), 'canvas']
}
// Optimize WASM loading
if (!isServer) {
// Enable dynamic imports for better code splitting

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",
@@ -59,19 +61,28 @@
"@svg-maps/world": "^2.0.0",
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-virtual": "^3.13.13",
"@tensorflow/tfjs": "^4.22.0",
"@types/jsdom": "^21.1.7",
"@types/qrcode": "^1.5.6",
"@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",
"framer-motion": "^12.23.26",
"gray-matter": "^4.0.3",
"jose": "^6.1.0",
"js-aruco2": "^2.0.0",
"js-yaml": "^4.1.0",
"jscanify": "^1.4.0",
"jspdf": "^3.0.4",
"lib0": "^0.2.114",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
@@ -79,7 +90,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 +99,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 +124,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,11 +135,13 @@
"@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",
"happy-dom": "^18.0.1",
"jsdom": "^27.0.0",
"sharp": "^0.34.5",
"storybook": "^9.1.7",
"tsc-alias": "^1.8.16",
"tsx": "^4.20.5",

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

@@ -8,7 +8,7 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3002',
baseURL: process.env.BASE_URL || 'http://localhost:3002',
trace: 'on-first-retry',
},
@@ -19,9 +19,11 @@ export default defineConfig({
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3002',
reuseExistingServer: !process.env.CI,
},
webServer: process.env.BASE_URL
? undefined
: {
command: 'pnpm dev',
url: 'http://localhost:3002',
reuseExistingServer: !process.env.CI,
},
})

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,674 @@
/*
Copyright (c) 2020 Damiano Falcioni
Copyright (c) 2011 Juan Mellado
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
References:
- "ArUco: a minimal library for Augmented Reality applications based on OpenCv"
http://www.uco.es/investiga/grupos/ava/node/26
- "js-aruco: a port to JavaScript of the ArUco library"
https://github.com/jcmellado/js-aruco
*/
var AR = {}
var CV = this.CV || require('./cv').CV
this.AR = AR
AR.DICTIONARIES = {
ARUCO: {
nBits: 25,
tau: 3,
codeList: [
0x1084210, 0x1084217, 0x1084209, 0x108420e, 0x10842f0, 0x10842f7, 0x10842e9, 0x10842ee,
0x1084130, 0x1084137, 0x1084129, 0x108412e, 0x10841d0, 0x10841d7, 0x10841c9, 0x10841ce,
0x1085e10, 0x1085e17, 0x1085e09, 0x1085e0e, 0x1085ef0, 0x1085ef7, 0x1085ee9, 0x1085eee,
0x1085d30, 0x1085d37, 0x1085d29, 0x1085d2e, 0x1085dd0, 0x1085dd7, 0x1085dc9, 0x1085dce,
0x1082610, 0x1082617, 0x1082609, 0x108260e, 0x10826f0, 0x10826f7, 0x10826e9, 0x10826ee,
0x1082530, 0x1082537, 0x1082529, 0x108252e, 0x10825d0, 0x10825d7, 0x10825c9, 0x10825ce,
0x1083a10, 0x1083a17, 0x1083a09, 0x1083a0e, 0x1083af0, 0x1083af7, 0x1083ae9, 0x1083aee,
0x1083930, 0x1083937, 0x1083929, 0x108392e, 0x10839d0, 0x10839d7, 0x10839c9, 0x10839ce,
0x10bc210, 0x10bc217, 0x10bc209, 0x10bc20e, 0x10bc2f0, 0x10bc2f7, 0x10bc2e9, 0x10bc2ee,
0x10bc130, 0x10bc137, 0x10bc129, 0x10bc12e, 0x10bc1d0, 0x10bc1d7, 0x10bc1c9, 0x10bc1ce,
0x10bde10, 0x10bde17, 0x10bde09, 0x10bde0e, 0x10bdef0, 0x10bdef7, 0x10bdee9, 0x10bdeee,
0x10bdd30, 0x10bdd37, 0x10bdd29, 0x10bdd2e, 0x10bddd0, 0x10bddd7, 0x10bddc9, 0x10bddce,
0x10ba610, 0x10ba617, 0x10ba609, 0x10ba60e, 0x10ba6f0, 0x10ba6f7, 0x10ba6e9, 0x10ba6ee,
0x10ba530, 0x10ba537, 0x10ba529, 0x10ba52e, 0x10ba5d0, 0x10ba5d7, 0x10ba5c9, 0x10ba5ce,
0x10bba10, 0x10bba17, 0x10bba09, 0x10bba0e, 0x10bbaf0, 0x10bbaf7, 0x10bbae9, 0x10bbaee,
0x10bb930, 0x10bb937, 0x10bb929, 0x10bb92e, 0x10bb9d0, 0x10bb9d7, 0x10bb9c9, 0x10bb9ce,
0x104c210, 0x104c217, 0x104c209, 0x104c20e, 0x104c2f0, 0x104c2f7, 0x104c2e9, 0x104c2ee,
0x104c130, 0x104c137, 0x104c129, 0x104c12e, 0x104c1d0, 0x104c1d7, 0x104c1c9, 0x104c1ce,
0x104de10, 0x104de17, 0x104de09, 0x104de0e, 0x104def0, 0x104def7, 0x104dee9, 0x104deee,
0x104dd30, 0x104dd37, 0x104dd29, 0x104dd2e, 0x104ddd0, 0x104ddd7, 0x104ddc9, 0x104ddce,
0x104a610, 0x104a617, 0x104a609, 0x104a60e, 0x104a6f0, 0x104a6f7, 0x104a6e9, 0x104a6ee,
0x104a530, 0x104a537, 0x104a529, 0x104a52e, 0x104a5d0, 0x104a5d7, 0x104a5c9, 0x104a5ce,
0x104ba10, 0x104ba17, 0x104ba09, 0x104ba0e, 0x104baf0, 0x104baf7, 0x104bae9, 0x104baee,
0x104b930, 0x104b937, 0x104b929, 0x104b92e, 0x104b9d0, 0x104b9d7, 0x104b9c9, 0x104b9ce,
0x1074210, 0x1074217, 0x1074209, 0x107420e, 0x10742f0, 0x10742f7, 0x10742e9, 0x10742ee,
0x1074130, 0x1074137, 0x1074129, 0x107412e, 0x10741d0, 0x10741d7, 0x10741c9, 0x10741ce,
0x1075e10, 0x1075e17, 0x1075e09, 0x1075e0e, 0x1075ef0, 0x1075ef7, 0x1075ee9, 0x1075eee,
0x1075d30, 0x1075d37, 0x1075d29, 0x1075d2e, 0x1075dd0, 0x1075dd7, 0x1075dc9, 0x1075dce,
0x1072610, 0x1072617, 0x1072609, 0x107260e, 0x10726f0, 0x10726f7, 0x10726e9, 0x10726ee,
0x1072530, 0x1072537, 0x1072529, 0x107252e, 0x10725d0, 0x10725d7, 0x10725c9, 0x10725ce,
0x1073a10, 0x1073a17, 0x1073a09, 0x1073a0e, 0x1073af0, 0x1073af7, 0x1073ae9, 0x1073aee,
0x1073930, 0x1073937, 0x1073929, 0x107392e, 0x10739d0, 0x10739d7, 0x10739c9, 0x10739ce,
0x1784210, 0x1784217, 0x1784209, 0x178420e, 0x17842f0, 0x17842f7, 0x17842e9, 0x17842ee,
0x1784130, 0x1784137, 0x1784129, 0x178412e, 0x17841d0, 0x17841d7, 0x17841c9, 0x17841ce,
0x1785e10, 0x1785e17, 0x1785e09, 0x1785e0e, 0x1785ef0, 0x1785ef7, 0x1785ee9, 0x1785eee,
0x1785d30, 0x1785d37, 0x1785d29, 0x1785d2e, 0x1785dd0, 0x1785dd7, 0x1785dc9, 0x1785dce,
0x1782610, 0x1782617, 0x1782609, 0x178260e, 0x17826f0, 0x17826f7, 0x17826e9, 0x17826ee,
0x1782530, 0x1782537, 0x1782529, 0x178252e, 0x17825d0, 0x17825d7, 0x17825c9, 0x17825ce,
0x1783a10, 0x1783a17, 0x1783a09, 0x1783a0e, 0x1783af0, 0x1783af7, 0x1783ae9, 0x1783aee,
0x1783930, 0x1783937, 0x1783929, 0x178392e, 0x17839d0, 0x17839d7, 0x17839c9, 0x17839ce,
0x17bc210, 0x17bc217, 0x17bc209, 0x17bc20e, 0x17bc2f0, 0x17bc2f7, 0x17bc2e9, 0x17bc2ee,
0x17bc130, 0x17bc137, 0x17bc129, 0x17bc12e, 0x17bc1d0, 0x17bc1d7, 0x17bc1c9, 0x17bc1ce,
0x17bde10, 0x17bde17, 0x17bde09, 0x17bde0e, 0x17bdef0, 0x17bdef7, 0x17bdee9, 0x17bdeee,
0x17bdd30, 0x17bdd37, 0x17bdd29, 0x17bdd2e, 0x17bddd0, 0x17bddd7, 0x17bddc9, 0x17bddce,
0x17ba610, 0x17ba617, 0x17ba609, 0x17ba60e, 0x17ba6f0, 0x17ba6f7, 0x17ba6e9, 0x17ba6ee,
0x17ba530, 0x17ba537, 0x17ba529, 0x17ba52e, 0x17ba5d0, 0x17ba5d7, 0x17ba5c9, 0x17ba5ce,
0x17bba10, 0x17bba17, 0x17bba09, 0x17bba0e, 0x17bbaf0, 0x17bbaf7, 0x17bbae9, 0x17bbaee,
0x17bb930, 0x17bb937, 0x17bb929, 0x17bb92e, 0x17bb9d0, 0x17bb9d7, 0x17bb9c9, 0x17bb9ce,
0x174c210, 0x174c217, 0x174c209, 0x174c20e, 0x174c2f0, 0x174c2f7, 0x174c2e9, 0x174c2ee,
0x174c130, 0x174c137, 0x174c129, 0x174c12e, 0x174c1d0, 0x174c1d7, 0x174c1c9, 0x174c1ce,
0x174de10, 0x174de17, 0x174de09, 0x174de0e, 0x174def0, 0x174def7, 0x174dee9, 0x174deee,
0x174dd30, 0x174dd37, 0x174dd29, 0x174dd2e, 0x174ddd0, 0x174ddd7, 0x174ddc9, 0x174ddce,
0x174a610, 0x174a617, 0x174a609, 0x174a60e, 0x174a6f0, 0x174a6f7, 0x174a6e9, 0x174a6ee,
0x174a530, 0x174a537, 0x174a529, 0x174a52e, 0x174a5d0, 0x174a5d7, 0x174a5c9, 0x174a5ce,
0x174ba10, 0x174ba17, 0x174ba09, 0x174ba0e, 0x174baf0, 0x174baf7, 0x174bae9, 0x174baee,
0x174b930, 0x174b937, 0x174b929, 0x174b92e, 0x174b9d0, 0x174b9d7, 0x174b9c9, 0x174b9ce,
0x1774210, 0x1774217, 0x1774209, 0x177420e, 0x17742f0, 0x17742f7, 0x17742e9, 0x17742ee,
0x1774130, 0x1774137, 0x1774129, 0x177412e, 0x17741d0, 0x17741d7, 0x17741c9, 0x17741ce,
0x1775e10, 0x1775e17, 0x1775e09, 0x1775e0e, 0x1775ef0, 0x1775ef7, 0x1775ee9, 0x1775eee,
0x1775d30, 0x1775d37, 0x1775d29, 0x1775d2e, 0x1775dd0, 0x1775dd7, 0x1775dc9, 0x1775dce,
0x1772610, 0x1772617, 0x1772609, 0x177260e, 0x17726f0, 0x17726f7, 0x17726e9, 0x17726ee,
0x1772530, 0x1772537, 0x1772529, 0x177252e, 0x17725d0, 0x17725d7, 0x17725c9, 0x17725ce,
0x1773a10, 0x1773a17, 0x1773a09, 0x1773a0e, 0x1773af0, 0x1773af7, 0x1773ae9, 0x1773aee,
0x1773930, 0x1773937, 0x1773929, 0x177392e, 0x17739d0, 0x17739d7, 0x17739c9, 0x17739ce,
0x984210, 0x984217, 0x984209, 0x98420e, 0x9842f0, 0x9842f7, 0x9842e9, 0x9842ee, 0x984130,
0x984137, 0x984129, 0x98412e, 0x9841d0, 0x9841d7, 0x9841c9, 0x9841ce, 0x985e10, 0x985e17,
0x985e09, 0x985e0e, 0x985ef0, 0x985ef7, 0x985ee9, 0x985eee, 0x985d30, 0x985d37, 0x985d29,
0x985d2e, 0x985dd0, 0x985dd7, 0x985dc9, 0x985dce, 0x982610, 0x982617, 0x982609, 0x98260e,
0x9826f0, 0x9826f7, 0x9826e9, 0x9826ee, 0x982530, 0x982537, 0x982529, 0x98252e, 0x9825d0,
0x9825d7, 0x9825c9, 0x9825ce, 0x983a10, 0x983a17, 0x983a09, 0x983a0e, 0x983af0, 0x983af7,
0x983ae9, 0x983aee, 0x983930, 0x983937, 0x983929, 0x98392e, 0x9839d0, 0x9839d7, 0x9839c9,
0x9839ce, 0x9bc210, 0x9bc217, 0x9bc209, 0x9bc20e, 0x9bc2f0, 0x9bc2f7, 0x9bc2e9, 0x9bc2ee,
0x9bc130, 0x9bc137, 0x9bc129, 0x9bc12e, 0x9bc1d0, 0x9bc1d7, 0x9bc1c9, 0x9bc1ce, 0x9bde10,
0x9bde17, 0x9bde09, 0x9bde0e, 0x9bdef0, 0x9bdef7, 0x9bdee9, 0x9bdeee, 0x9bdd30, 0x9bdd37,
0x9bdd29, 0x9bdd2e, 0x9bddd0, 0x9bddd7, 0x9bddc9, 0x9bddce, 0x9ba610, 0x9ba617, 0x9ba609,
0x9ba60e, 0x9ba6f0, 0x9ba6f7, 0x9ba6e9, 0x9ba6ee, 0x9ba530, 0x9ba537, 0x9ba529, 0x9ba52e,
0x9ba5d0, 0x9ba5d7, 0x9ba5c9, 0x9ba5ce, 0x9bba10, 0x9bba17, 0x9bba09, 0x9bba0e, 0x9bbaf0,
0x9bbaf7, 0x9bbae9, 0x9bbaee, 0x9bb930, 0x9bb937, 0x9bb929, 0x9bb92e, 0x9bb9d0, 0x9bb9d7,
0x9bb9c9, 0x9bb9ce, 0x94c210, 0x94c217, 0x94c209, 0x94c20e, 0x94c2f0, 0x94c2f7, 0x94c2e9,
0x94c2ee, 0x94c130, 0x94c137, 0x94c129, 0x94c12e, 0x94c1d0, 0x94c1d7, 0x94c1c9, 0x94c1ce,
0x94de10, 0x94de17, 0x94de09, 0x94de0e, 0x94def0, 0x94def7, 0x94dee9, 0x94deee, 0x94dd30,
0x94dd37, 0x94dd29, 0x94dd2e, 0x94ddd0, 0x94ddd7, 0x94ddc9, 0x94ddce, 0x94a610, 0x94a617,
0x94a609, 0x94a60e, 0x94a6f0, 0x94a6f7, 0x94a6e9, 0x94a6ee, 0x94a530, 0x94a537, 0x94a529,
0x94a52e, 0x94a5d0, 0x94a5d7, 0x94a5c9, 0x94a5ce, 0x94ba10, 0x94ba17, 0x94ba09, 0x94ba0e,
0x94baf0, 0x94baf7, 0x94bae9, 0x94baee, 0x94b930, 0x94b937, 0x94b929, 0x94b92e, 0x94b9d0,
0x94b9d7, 0x94b9c9, 0x94b9ce, 0x974210, 0x974217, 0x974209, 0x97420e, 0x9742f0, 0x9742f7,
0x9742e9, 0x9742ee, 0x974130, 0x974137, 0x974129, 0x97412e, 0x9741d0, 0x9741d7, 0x9741c9,
0x9741ce, 0x975e10, 0x975e17, 0x975e09, 0x975e0e, 0x975ef0, 0x975ef7, 0x975ee9, 0x975eee,
0x975d30, 0x975d37, 0x975d29, 0x975d2e, 0x975dd0, 0x975dd7, 0x975dc9, 0x975dce, 0x972610,
0x972617, 0x972609, 0x97260e, 0x9726f0, 0x9726f7, 0x9726e9, 0x9726ee, 0x972530, 0x972537,
0x972529, 0x97252e, 0x9725d0, 0x9725d7, 0x9725c9, 0x9725ce, 0x973a10, 0x973a17, 0x973a09,
0x973a0e, 0x973af0, 0x973af7, 0x973ae9, 0x973aee, 0x973930, 0x973937, 0x973929, 0x97392e,
0x9739d0, 0x9739d7, 0x9739c9, 0x9739ce, 0xe84210, 0xe84217, 0xe84209, 0xe8420e, 0xe842f0,
0xe842f7, 0xe842e9, 0xe842ee, 0xe84130, 0xe84137, 0xe84129, 0xe8412e, 0xe841d0, 0xe841d7,
0xe841c9, 0xe841ce, 0xe85e10, 0xe85e17, 0xe85e09, 0xe85e0e, 0xe85ef0, 0xe85ef7, 0xe85ee9,
0xe85eee, 0xe85d30, 0xe85d37, 0xe85d29, 0xe85d2e, 0xe85dd0, 0xe85dd7, 0xe85dc9, 0xe85dce,
0xe82610, 0xe82617, 0xe82609, 0xe8260e, 0xe826f0, 0xe826f7, 0xe826e9, 0xe826ee, 0xe82530,
0xe82537, 0xe82529, 0xe8252e, 0xe825d0, 0xe825d7, 0xe825c9, 0xe825ce, 0xe83a10, 0xe83a17,
0xe83a09, 0xe83a0e, 0xe83af0, 0xe83af7, 0xe83ae9, 0xe83aee, 0xe83930, 0xe83937, 0xe83929,
0xe8392e, 0xe839d0, 0xe839d7, 0xe839c9, 0xe839ce, 0xebc210, 0xebc217, 0xebc209, 0xebc20e,
0xebc2f0, 0xebc2f7, 0xebc2e9, 0xebc2ee, 0xebc130, 0xebc137, 0xebc129, 0xebc12e, 0xebc1d0,
0xebc1d7, 0xebc1c9, 0xebc1ce, 0xebde10, 0xebde17, 0xebde09, 0xebde0e, 0xebdef0, 0xebdef7,
0xebdee9, 0xebdeee, 0xebdd30, 0xebdd37, 0xebdd29, 0xebdd2e, 0xebddd0, 0xebddd7, 0xebddc9,
0xebddce, 0xeba610, 0xeba617, 0xeba609, 0xeba60e, 0xeba6f0, 0xeba6f7, 0xeba6e9, 0xeba6ee,
0xeba530, 0xeba537, 0xeba529, 0xeba52e, 0xeba5d0, 0xeba5d7, 0xeba5c9, 0xeba5ce, 0xebba10,
0xebba17, 0xebba09, 0xebba0e, 0xebbaf0, 0xebbaf7, 0xebbae9, 0xebbaee, 0xebb930, 0xebb937,
0xebb929, 0xebb92e, 0xebb9d0, 0xebb9d7, 0xebb9c9, 0xebb9ce, 0xe4c210, 0xe4c217, 0xe4c209,
0xe4c20e, 0xe4c2f0, 0xe4c2f7, 0xe4c2e9, 0xe4c2ee, 0xe4c130, 0xe4c137, 0xe4c129, 0xe4c12e,
0xe4c1d0, 0xe4c1d7, 0xe4c1c9, 0xe4c1ce, 0xe4de10, 0xe4de17, 0xe4de09, 0xe4de0e, 0xe4def0,
0xe4def7, 0xe4dee9, 0xe4deee, 0xe4dd30, 0xe4dd37, 0xe4dd29, 0xe4dd2e, 0xe4ddd0, 0xe4ddd7,
0xe4ddc9, 0xe4ddce, 0xe4a610, 0xe4a617, 0xe4a609, 0xe4a60e, 0xe4a6f0, 0xe4a6f7, 0xe4a6e9,
0xe4a6ee, 0xe4a530, 0xe4a537, 0xe4a529, 0xe4a52e, 0xe4a5d0, 0xe4a5d7, 0xe4a5c9, 0xe4a5ce,
0xe4ba10, 0xe4ba17, 0xe4ba09, 0xe4ba0e, 0xe4baf0, 0xe4baf7, 0xe4bae9, 0xe4baee, 0xe4b930,
0xe4b937, 0xe4b929, 0xe4b92e, 0xe4b9d0, 0xe4b9d7, 0xe4b9c9, 0xe4b9ce, 0xe74210, 0xe74217,
0xe74209, 0xe7420e, 0xe742f0, 0xe742f7, 0xe742e9, 0xe742ee, 0xe74130, 0xe74137, 0xe74129,
0xe7412e, 0xe741d0, 0xe741d7, 0xe741c9, 0xe741ce, 0xe75e10, 0xe75e17, 0xe75e09, 0xe75e0e,
0xe75ef0, 0xe75ef7, 0xe75ee9, 0xe75eee, 0xe75d30, 0xe75d37, 0xe75d29, 0xe75d2e, 0xe75dd0,
0xe75dd7, 0xe75dc9, 0xe75dce, 0xe72610, 0xe72617, 0xe72609, 0xe7260e, 0xe726f0, 0xe726f7,
0xe726e9, 0xe726ee, 0xe72530, 0xe72537, 0xe72529, 0xe7252e, 0xe725d0, 0xe725d7, 0xe725c9,
0xe725ce, 0xe73a10, 0xe73a17, 0xe73a09, 0xe73a0e, 0xe73af0, 0xe73af7, 0xe73ae9, 0xe73aee,
0xe73930, 0xe73937, 0xe73929, 0xe7392e, 0xe739d0, 0xe739d7, 0xe739c9,
],
},
ARUCO_MIP_36h12: {
nBits: 36,
tau: 12,
codeList: [
0xd2b63a09d, 0x6001134e5, 0x1206fbe72, 0xff8ad6cb4, 0x85da9bc49, 0xb461afe9c, 0x6db51fe13,
0x5248c541f, 0x8f34503, 0x8ea462ece, 0xeac2be76d, 0x1af615c44, 0xb48a49f27, 0x2e4e1283b,
0x78b1f2fa8, 0x27d34f57e, 0x89222fff1, 0x4c1669406, 0xbf49b3511, 0xdc191cd5d, 0x11d7c3f85,
0x16a130e35, 0xe29f27eff, 0x428d8ae0c, 0x90d548477, 0x2319cbc93, 0xc3b0c3dfc, 0x424bccc9,
0x2a081d630, 0x762743d96, 0xd0645bf19, 0xf38d7fd60, 0xc6cbf9a10, 0x3c1be7c65, 0x276f75e63,
0x4490a3f63, 0xda60acd52, 0x3cc68df59, 0xab46f9dae, 0x88d533d78, 0xb6d62ec21, 0xb3c02b646,
0x22e56d408, 0xac5f5770a, 0xaaa993f66, 0x4caa07c8d, 0x5c9b4f7b0, 0xaa9ef0e05, 0x705c5750,
0xac81f545e, 0x735b91e74, 0x8cc35cee4, 0xe44694d04, 0xb5e121de0, 0x261017d0f, 0xf1d439eb5,
0xa1a33ac96, 0x174c62c02, 0x1ee27f716, 0x8b1c5ece9, 0x6a05b0c6a, 0xd0568dfc, 0x192d25e5f,
0x1adbeccc8, 0xcfec87f00, 0xd0b9dde7a, 0x88dcef81e, 0x445681cb9, 0xdbb2ffc83, 0xa48d96df1,
0xb72cc2e7d, 0xc295b53f, 0xf49832704, 0x9968edc29, 0x9e4e1af85, 0x8683e2d1b, 0x810b45c04,
0x6ac44bfe2, 0x645346615, 0x3990bd598, 0x1c9ed0f6a, 0xc26729d65, 0x83993f795, 0x3ac05ac5d,
0x357adff3b, 0xd5c05565, 0x2f547ef44, 0x86c115041, 0x640fd9e5f, 0xce08bbcf7, 0x109bb343e,
0xc21435c92, 0x35b4dfce4, 0x459752cf2, 0xec915b82c, 0x51881eed0, 0x2dda7dc97, 0x2e0142144,
0x42e890f99, 0x9a8856527, 0x8e80d9d80, 0x891cbcf34, 0x25dd82410, 0x239551d34, 0x8fe8f0c70,
0x94106a970, 0x82609b40c, 0xfc9caf36, 0x688181d11, 0x718613c08, 0xf1ab7629, 0xa357bfc18,
0x4c03b7a46, 0x204dedce6, 0xad6300d37, 0x84cc4cd09, 0x42160e5c4, 0x87d2adfa8, 0x7850e7749,
0x4e750fc7c, 0xbf2e5dfda, 0xd88324da5, 0x234b52f80, 0x378204514, 0xabdf2ad53, 0x365e78ef9,
0x49caa6ca2, 0x3c39ddf3, 0xc68c5385d, 0x5bfcbbf67, 0x623241e21, 0xabc90d5cc, 0x388c6fe85,
0xda0e2d62d, 0x10855dfe9, 0x4d46efd6b, 0x76ea12d61, 0x9db377d3d, 0xeed0efa71, 0xe6ec3ae2f,
0x441faee83, 0xba19c8ff5, 0x313035eab, 0x6ce8f7625, 0x880dab58d, 0x8d3409e0d, 0x2be92ee21,
0xd60302c6c, 0x469ffc724, 0x87eebeed3, 0x42587ef7a, 0x7a8cc4e52, 0x76a437650, 0x999e41ef4,
0x7d0969e42, 0xc02baf46b, 0x9259f3e47, 0x2116a1dc0, 0x9f2de4d84, 0xeffac29, 0x7b371ff8c,
0x668339da9, 0xd010aee3f, 0x1cd00b4c0, 0x95070fc3b, 0xf84c9a770, 0x38f863d76, 0x3646ff045,
0xce1b96412, 0x7a5d45da8, 0x14e00ef6c, 0x5e95abfd8, 0xb2e9cb729, 0x36c47dd7, 0xb8ee97c6b,
0xe9e8f657, 0xd4ad2ef1a, 0x8811c7f32, 0x47bde7c31, 0x3adadfb64, 0x6e5b28574, 0x33e67cd91,
0x2ab9fdd2d, 0x8afa67f2b, 0xe6a28fc5e, 0x72049cdbd, 0xae65dac12, 0x1251a4526, 0x1089ab841,
0xe2f096ee0, 0xb0caee573, 0xfd6677e86, 0x444b3f518, 0xbe8b3a56a, 0x680a75cfc, 0xac02baea8,
0x97d815e1c, 0x1d4386e08, 0x1a14f5b0e, 0xe658a8d81, 0xa3868efa7, 0x3668a9673, 0xe8fc53d85,
0x2e2b7edd5, 0x8b2470f13, 0xf69795f32, 0x4589ffc8e, 0x2e2080c9c, 0x64265f7d, 0x3d714dd10,
0x1692c6ef1, 0x3e67f2f49, 0x5041dad63, 0x1a1503415, 0x64c18c742, 0xa72eec35, 0x1f0f9dc60,
0xa9559bc67, 0xf32911d0d, 0x21c0d4ffc, 0xe01cef5b0, 0x4e23a3520, 0xaa4f04e49, 0xe1c4fcc43,
0x208e8f6e8, 0x8486774a5, 0x9e98c7558, 0x2c59fb7dc, 0x9446a4613, 0x8292dcc2e, 0x4d61631,
0xd05527809, 0xa0163852d, 0x8f657f639, 0xcca6c3e37, 0xcb136bc7a, 0xfc5a83e53, 0x9aa44fc30,
0xbdec1bd3c, 0xe020b9f7c, 0x4b8f35fb0, 0xb8165f637, 0x33dc88d69, 0x10a2f7e4d, 0xc8cb5ff53,
0xde259ff6b, 0x46d070dd4, 0x32d3b9741, 0x7075f1c04, 0x4d58dbea0,
],
},
}
AR.Dictionary = function (dicName) {
this.codes = {}
this.codeList = []
this.tau = 0
this._initialize(dicName)
}
AR.Dictionary.prototype._initialize = function (dicName) {
this.codes = {}
this.codeList = []
this.tau = 0
this.nBits = 0
this.markSize = 0
this.dicName = dicName
var dictionary = AR.DICTIONARIES[dicName]
if (!dictionary) throw 'The dictionary "' + dicName + '" is not recognized.'
this.nBits = dictionary.nBits
this.markSize = Math.sqrt(dictionary.nBits) + 2
for (var i = 0; i < dictionary.codeList.length; i++) {
var code = null
if (typeof dictionary.codeList[i] === 'number')
code = this._hex2bin(dictionary.codeList[i], dictionary.nBits)
if (typeof dictionary.codeList[i] === 'string')
code = this._hex2bin(parseInt(dictionary.codeList[i], 16), dictionary.nBits)
if (Array.isArray(dictionary.codeList[i]))
code = this._bytes2bin(dictionary.codeList[i], dictionary.nBits)
if (code === null)
throw (
'Invalid code ' +
i +
' in dictionary ' +
dicName +
': ' +
JSON.stringify(dictionary.codeList[i])
)
if (code.length != dictionary.nBits)
throw (
'The code ' +
i +
' in dictionary ' +
dicName +
' is not ' +
dictionary.nBits +
' bits long but ' +
code.length +
': ' +
code
)
this.codeList.push(code)
this.codes[code] = {
id: i,
}
}
this.tau = dictionary.tau || this._calculateTau()
}
AR.Dictionary.prototype.find = function (bits) {
var val = '',
i,
j
for (i = 0; i < bits.length; i++) {
var bitRow = bits[i]
for (j = 0; j < bitRow.length; j++) {
val += bitRow[j]
}
}
var minFound = this.codes[val]
if (minFound)
return {
id: minFound.id,
distance: 0,
}
for (i = 0; i < this.codeList.length; i++) {
var code = this.codeList[i]
var distance = this._hammingDistance(val, code)
if (this._hammingDistance(val, code) < this.tau) {
if (!minFound || minFound.distance > distance) {
minFound = {
id: this.codes[code].id,
distance: distance,
}
}
}
}
return minFound
}
AR.Dictionary.prototype._hex2bin = (hex, nBits) => hex.toString(2).padStart(nBits, '0')
AR.Dictionary.prototype._bytes2bin = (byteList, nBits) => {
var bits = '',
byte
for (byte of byteList) {
bits += byte.toString(2).padStart(bits.length + 8 > nBits ? nBits - bits.length : 8, '0')
}
return bits
}
AR.Dictionary.prototype._hammingDistance = (str1, str2) => {
if (str1.length != str2.length)
throw 'Hamming distance calculation require inputs of the same length'
var distance = 0,
i
for (i = 0; i < str1.length; i++) if (str1[i] !== str2[i]) distance += 1
return distance
}
AR.Dictionary.prototype._calculateTau = function () {
var tau = Number.MAX_VALUE
for (var i = 0; i < this.codeList.length; i++)
for (var j = i + 1; j < this.codeList.length; j++) {
var distance = this._hammingDistance(this.codeList[i], this.codeList[j])
tau = distance < tau ? distance : tau
}
return tau
}
AR.Dictionary.prototype.generateSVG = function (id) {
var code = this.codeList[id]
if (code == null)
throw (
'The id "' +
id +
'" is not valid for the dictionary "' +
this.dicName +
'". ID must be between 0 and ' +
(this.codeList.length - 1) +
' included.'
)
var size = this.markSize - 2
var svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (size + 4) + ' ' + (size + 4) + '">'
svg += '<rect x="0" y="0" width="' + (size + 4) + '" height="' + (size + 4) + '" fill="white"/>'
svg += '<rect x="1" y="1" width="' + (size + 2) + '" height="' + (size + 2) + '" fill="black"/>'
for (var y = 0; y < size; y++) {
for (var x = 0; x < size; x++) {
if (code[y * size + x] == '1')
svg += '<rect x="' + (x + 2) + '" y="' + (y + 2) + '" width="1" height="1" fill="white"/>'
}
}
svg += '</svg>'
return svg
}
AR.Marker = function (id, corners, hammingDistance) {
this.id = id
this.corners = corners
this.hammingDistance = hammingDistance
}
AR.Detector = function (config) {
config = config || {}
this.grey = new CV.Image()
this.thres = new CV.Image()
this.homography = new CV.Image()
this.binary = []
this.contours = []
this.polys = []
this.candidates = []
config.dictionaryName = config.dictionaryName || 'ARUCO_MIP_36h12'
this.dictionary = new AR.Dictionary(config.dictionaryName)
this.dictionary.tau =
config.maxHammingDistance != null ? config.maxHammingDistance : this.dictionary.tau
}
AR.Detector.prototype.detectImage = function (width, height, data) {
return this.detect({
width: width,
height: height,
data: data,
})
}
AR.Detector.prototype.detectStreamInit = function (width, height, callback) {
this.streamConfig = {}
this.streamConfig.width = width
this.streamConfig.height = height
this.streamConfig.imageSize = width * height * 4 //provided image must be a sequence of rgba bytes (4 bytes represent a pixel)
this.streamConfig.index = 0
this.streamConfig.imageData = new Uint8ClampedArray(this.streamConfig.imageSize)
this.streamConfig.callback = callback || ((image, markerList) => {})
}
//accept data chunks of different sizes
AR.Detector.prototype.detectStream = function (data) {
for (var i = 0; i < data.length; i++) {
this.streamConfig.imageData[this.streamConfig.index] = data[i]
this.streamConfig.index = (this.streamConfig.index + 1) % this.streamConfig.imageSize
if (this.streamConfig.index == 0) {
var image = {
width: this.streamConfig.width,
height: this.streamConfig.height,
data: this.streamConfig.imageData,
}
var markerList = this.detect(image)
this.streamConfig.callback(image, markerList)
}
}
}
AR.Detector.prototype.detectMJPEGStreamInit = function (width, height, callback, decoderFn) {
this.mjpeg = {
decoderFn: decoderFn,
chunks: [],
SOI: [0xff, 0xd8],
EOI: [0xff, 0xd9],
}
this.detectStreamInit(width, height, callback)
}
AR.Detector.prototype.detectMJPEGStream = function (chunk) {
var eoiPos = chunk.findIndex(function (element, index, array) {
return (
this.mjpeg.EOI[0] == element &&
array.length > index + 1 &&
this.mjpeg.EOI[1] == array[index + 1]
)
})
var soiPos = chunk.findIndex(function (element, index, array) {
return (
this.mjpeg.SOI[0] == element &&
array.length > index + 1 &&
this.mjpeg.SOI[1] == array[index + 1]
)
})
if (eoiPos === -1) {
this.mjpeg.chunks.push(chunk)
} else {
var part1 = chunk.slice(0, eoiPos + 2)
if (part1.length) {
this.mjpeg.chunks.push(part1)
}
if (this.mjpeg.chunks.length) {
var jpegImage = this.mjpeg.chunks.flat()
var rgba = this.mjpeg.decoderFn(jpegImage)
this.detectStream(rgba)
}
this.mjpeg.chunks = []
}
if (soiPos > -1) {
this.mjpeg.chunks = []
this.mjpeg.chunks.push(chunk.slice(soiPos))
}
}
AR.Detector.prototype.detect = function (image) {
CV.grayscale(image, this.grey)
CV.adaptiveThreshold(this.grey, this.thres, 2, 7)
this.contours = CV.findContours(this.thres, this.binary)
//Scale Fix: https://stackoverflow.com/questions/35936397/marker-detection-on-paper-sheet-using-javascript
//this.candidates = this.findCandidates(this.contours, image.width * 0.20, 0.05, 10);
this.candidates = this.findCandidates(this.contours, image.width * 0.01, 0.05, 10)
this.candidates = this.clockwiseCorners(this.candidates)
this.candidates = this.notTooNear(this.candidates, 10)
return this.findMarkers(this.grey, this.candidates, 49)
}
AR.Detector.prototype.findCandidates = function (contours, minSize, epsilon, minLength) {
var candidates = [],
len = contours.length,
contour,
poly,
i
this.polys = []
for (i = 0; i < len; ++i) {
contour = contours[i]
if (contour.length >= minSize) {
poly = CV.approxPolyDP(contour, contour.length * epsilon)
this.polys.push(poly)
if (4 === poly.length && CV.isContourConvex(poly)) {
if (CV.minEdgeLength(poly) >= minLength) {
candidates.push(poly)
}
}
}
}
return candidates
}
AR.Detector.prototype.clockwiseCorners = (candidates) => {
var len = candidates.length,
dx1,
dx2,
dy1,
dy2,
swap,
i
for (i = 0; i < len; ++i) {
dx1 = candidates[i][1].x - candidates[i][0].x
dy1 = candidates[i][1].y - candidates[i][0].y
dx2 = candidates[i][2].x - candidates[i][0].x
dy2 = candidates[i][2].y - candidates[i][0].y
if (dx1 * dy2 - dy1 * dx2 < 0) {
swap = candidates[i][1]
candidates[i][1] = candidates[i][3]
candidates[i][3] = swap
}
}
return candidates
}
AR.Detector.prototype.notTooNear = (candidates, minDist) => {
var notTooNear = [],
len = candidates.length,
dist,
dx,
dy,
i,
j,
k
for (i = 0; i < len; ++i) {
for (j = i + 1; j < len; ++j) {
dist = 0
for (k = 0; k < 4; ++k) {
dx = candidates[i][k].x - candidates[j][k].x
dy = candidates[i][k].y - candidates[j][k].y
dist += dx * dx + dy * dy
}
if (dist / 4 < minDist * minDist) {
if (CV.perimeter(candidates[i]) < CV.perimeter(candidates[j])) {
candidates[i].tooNear = true
} else {
candidates[j].tooNear = true
}
}
}
}
for (i = 0; i < len; ++i) {
if (!candidates[i].tooNear) {
notTooNear.push(candidates[i])
}
}
return notTooNear
}
AR.Detector.prototype.findMarkers = function (imageSrc, candidates, warpSize) {
var markers = [],
len = candidates.length,
candidate,
marker,
i
for (i = 0; i < len; ++i) {
candidate = candidates[i]
CV.warp(imageSrc, this.homography, candidate, warpSize)
CV.threshold(this.homography, this.homography, CV.otsu(this.homography))
marker = this.getMarker(this.homography, candidate)
if (marker) {
markers.push(marker)
}
}
return markers
}
AR.Detector.prototype.getMarker = function (imageSrc, candidate) {
var markSize = this.dictionary.markSize
var width = (imageSrc.width / markSize) >>> 0,
minZero = (width * width) >> 1,
bits = [],
rotations = [],
square,
inc,
i,
j
for (i = 0; i < markSize; ++i) {
inc = 0 === i || markSize - 1 === i ? 1 : markSize - 1
for (j = 0; j < markSize; j += inc) {
square = {
x: j * width,
y: i * width,
width: width,
height: width,
}
if (CV.countNonZero(imageSrc, square) > minZero) {
return null
}
}
}
for (i = 0; i < markSize - 2; ++i) {
bits[i] = []
for (j = 0; j < markSize - 2; ++j) {
square = {
x: (j + 1) * width,
y: (i + 1) * width,
width: width,
height: width,
}
bits[i][j] = CV.countNonZero(imageSrc, square) > minZero ? 1 : 0
}
}
rotations[0] = bits
var foundMin = null
var rot = 0
for (i = 0; i < 4; i++) {
var found = this.dictionary.find(rotations[i])
if (found && (foundMin === null || found.distance < foundMin.distance)) {
foundMin = found
rot = i
if (foundMin.distance === 0) break
}
rotations[i + 1] = this.rotate(rotations[i])
}
if (foundMin)
return new AR.Marker(foundMin.id, this.rotate2(candidate, 4 - rot), foundMin.distance)
return null
}
AR.Detector.prototype.rotate = (src) => {
var dst = [],
len = src.length,
i,
j
for (i = 0; i < len; ++i) {
dst[i] = []
for (j = 0; j < src[i].length; ++j) {
dst[i][j] = src[src[i].length - j - 1][i]
}
}
return dst
}
AR.Detector.prototype.rotate2 = (src, rotation) => {
var dst = [],
len = src.length,
i
for (i = 0; i < len; ++i) {
dst[i] = src[(rotation + i) % len]
}
return dst
}

View File

@@ -0,0 +1,879 @@
/*
Copyright (c) 2011 Juan Mellado
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
References:
- "OpenCV: Open Computer Vision Library"
http://sourceforge.net/projects/opencvlibrary/
- "Stack Blur: Fast But Goodlooking"
http://incubator.quasimondo.com/processing/fast_blur_deluxe.php
*/
var CV = CV || {}
this.CV = CV
CV.Image = function (width, height, data) {
this.width = width || 0
this.height = height || 0
this.data = data || []
}
CV.grayscale = (imageSrc, imageDst) => {
var src = imageSrc.data,
dst = imageDst.data,
len = src.length,
i = 0,
j = 0
for (; i < len; i += 4) {
dst[j++] = (src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114 + 0.5) & 0xff
}
imageDst.width = imageSrc.width
imageDst.height = imageSrc.height
return imageDst
}
CV.threshold = (imageSrc, imageDst, threshold) => {
var src = imageSrc.data,
dst = imageDst.data,
len = src.length,
tab = [],
i
for (i = 0; i < 256; ++i) {
tab[i] = i <= threshold ? 0 : 255
}
for (i = 0; i < len; ++i) {
dst[i] = tab[src[i]]
}
imageDst.width = imageSrc.width
imageDst.height = imageSrc.height
return imageDst
}
CV.adaptiveThreshold = (imageSrc, imageDst, kernelSize, threshold) => {
var src = imageSrc.data,
dst = imageDst.data,
len = src.length,
tab = [],
i
CV.stackBoxBlur(imageSrc, imageDst, kernelSize)
for (i = 0; i < 768; ++i) {
tab[i] = i - 255 <= -threshold ? 255 : 0
}
for (i = 0; i < len; ++i) {
dst[i] = tab[src[i] - dst[i] + 255]
}
imageDst.width = imageSrc.width
imageDst.height = imageSrc.height
return imageDst
}
CV.otsu = (imageSrc) => {
var src = imageSrc.data,
len = src.length,
hist = [],
threshold = 0,
sum = 0,
sumB = 0,
wB = 0,
wF = 0,
max = 0,
mu,
between,
i
for (i = 0; i < 256; ++i) {
hist[i] = 0
}
for (i = 0; i < len; ++i) {
hist[src[i]]++
}
for (i = 0; i < 256; ++i) {
sum += hist[i] * i
}
for (i = 0; i < 256; ++i) {
wB += hist[i]
if (0 !== wB) {
wF = len - wB
if (0 === wF) {
break
}
sumB += hist[i] * i
mu = sumB / wB - (sum - sumB) / wF
between = wB * wF * mu * mu
if (between > max) {
max = between
threshold = i
}
}
}
return threshold
}
CV.stackBoxBlurMult = [1, 171, 205, 293, 57, 373, 79, 137, 241, 27, 391, 357, 41, 19, 283, 265]
CV.stackBoxBlurShift = [0, 9, 10, 11, 9, 12, 10, 11, 12, 9, 13, 13, 10, 9, 13, 13]
CV.BlurStack = function () {
this.color = 0
this.next = null
}
CV.stackBoxBlur = (imageSrc, imageDst, kernelSize) => {
var src = imageSrc.data,
dst = imageDst.data,
height = imageSrc.height,
width = imageSrc.width,
heightMinus1 = height - 1,
widthMinus1 = width - 1,
size = kernelSize + kernelSize + 1,
radius = kernelSize + 1,
mult = CV.stackBoxBlurMult[kernelSize],
shift = CV.stackBoxBlurShift[kernelSize],
stack,
stackStart,
color,
sum,
pos,
start,
p,
x,
y,
i
stack = stackStart = new CV.BlurStack()
for (i = 1; i < size; ++i) {
stack = stack.next = new CV.BlurStack()
}
stack.next = stackStart
pos = 0
for (y = 0; y < height; ++y) {
start = pos
color = src[pos]
sum = radius * color
stack = stackStart
for (i = 0; i < radius; ++i) {
stack.color = color
stack = stack.next
}
for (i = 1; i < radius; ++i) {
stack.color = src[pos + i]
sum += stack.color
stack = stack.next
}
stack = stackStart
for (x = 0; x < width; ++x) {
dst[pos++] = (sum * mult) >>> shift
p = x + radius
p = start + (p < widthMinus1 ? p : widthMinus1)
sum -= stack.color - src[p]
stack.color = src[p]
stack = stack.next
}
}
for (x = 0; x < width; ++x) {
pos = x
start = pos + width
color = dst[pos]
sum = radius * color
stack = stackStart
for (i = 0; i < radius; ++i) {
stack.color = color
stack = stack.next
}
for (i = 1; i < radius; ++i) {
stack.color = dst[start]
sum += stack.color
stack = stack.next
start += width
}
stack = stackStart
for (y = 0; y < height; ++y) {
dst[pos] = (sum * mult) >>> shift
p = y + radius
p = x + (p < heightMinus1 ? p : heightMinus1) * width
sum -= stack.color - dst[p]
stack.color = dst[p]
stack = stack.next
pos += width
}
}
return imageDst
}
CV.gaussianBlur = (imageSrc, imageDst, imageMean, kernelSize) => {
var kernel = CV.gaussianKernel(kernelSize)
imageDst.width = imageSrc.width
imageDst.height = imageSrc.height
imageMean.width = imageSrc.width
imageMean.height = imageSrc.height
CV.gaussianBlurFilter(imageSrc, imageMean, kernel, true)
CV.gaussianBlurFilter(imageMean, imageDst, kernel, false)
return imageDst
}
CV.gaussianBlurFilter = (imageSrc, imageDst, kernel, horizontal) => {
var src = imageSrc.data,
dst = imageDst.data,
height = imageSrc.height,
width = imageSrc.width,
pos = 0,
limit = kernel.length >> 1,
cur,
value,
i,
j,
k
for (i = 0; i < height; ++i) {
for (j = 0; j < width; ++j) {
value = 0.0
for (k = -limit; k <= limit; ++k) {
if (horizontal) {
cur = pos + k
if (j + k < 0) {
cur = pos
} else if (j + k >= width) {
cur = pos
}
} else {
cur = pos + k * width
if (i + k < 0) {
cur = pos
} else if (i + k >= height) {
cur = pos
}
}
value += kernel[limit + k] * src[cur]
}
dst[pos++] = horizontal ? value : (value + 0.5) & 0xff
}
}
return imageDst
}
CV.gaussianKernel = (kernelSize) => {
var tab = [
[1],
[0.25, 0.5, 0.25],
[0.0625, 0.25, 0.375, 0.25, 0.0625],
[0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125],
],
kernel = [],
center,
sigma,
scale2X,
sum,
x,
i
if (kernelSize <= 7 && kernelSize % 2 === 1) {
kernel = tab[kernelSize >> 1]
} else {
center = (kernelSize - 1.0) * 0.5
sigma = 0.8 + 0.3 * (center - 1.0)
scale2X = -0.5 / (sigma * sigma)
sum = 0.0
for (i = 0; i < kernelSize; ++i) {
x = i - center
sum += kernel[i] = Math.exp(scale2X * x * x)
}
sum = 1 / sum
for (i = 0; i < kernelSize; ++i) {
kernel[i] *= sum
}
}
return kernel
}
CV.findContours = (imageSrc, binary) => {
var width = imageSrc.width,
height = imageSrc.height,
contours = [],
src,
deltas,
pos,
pix,
nbd,
outer,
hole,
i,
j
src = CV.binaryBorder(imageSrc, binary)
deltas = CV.neighborhoodDeltas(width + 2)
pos = width + 3
nbd = 1
for (i = 0; i < height; ++i, pos += 2) {
for (j = 0; j < width; ++j, ++pos) {
pix = src[pos]
if (0 !== pix) {
outer = hole = false
if (1 === pix && 0 === src[pos - 1]) {
outer = true
} else if (pix >= 1 && 0 === src[pos + 1]) {
hole = true
}
if (outer || hole) {
++nbd
contours.push(CV.borderFollowing(src, pos, nbd, { x: j, y: i }, hole, deltas))
}
}
}
}
return contours
}
CV.borderFollowing = (src, pos, nbd, point, hole, deltas) => {
var contour = [],
pos1,
pos3,
pos4,
s,
s_end,
s_prev
contour.hole = hole
s = s_end = hole ? 0 : 4
do {
s = (s - 1) & 7
pos1 = pos + deltas[s]
if (src[pos1] !== 0) {
break
}
} while (s !== s_end)
if (s === s_end) {
src[pos] = -nbd
contour.push({ x: point.x, y: point.y })
} else {
pos3 = pos
s_prev = s ^ 4
while (true) {
s_end = s
do {
pos4 = pos3 + deltas[++s]
} while (src[pos4] === 0)
s &= 7
if ((s - 1) >>> 0 < s_end >>> 0) {
src[pos3] = -nbd
} else if (src[pos3] === 1) {
src[pos3] = nbd
}
contour.push({ x: point.x, y: point.y })
s_prev = s
point.x += CV.neighborhood[s][0]
point.y += CV.neighborhood[s][1]
if (pos4 === pos && pos3 === pos1) {
break
}
pos3 = pos4
s = (s + 4) & 7
}
}
return contour
}
CV.neighborhood = [
[1, 0],
[1, -1],
[0, -1],
[-1, -1],
[-1, 0],
[-1, 1],
[0, 1],
[1, 1],
]
CV.neighborhoodDeltas = (width) => {
var deltas = [],
len = CV.neighborhood.length,
i = 0
for (; i < len; ++i) {
deltas[i] = CV.neighborhood[i][0] + CV.neighborhood[i][1] * width
}
return deltas.concat(deltas)
}
CV.approxPolyDP = (contour, epsilon) => {
var slice = { start_index: 0, end_index: 0 },
right_slice = { start_index: 0, end_index: 0 },
poly = [],
stack = [],
len = contour.length,
pt,
start_pt,
end_pt,
dist,
max_dist,
le_eps,
dx,
dy,
i,
j,
k
epsilon *= epsilon
k = 0
for (i = 0; i < 3; ++i) {
max_dist = 0
k = (k + right_slice.start_index) % len
start_pt = contour[k]
if (++k === len) {
k = 0
}
for (j = 1; j < len; ++j) {
pt = contour[k]
if (++k === len) {
k = 0
}
dx = pt.x - start_pt.x
dy = pt.y - start_pt.y
dist = dx * dx + dy * dy
if (dist > max_dist) {
max_dist = dist
right_slice.start_index = j
}
}
}
if (max_dist <= epsilon) {
poly.push({ x: start_pt.x, y: start_pt.y })
} else {
slice.start_index = k
slice.end_index = right_slice.start_index += slice.start_index
right_slice.start_index -= right_slice.start_index >= len ? len : 0
right_slice.end_index = slice.start_index
if (right_slice.end_index < right_slice.start_index) {
right_slice.end_index += len
}
stack.push({
start_index: right_slice.start_index,
end_index: right_slice.end_index,
})
stack.push({ start_index: slice.start_index, end_index: slice.end_index })
}
while (stack.length !== 0) {
slice = stack.pop()
end_pt = contour[slice.end_index % len]
start_pt = contour[(k = slice.start_index % len)]
if (++k === len) {
k = 0
}
if (slice.end_index <= slice.start_index + 1) {
le_eps = true
} else {
max_dist = 0
dx = end_pt.x - start_pt.x
dy = end_pt.y - start_pt.y
for (i = slice.start_index + 1; i < slice.end_index; ++i) {
pt = contour[k]
if (++k === len) {
k = 0
}
dist = Math.abs((pt.y - start_pt.y) * dx - (pt.x - start_pt.x) * dy)
if (dist > max_dist) {
max_dist = dist
right_slice.start_index = i
}
}
le_eps = max_dist * max_dist <= epsilon * (dx * dx + dy * dy)
}
if (le_eps) {
poly.push({ x: start_pt.x, y: start_pt.y })
} else {
right_slice.end_index = slice.end_index
slice.end_index = right_slice.start_index
stack.push({
start_index: right_slice.start_index,
end_index: right_slice.end_index,
})
stack.push({
start_index: slice.start_index,
end_index: slice.end_index,
})
}
}
return poly
}
CV.warp = (imageSrc, imageDst, contour, warpSize) => {
var src = imageSrc.data,
dst = imageDst.data,
width = imageSrc.width,
height = imageSrc.height,
pos = 0,
sx1,
sx2,
dx1,
dx2,
sy1,
sy2,
dy1,
dy2,
p1,
p2,
p3,
p4,
m,
r,
s,
t,
u,
v,
w,
x,
y,
i,
j
m = CV.getPerspectiveTransform(contour, warpSize - 1)
r = m[8]
s = m[2]
t = m[5]
for (i = 0; i < warpSize; ++i) {
r += m[7]
s += m[1]
t += m[4]
u = r
v = s
w = t
for (j = 0; j < warpSize; ++j) {
u += m[6]
v += m[0]
w += m[3]
x = v / u
y = w / u
sx1 = x >>> 0
sx2 = sx1 === width - 1 ? sx1 : sx1 + 1
dx1 = x - sx1
dx2 = 1.0 - dx1
sy1 = y >>> 0
sy2 = sy1 === height - 1 ? sy1 : sy1 + 1
dy1 = y - sy1
dy2 = 1.0 - dy1
p1 = p2 = sy1 * width
p3 = p4 = sy2 * width
dst[pos++] =
(dy2 * (dx2 * src[p1 + sx1] + dx1 * src[p2 + sx2]) +
dy1 * (dx2 * src[p3 + sx1] + dx1 * src[p4 + sx2])) &
0xff
}
}
imageDst.width = warpSize
imageDst.height = warpSize
return imageDst
}
CV.getPerspectiveTransform = (src, size) => {
var rq = CV.square2quad(src)
rq[0] /= size
rq[1] /= size
rq[3] /= size
rq[4] /= size
rq[6] /= size
rq[7] /= size
return rq
}
CV.square2quad = (src) => {
var sq = [],
px,
py,
dx1,
dx2,
dy1,
dy2,
den
px = src[0].x - src[1].x + src[2].x - src[3].x
py = src[0].y - src[1].y + src[2].y - src[3].y
if (0 === px && 0 === py) {
sq[0] = src[1].x - src[0].x
sq[1] = src[2].x - src[1].x
sq[2] = src[0].x
sq[3] = src[1].y - src[0].y
sq[4] = src[2].y - src[1].y
sq[5] = src[0].y
sq[6] = 0
sq[7] = 0
sq[8] = 1
} else {
dx1 = src[1].x - src[2].x
dx2 = src[3].x - src[2].x
dy1 = src[1].y - src[2].y
dy2 = src[3].y - src[2].y
den = dx1 * dy2 - dx2 * dy1
sq[6] = (px * dy2 - dx2 * py) / den
sq[7] = (dx1 * py - px * dy1) / den
sq[8] = 1
sq[0] = src[1].x - src[0].x + sq[6] * src[1].x
sq[1] = src[3].x - src[0].x + sq[7] * src[3].x
sq[2] = src[0].x
sq[3] = src[1].y - src[0].y + sq[6] * src[1].y
sq[4] = src[3].y - src[0].y + sq[7] * src[3].y
sq[5] = src[0].y
}
return sq
}
CV.isContourConvex = (contour) => {
var orientation = 0,
convex = true,
len = contour.length,
i = 0,
j = 0,
cur_pt,
prev_pt,
dxdy0,
dydx0,
dx0,
dy0,
dx,
dy
prev_pt = contour[len - 1]
cur_pt = contour[0]
dx0 = cur_pt.x - prev_pt.x
dy0 = cur_pt.y - prev_pt.y
for (; i < len; ++i) {
if (++j === len) {
j = 0
}
prev_pt = cur_pt
cur_pt = contour[j]
dx = cur_pt.x - prev_pt.x
dy = cur_pt.y - prev_pt.y
dxdy0 = dx * dy0
dydx0 = dy * dx0
orientation |= dydx0 > dxdy0 ? 1 : dydx0 < dxdy0 ? 2 : 3
if (3 === orientation) {
convex = false
break
}
dx0 = dx
dy0 = dy
}
return convex
}
CV.perimeter = (poly) => {
var len = poly.length,
i = 0,
j = len - 1,
p = 0.0,
dx,
dy
for (; i < len; j = i++) {
dx = poly[i].x - poly[j].x
dy = poly[i].y - poly[j].y
p += Math.sqrt(dx * dx + dy * dy)
}
return p
}
CV.minEdgeLength = (poly) => {
var len = poly.length,
i = 0,
j = len - 1,
min = Infinity,
d,
dx,
dy
for (; i < len; j = i++) {
dx = poly[i].x - poly[j].x
dy = poly[i].y - poly[j].y
d = dx * dx + dy * dy
if (d < min) {
min = d
}
}
return Math.sqrt(min)
}
CV.countNonZero = (imageSrc, square) => {
var src = imageSrc.data,
height = square.height,
width = square.width,
pos = square.x + square.y * imageSrc.width,
span = imageSrc.width - width,
nz = 0,
i,
j
for (i = 0; i < height; ++i) {
for (j = 0; j < width; ++j) {
if (0 !== src[pos++]) {
++nz
}
}
pos += span
}
return nz
}
CV.binaryBorder = (imageSrc, dst) => {
var src = imageSrc.data,
height = imageSrc.height,
width = imageSrc.width,
posSrc = 0,
posDst = 0,
i,
j
for (j = -2; j < width; ++j) {
dst[posDst++] = 0
}
for (i = 0; i < height; ++i) {
dst[posDst++] = 0
for (j = 0; j < width; ++j) {
dst[posDst++] = 0 === src[posSrc++] ? 0 : 1
}
dst[posDst++] = 0
}
for (j = -2; j < width; ++j) {
dst[posDst++] = 0
}
return dst
}

10330
apps/web/public/opencv.js Normal file

File diff suppressed because one or more lines are too long

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)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
# Column Classifier Training Data Generator
Generates synthetic training images for the TensorFlow.js abacus column digit classifier used by the AbacusVisionBridge feature.
## Overview
This script renders single-column abacus SVGs for digits 0-9 using the `AbacusStatic` component from `@soroban/abacus-react`, then applies various augmentations to create diverse training data.
## Quick Start
```bash
# Generate training data (default: 5000 samples per digit = 50,000 total)
npx tsx scripts/train-column-classifier/generateTrainingData.ts
# Generate fewer samples for testing
npx tsx scripts/train-column-classifier/generateTrainingData.ts --samples 100
# Specify output directory
npx tsx scripts/train-column-classifier/generateTrainingData.ts --output ./my-training-data
# Set random seed for reproducibility
npx tsx scripts/train-column-classifier/generateTrainingData.ts --seed 42
# Dry run (show config without generating)
npx tsx scripts/train-column-classifier/generateTrainingData.ts --dry-run
```
## Output Structure
```
training-data/column-classifier/
├── 0/
│ ├── 0_circle-mono_00000.png
│ ├── 0_circle-mono_00001.png
│ └── ...
├── 1/
├── 2/
├── ...
├── 9/
├── metadata.json # Generation configuration and stats
└── labels.csv # Sample labels with augmentation params
```
## Image Specifications
- **Dimensions**: 64x128 pixels (width x height)
- **Format**: Grayscale PNG
- **Classes**: 10 (digits 0-9)
- **Default samples**: 5,000 per digit (50,000 total)
## Augmentations Applied
Each sample is randomly augmented with:
| Augmentation | Range | Purpose |
| ---------------- | -------------------- | ------------------------------ |
| Rotation | ±5° | Handle camera angle variations |
| Scale | 0.9-1.1x | Handle distance variations |
| Brightness | 0.8-1.2x | Handle lighting conditions |
| Gaussian noise | σ=10 | Handle camera sensor noise |
| Background color | 7 variations | Handle different surfaces |
| Blur | 0-1.5px (10% chance) | Handle focus issues |
## Style Variants
Training data includes all bead shapes and color schemes:
- `circle-mono` - Circle beads, monochrome
- `diamond-mono` - Diamond beads, monochrome
- `square-mono` - Square beads, monochrome
- `circle-heaven-earth` - Circle beads, heaven-earth colors
- `diamond-heaven-earth` - Diamond beads, heaven-earth colors
- `circle-place-value` - Circle beads, place-value colors
## Training the Model
After generating training data, use the Python training script:
```bash
# Install dependencies
pip install tensorflow numpy pillow
# Train the model
python scripts/train-column-classifier/train_model.py
# Export to TensorFlow.js format
tensorflowjs_converter \
--input_format=keras \
./models/column-classifier.keras \
./public/models/abacus-column-classifier/
```
## Model Architecture
The CNN architecture is designed for efficiency on mobile devices:
```
Input: 64x128x1 (grayscale)
├── Conv2D(32, 3x3) + ReLU + MaxPool(2x2)
├── Conv2D(64, 3x3) + ReLU + MaxPool(2x2)
├── Conv2D(128, 3x3) + ReLU + MaxPool(2x2)
├── Flatten
├── Dense(128) + ReLU + Dropout(0.5)
└── Dense(10) + Softmax
Output: 10 classes (digits 0-9)
```
Target model size: <2MB (quantized)
## Files
- `types.ts` - Type definitions and default configurations
- `renderColumn.tsx` - Single-column SVG rendering
- `augmentation.ts` - Image augmentation utilities
- `generateTrainingData.ts` - Main generation script
- `train_model.py` - Python training script (to be created)
## Requirements
- Node.js 18+
- `sharp` package (for image processing)
- `@soroban/abacus-react` package (workspace dependency)
## Notes
- Generation takes ~5-10 minutes for 50,000 samples
- Output size is approximately 200-300MB
- Training data is grayscale to focus on shape recognition
- The model will be integrated via `useColumnClassifier.ts` hook

View File

@@ -0,0 +1,217 @@
/**
* Image augmentation utilities for synthetic training data
*
* Applies various transformations to increase dataset diversity
*/
import sharp from 'sharp'
import type { AugmentationConfig } from './types'
/**
* Seeded random number generator for reproducibility
*/
export class SeededRandom {
private seed: number
constructor(seed: number = Date.now()) {
this.seed = seed
}
/** Generate a random number in [0, 1) */
next(): number {
// Simple LCG algorithm
this.seed = (this.seed * 1103515245 + 12345) & 0x7fffffff
return this.seed / 0x7fffffff
}
/** Generate a random number in [min, max] */
range(min: number, max: number): number {
return min + this.next() * (max - min)
}
/** Generate a random integer in [min, max] */
int(min: number, max: number): number {
return Math.floor(this.range(min, max + 1))
}
/** Pick a random item from an array */
pick<T>(items: T[]): T {
return items[this.int(0, items.length - 1)]
}
/** Return true with given probability */
probability(p: number): boolean {
return this.next() < p
}
}
export interface AugmentationResult {
/** Augmented image buffer */
buffer: Buffer
/** Applied augmentation parameters */
params: {
rotation: number
scale: number
brightness: number
noiseApplied: boolean
backgroundColor: string
blurRadius: number
}
}
/**
* Apply augmentations to an SVG image
*
* @param svgContent - The SVG string
* @param config - Augmentation configuration
* @param outputWidth - Target output width
* @param outputHeight - Target output height
* @param rng - Seeded random number generator
* @returns Augmented image buffer and applied parameters
*/
export async function augmentImage(
svgContent: string,
config: AugmentationConfig,
outputWidth: number,
outputHeight: number,
rng: SeededRandom
): Promise<AugmentationResult> {
// Generate random augmentation parameters
const rotation = rng.range(-config.rotationRange, config.rotationRange)
const scale = rng.range(config.scaleRange[0], config.scaleRange[1])
const brightness = rng.range(config.brightnessRange[0], config.brightnessRange[1])
const backgroundColor = rng.pick(config.backgroundColors)
const applyBlur = rng.probability(config.blurProbability)
const blurRadius = applyBlur ? rng.range(0.5, config.maxBlurRadius) : 0
const applyNoise = rng.probability(0.5) // 50% chance of noise
// Convert hex color to RGB
const bgRgb = hexToRgb(backgroundColor)
// Calculate scaled dimensions
const scaledWidth = Math.round(outputWidth * scale * 1.2) // Extra margin for rotation
const scaledHeight = Math.round(outputHeight * scale * 1.2)
// Start with the SVG
let pipeline = sharp(Buffer.from(svgContent))
.resize(scaledWidth, scaledHeight, {
fit: 'contain',
background: { r: bgRgb.r, g: bgRgb.g, b: bgRgb.b, alpha: 1 },
})
.rotate(rotation, {
background: { r: bgRgb.r, g: bgRgb.g, b: bgRgb.b, alpha: 1 },
})
.extract({
left: Math.round((scaledWidth - outputWidth) / 2),
top: Math.round((scaledHeight - outputHeight) / 2),
width: outputWidth,
height: outputHeight,
})
.modulate({ brightness })
// Apply blur if selected
if (blurRadius > 0) {
pipeline = pipeline.blur(blurRadius)
}
// Convert to grayscale for training (reduces complexity, focuses on shape)
pipeline = pipeline.grayscale()
// Get the buffer
let buffer = await pipeline.png().toBuffer()
// Apply noise if selected
if (applyNoise && config.noiseStdDev > 0) {
buffer = await addGaussianNoise(buffer, config.noiseStdDev, rng)
}
return {
buffer,
params: {
rotation,
scale,
brightness,
noiseApplied: applyNoise,
backgroundColor,
blurRadius,
},
}
}
/**
* Add Gaussian noise to an image
*/
async function addGaussianNoise(
imageBuffer: Buffer,
stdDev: number,
rng: SeededRandom
): Promise<Buffer> {
const { data, info } = await sharp(imageBuffer).raw().toBuffer({ resolveWithObject: true })
const pixels = new Uint8Array(data)
for (let i = 0; i < pixels.length; i++) {
// Box-Muller transform for Gaussian noise
const u1 = rng.next()
const u2 = rng.next()
const noise = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) * stdDev
// Clamp to valid range
pixels[i] = Math.max(0, Math.min(255, Math.round(pixels[i] + noise)))
}
return sharp(Buffer.from(pixels), {
raw: {
width: info.width,
height: info.height,
channels: info.channels,
},
})
.png()
.toBuffer()
}
/**
* Convert hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
if (!result) {
return { r: 255, g: 255, b: 255 } // Default to white
}
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
}
/**
* Generate a batch of augmented images from a single SVG
*
* @param svgContent - The SVG string
* @param count - Number of augmented images to generate
* @param config - Augmentation configuration
* @param outputWidth - Target output width
* @param outputHeight - Target output height
* @param seed - Random seed for reproducibility
* @returns Array of augmented image results
*/
export async function generateAugmentedBatch(
svgContent: string,
count: number,
config: AugmentationConfig,
outputWidth: number,
outputHeight: number,
seed: number
): Promise<AugmentationResult[]> {
const rng = new SeededRandom(seed)
const results: AugmentationResult[] = []
for (let i = 0; i < count; i++) {
const result = await augmentImage(svgContent, config, outputWidth, outputHeight, rng)
results.push(result)
}
return results
}

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env npx tsx
/**
* Generate synthetic training data for the abacus column classifier
*
* This script:
* 1. Renders single-column abacus SVGs for digits 0-9 using AbacusStatic
* 2. Applies data augmentation (rotation, scale, brightness, noise)
* 3. Generates ~5000 samples per digit (50,000 total) across various styles
* 4. Outputs grayscale PNG images (64x128) organized by digit
*
* Usage:
* npx tsx scripts/train-column-classifier/generateTrainingData.ts [options]
*
* Options:
* --samples <n> Number of samples per digit (default: 5000)
* --output <dir> Output directory (default: ./training-data/column-classifier)
* --seed <n> Random seed for reproducibility
* --dry-run Print config without generating
*/
import fs from 'fs'
import path from 'path'
import { parseArgs } from 'util'
import { renderColumnSVG } from './renderColumn'
import { SeededRandom, augmentImage } from './augmentation'
import {
type GenerationConfig,
type GenerationProgress,
type GeneratedSample,
DEFAULT_GENERATION_CONFIG,
ABACUS_STYLE_VARIANTS,
} from './types'
// Parse command line arguments
const { values } = parseArgs({
options: {
samples: { type: 'string', short: 's' },
output: { type: 'string', short: 'o' },
seed: { type: 'string' },
'dry-run': { type: 'boolean' },
},
})
// Build configuration
const config: GenerationConfig = {
...DEFAULT_GENERATION_CONFIG,
samplesPerDigit: values.samples
? parseInt(values.samples, 10)
: DEFAULT_GENERATION_CONFIG.samplesPerDigit,
outputDir: values.output || DEFAULT_GENERATION_CONFIG.outputDir,
seed: values.seed ? parseInt(values.seed, 10) : undefined,
}
// Progress tracking
const progress: GenerationProgress = {
total: config.samplesPerDigit * 10, // 10 digits
completed: 0,
currentDigit: 0,
errors: [],
}
/**
* Generate training data for a single digit
*/
async function generateDigitSamples(
digit: number,
config: GenerationConfig,
rng: SeededRandom
): Promise<GeneratedSample[]> {
const samples: GeneratedSample[] = []
const digitDir = path.join(config.outputDir, digit.toString())
// Create digit directory
fs.mkdirSync(digitDir, { recursive: true })
// Distribute samples across style variants
const stylesCount = ABACUS_STYLE_VARIANTS.length
const samplesPerStyle = Math.ceil(config.samplesPerDigit / stylesCount)
let sampleIndex = 0
for (const style of ABACUS_STYLE_VARIANTS) {
// Generate base SVG for this style
const svgContent = renderColumnSVG(digit, style)
// Generate augmented samples for this style
const samplesForThisStyle = Math.min(samplesPerStyle, config.samplesPerDigit - sampleIndex)
for (let i = 0; i < samplesForThisStyle; i++) {
try {
const result = await augmentImage(
svgContent,
config.augmentation,
config.outputWidth,
config.outputHeight,
rng
)
// Generate filename with metadata
const filename = `${digit}_${style.name}_${sampleIndex.toString().padStart(5, '0')}.png`
const filePath = path.join(digitDir, filename)
// Save image
fs.writeFileSync(filePath, result.buffer)
samples.push({
filePath,
digit,
augmentation: result.params,
})
sampleIndex++
progress.completed++
// Progress update every 100 samples
if (progress.completed % 100 === 0) {
const pct = ((progress.completed / progress.total) * 100).toFixed(1)
process.stdout.write(`\rProgress: ${pct}% (${progress.completed}/${progress.total})`)
}
} catch (error) {
const errorMsg = `Error generating sample ${sampleIndex} for digit ${digit}: ${error}`
progress.errors.push(errorMsg)
console.error(`\n${errorMsg}`)
}
}
}
return samples
}
/**
* Main generation function
*/
async function generateAllTrainingData(): Promise<void> {
console.log('=== Abacus Column Classifier Training Data Generator ===\n')
console.log('Configuration:')
console.log(` Samples per digit: ${config.samplesPerDigit}`)
console.log(` Total samples: ${config.samplesPerDigit * 10}`)
console.log(` Output size: ${config.outputWidth}x${config.outputHeight}`)
console.log(` Output directory: ${config.outputDir}`)
console.log(` Style variants: ${ABACUS_STYLE_VARIANTS.length}`)
console.log(` Random seed: ${config.seed || 'random'}`)
console.log()
if (values['dry-run']) {
console.log('Dry run - no files generated')
console.log('\nStyle variants:')
ABACUS_STYLE_VARIANTS.forEach((style) => {
console.log(` - ${style.name}: ${style.beadShape}, ${style.colorScheme}`)
})
return
}
// Create output directory
fs.mkdirSync(config.outputDir, { recursive: true })
// Initialize RNG
const rng = new SeededRandom(config.seed)
const allSamples: GeneratedSample[] = []
const startTime = Date.now()
console.log('Generating samples...\n')
// Generate samples for each digit
for (let digit = 0; digit <= 9; digit++) {
progress.currentDigit = digit
console.log(`\nGenerating digit ${digit}...`)
const samples = await generateDigitSamples(digit, config, rng)
allSamples.push(...samples)
}
const duration = ((Date.now() - startTime) / 1000).toFixed(1)
console.log('\n\n=== Generation Complete ===')
console.log(`Total samples generated: ${allSamples.length}`)
console.log(`Duration: ${duration}s`)
console.log(`Output directory: ${config.outputDir}`)
if (progress.errors.length > 0) {
console.log(`\nErrors encountered: ${progress.errors.length}`)
progress.errors.slice(0, 5).forEach((err) => console.log(` - ${err}`))
if (progress.errors.length > 5) {
console.log(` ... and ${progress.errors.length - 5} more`)
}
}
// Generate metadata file
const metadata = {
generatedAt: new Date().toISOString(),
config: {
samplesPerDigit: config.samplesPerDigit,
outputWidth: config.outputWidth,
outputHeight: config.outputHeight,
seed: config.seed,
augmentation: config.augmentation,
},
styleVariants: ABACUS_STYLE_VARIANTS,
totalSamples: allSamples.length,
samplesPerDigit: Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [i, allSamples.filter((s) => s.digit === i).length])
),
errors: progress.errors,
}
const metadataPath = path.join(config.outputDir, 'metadata.json')
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2))
console.log(`\nMetadata saved to: ${metadataPath}`)
// Generate labels CSV
const labelsPath = path.join(config.outputDir, 'labels.csv')
const labelsContent = ['filename,digit,style,rotation,scale,brightness']
.concat(
allSamples.map((s) => {
const filename = path.basename(s.filePath)
const style = filename.split('_')[1]
return `${filename},${s.digit},${style},${s.augmentation.rotation.toFixed(2)},${s.augmentation.scale.toFixed(2)},${s.augmentation.brightness.toFixed(2)}`
})
)
.join('\n')
fs.writeFileSync(labelsPath, labelsContent)
console.log(`Labels CSV saved to: ${labelsPath}`)
}
// Run the generator
generateAllTrainingData().catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})

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